From 843355a4174943bef9e3c67078af228404ff6329 Mon Sep 17 00:00:00 2001 From: duyifeng01 Date: Sat, 11 Jul 2026 13:05:53 +0800 Subject: [PATCH 1/4] feat(mcp): add HugeGraph MCP V1 core and guarded workflows Change-Id: Iaec68dbf134040f94b6fd13cd257d66542d1ae9d --- .../hugegraph_llm/api/models/rag_requests.py | 26 +- .../hugegraph_llm/api/models/rag_response.py | 27 +- .../src/hugegraph_llm/api/thin_api.py | 122 ++ .../src/hugegraph_llm/demo/rag_demo/app.py | 2 + .../hugegraph_llm/flows/import_graph_data.py | 3 +- .../operators/hugegraph_op/schema_manager.py | 4 +- hugegraph-mcp/hugegraph_mcp/__init__.py | 14 + hugegraph-mcp/hugegraph_mcp/config.py | 177 +++ hugegraph-mcp/hugegraph_mcp/envelope.py | 171 +++ hugegraph-mcp/hugegraph_mcp/gremlin_policy.py | 364 +++++ hugegraph-mcp/hugegraph_mcp/gremlin_safety.py | 34 + hugegraph-mcp/hugegraph_mcp/gremlin_tools.py | 389 ++++++ hugegraph-mcp/hugegraph_mcp/guard.py | 85 ++ .../hugegraph_mcp/hugegraph_ai_client.py | 213 +++ .../hugegraph_mcp/hugegraph_client.py | 39 + hugegraph-mcp/hugegraph_mcp/plan_hash.py | 210 +++ hugegraph-mcp/hugegraph_mcp/schema_tools.py | 125 ++ hugegraph-mcp/hugegraph_mcp/server.py | 447 ++++++ hugegraph-mcp/hugegraph_mcp/tools/__init__.py | 16 + .../hugegraph_mcp/tools/extract_graph_data.py | 156 +++ .../hugegraph_mcp/tools/generate_gremlin.py | 135 ++ .../hugegraph_mcp/tools/graph_data_execute.py | 795 +++++++++++ .../hugegraph_mcp/tools/graph_data_gremlin.py | 141 ++ .../hugegraph_mcp/tools/graph_data_mapping.py | 195 +++ .../tools/graph_data_validate.py | 486 +++++++ .../hugegraph_mcp/tools/ingest_graph_data.py | 1197 +++++++++++++++++ .../hugegraph_mcp/tools/inspect_graph.py | 199 +++ .../hugegraph_mcp/tools/live_schema.py | 39 + .../hugegraph_mcp/tools/manage_graph_data.py | 390 ++++++ .../hugegraph_mcp/tools/manage_schema.py | 546 ++++++++ .../tools/refresh_vid_embeddings.py | 88 ++ .../hugegraph_mcp/tools/schema_utils.py | 164 +++ hugegraph-mcp/pyproject.toml | 60 + .../src/pyhugegraph/utils/huge_config.py | 3 +- .../src/pyhugegraph/utils/log.py | 14 +- pyproject.toml | 8 +- 36 files changed, 7074 insertions(+), 10 deletions(-) create mode 100644 hugegraph-llm/src/hugegraph_llm/api/thin_api.py create mode 100644 hugegraph-mcp/hugegraph_mcp/__init__.py create mode 100644 hugegraph-mcp/hugegraph_mcp/config.py create mode 100644 hugegraph-mcp/hugegraph_mcp/envelope.py create mode 100644 hugegraph-mcp/hugegraph_mcp/gremlin_policy.py create mode 100644 hugegraph-mcp/hugegraph_mcp/gremlin_safety.py create mode 100644 hugegraph-mcp/hugegraph_mcp/gremlin_tools.py create mode 100644 hugegraph-mcp/hugegraph_mcp/guard.py create mode 100644 hugegraph-mcp/hugegraph_mcp/hugegraph_ai_client.py create mode 100644 hugegraph-mcp/hugegraph_mcp/hugegraph_client.py create mode 100644 hugegraph-mcp/hugegraph_mcp/plan_hash.py create mode 100644 hugegraph-mcp/hugegraph_mcp/schema_tools.py create mode 100644 hugegraph-mcp/hugegraph_mcp/server.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/__init__.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/extract_graph_data.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/generate_gremlin.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/graph_data_execute.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/graph_data_gremlin.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/graph_data_mapping.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/graph_data_validate.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/ingest_graph_data.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/inspect_graph.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/live_schema.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/refresh_vid_embeddings.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/schema_utils.py create mode 100644 hugegraph-mcp/pyproject.toml diff --git a/hugegraph-llm/src/hugegraph_llm/api/models/rag_requests.py b/hugegraph-llm/src/hugegraph_llm/api/models/rag_requests.py index df65e268a..e77394215 100644 --- a/hugegraph-llm/src/hugegraph_llm/api/models/rag_requests.py +++ b/hugegraph-llm/src/hugegraph_llm/api/models/rag_requests.py @@ -19,7 +19,7 @@ from typing import List, Literal, Optional from fastapi import Query -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from hugegraph_llm.config import prompt @@ -167,3 +167,27 @@ def validate_prompt_placeholders(cls, v): if missing: raise ValueError(f"Prompt template is missing required placeholders: {', '.join(missing)}") return v + + +class GraphExtractRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + text: str + graph_schema: Optional[str] = Field(default=None, alias="schema") + example_prompt: Optional[str] = None + language: str = "zh" + + +class GraphImportRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + data: str + graph_schema: Optional[str] = Field(default=None, alias="schema") + + +class VidEmbeddingsRefreshRequest(BaseModel): + pass + + +class GraphIndexInfoRequest(BaseModel): + pass diff --git a/hugegraph-llm/src/hugegraph_llm/api/models/rag_response.py b/hugegraph-llm/src/hugegraph_llm/api/models/rag_response.py index fe139eeb8..3780ade03 100644 --- a/hugegraph-llm/src/hugegraph_llm/api/models/rag_response.py +++ b/hugegraph-llm/src/hugegraph_llm/api/models/rag_response.py @@ -15,9 +15,34 @@ # specific language governing permissions and limitations # under the License. -from pydantic import BaseModel +from typing import Any + +from pydantic import BaseModel, Field class RAGResponse(BaseModel): status_code: int = -1 message: str = "" + + +class ThinAPIError(BaseModel): + type: str + message: str + suggestion: str | None = None + retryable: bool = False + source: str = "hugegraph-llm" + details: dict[str, Any] = Field(default_factory=dict) + + +class ThinAPIMeta(BaseModel): + request_id: str + duration_ms: float = 0.0 + + +class ThinAPIResponse(BaseModel): + ok: bool + data: Any = None + error: ThinAPIError | None = None + warnings: list[str] = Field(default_factory=list) + next_actions: list[str] = Field(default_factory=list) + meta: ThinAPIMeta diff --git a/hugegraph-llm/src/hugegraph_llm/api/thin_api.py b/hugegraph-llm/src/hugegraph_llm/api/thin_api.py new file mode 100644 index 000000000..6f6629545 --- /dev/null +++ b/hugegraph-llm/src/hugegraph_llm/api/thin_api.py @@ -0,0 +1,122 @@ +# 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. + +import time +from typing import Any +from uuid import uuid4 + +from fastapi import APIRouter, status + +from hugegraph_llm.api.models.rag_requests import ( + GraphExtractRequest, + GraphImportRequest, + VidEmbeddingsRefreshRequest, +) +from hugegraph_llm.api.models.rag_response import ThinAPIResponse +from hugegraph_llm.flows import FlowName +from hugegraph_llm.flows.scheduler import SchedulerSingleton +from hugegraph_llm.utils.log import log + +thin_router = APIRouter() + + +def _generate_request_id() -> str: + return f"req-{uuid4().hex[:12]}" + + +def _envelope_ok( + data: Any, *, warnings: list[str] | None = None, next_actions: list[str] | None = None +) -> dict[str, Any]: + return { + "ok": True, + "data": data, + "error": None, + "warnings": warnings or [], + "next_actions": next_actions or [], + "meta": { + "request_id": _generate_request_id(), + "duration_ms": 0, + }, + } + + +def _envelope_err( + error_type: str, message: str, *, suggestion: str | None = None, details: Any = None +) -> dict[str, Any]: + return { + "ok": False, + "data": None, + "error": { + "type": error_type, + "message": message, + "suggestion": suggestion, + "retryable": False, + "source": "hugegraph-llm", + "details": details if details is not None else {}, + }, + "warnings": [], + "next_actions": [], + "meta": { + "request_id": _generate_request_id(), + "duration_ms": 0, + }, + } + + +def _wrap_flow_call(flow_name: FlowName, *args: Any) -> dict[str, Any]: + start = time.perf_counter() + try: + result = SchedulerSingleton.get_instance().schedule_flow(flow_name, *args) + envelope = _envelope_ok(result) + envelope["meta"]["duration_ms"] = (time.perf_counter() - start) * 1000.0 + return envelope + except Exception as exc: + log.error("Thin API flow execution failed: %s", exc, exc_info=True) + envelope = _envelope_err( + "FLOW_EXECUTION_FAILED", + "An internal error occurred during flow execution.", + suggestion="Check HugeGraph-AI service logs for details.", + ) + envelope["meta"]["duration_ms"] = (time.perf_counter() - start) * 1000.0 + return envelope + + +@thin_router.post("/graph-extract", status_code=status.HTTP_200_OK, response_model=ThinAPIResponse) +def graph_extract_api(req: GraphExtractRequest): + return _wrap_flow_call( + FlowName.GRAPH_EXTRACT, + req.graph_schema, + req.text, + req.example_prompt, + "property_graph", + req.language, + ) + + +@thin_router.post("/graph-import", status_code=status.HTTP_200_OK, response_model=ThinAPIResponse) +def graph_import_api(req: GraphImportRequest): + return _wrap_flow_call(FlowName.IMPORT_GRAPH_DATA, req.data, req.graph_schema) + + +@thin_router.post("/vid-embeddings/refresh", status_code=status.HTTP_200_OK, response_model=ThinAPIResponse) +def vid_embeddings_refresh_api(_req: VidEmbeddingsRefreshRequest): + return _wrap_flow_call(FlowName.UPDATE_VID_EMBEDDINGS) + + +@thin_router.get("/graph-index-info", status_code=status.HTTP_200_OK, response_model=ThinAPIResponse) +def graph_index_info_api(): + return _wrap_flow_call(FlowName.GET_GRAPH_INDEX_INFO) diff --git a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py index fcb7cac2a..0bff2ced6 100644 --- a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py +++ b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py @@ -26,6 +26,7 @@ from hugegraph_llm.api.admin_api import admin_http_api from hugegraph_llm.api.graph_extract_api import graph_extract_http_api from hugegraph_llm.api.rag_api import rag_http_api +from hugegraph_llm.api.thin_api import thin_router from hugegraph_llm.config import admin_settings, huge_settings, prompt from hugegraph_llm.demo.rag_demo.admin_block import create_admin_block, log_stream from hugegraph_llm.demo.rag_demo.configs_block import ( @@ -179,6 +180,7 @@ def create_app(): gremlin_generate_selective, ) admin_http_api(api_auth, log_stream) + api_auth.include_router(thin_router) graph_extract_http_api(api_auth) app.include_router(api_auth) diff --git a/hugegraph-llm/src/hugegraph_llm/flows/import_graph_data.py b/hugegraph-llm/src/hugegraph_llm/flows/import_graph_data.py index ac0f2ab1a..bb2182011 100644 --- a/hugegraph-llm/src/hugegraph_llm/flows/import_graph_data.py +++ b/hugegraph-llm/src/hugegraph_llm/flows/import_graph_data.py @@ -15,7 +15,6 @@ import json -import gradio as gr from pycgraph import GPipeline from hugegraph_llm.flows.common import BaseFlow @@ -64,5 +63,5 @@ def build_flow(self, data, schema, **kwargs): def post_deal(self, pipeline=None, **kwargs): res = pipeline.getGParamWithNoEmpty("wkflow_state").to_json() - gr.Info("Import graph data successfully!") + log.info("Import graph data completed successfully.") return json.dumps(res, ensure_ascii=False, indent=2) diff --git a/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/schema_manager.py b/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/schema_manager.py index a862ea446..f87e9b455 100644 --- a/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/schema_manager.py +++ b/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/schema_manager.py @@ -74,7 +74,9 @@ def run(self, context: Optional[Dict[str, Any]]) -> Dict[str, Any]: schema = self.schema.getSchema() except RequestException as e: raise ValueError(f"Failed to connect to HugeGraph to get schema '{self.graph_name}': {e}") from e - if not schema["vertexlabels"] and not schema["edgelabels"]: + if not isinstance(schema, dict): + raise ValueError(f"Cannot get {self.graph_name}'s schema from HugeGraph!") + if not schema.get("vertexlabels") and not schema.get("edgelabels"): raise ValueError(f"Cannot get {self.graph_name}'s schema from HugeGraph!") context.update({"schema": schema}) diff --git a/hugegraph-mcp/hugegraph_mcp/__init__.py b/hugegraph-mcp/hugegraph_mcp/__init__.py new file mode 100644 index 000000000..fdbd0208b --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/__init__.py @@ -0,0 +1,14 @@ +# 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. + +# HugeGraph MCP package init diff --git a/hugegraph-mcp/hugegraph_mcp/config.py b/hugegraph-mcp/hugegraph_mcp/config.py new file mode 100644 index 000000000..c2dc61f6e --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/config.py @@ -0,0 +1,177 @@ +# 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. + +"""统一配置层 — 所有 MCP 工具通过 MCPConfig.from_env() 获取配置。 + +环境变量优先级高于默认值,避免硬编码连接信息。""" + +import logging +import os +from dataclasses import dataclass, field +from typing import Mapping + +TRUE_VALUES = {"1", "true", "yes"} +LOGGER = logging.getLogger("hugegraph_mcp.config") +CONFIG_ENV_NAMES = ( + "HUGEGRAPH_URL", + "HUGEGRAPH_GRAPH_PATH", + "HUGEGRAPH_GRAPH", + "HUGEGRAPH_GRAPHSPACE", + "HUGEGRAPH_USER", + "HUGEGRAPH_PASSWORD", + "HUGEGRAPH_MCP_READONLY", + "HUGEGRAPH_AI_URL", + "HUGEGRAPH_AI_GRAPH_URL", + "HUGEGRAPH_MCP_ALLOW_AI", + "HUGEGRAPH_MCP_ADMIN_MODE", + "HUGEGRAPH_MCP_TIMEOUT_SECONDS", +) +_CONFIG_CACHE_KEY: tuple[tuple[str, str | None], ...] | None = None +_CONFIG_CACHE_VALUE = None + + +@dataclass +class MCPConfig: + """MCP 服务器统一配置,所有字段从环境变量读取,有合理默认值。""" + + url: str = "http://127.0.0.1:8080" + graph: str = "hugegraph" + graphspace: str | None = "DEFAULT" + user: str = "admin" + password: str = "" + readonly: bool = True + ai_url: str = "http://127.0.0.1:8001" + ai_graph_url: str | None = None + allow_ai: bool = False + admin_mode: bool = False + timeout_seconds: int = 30 + warnings: tuple[str, ...] = field(default_factory=tuple) + + @classmethod + def from_env(cls, env: Mapping[str, str] | None = None) -> "MCPConfig": + global _CONFIG_CACHE_KEY, _CONFIG_CACHE_VALUE + + use_cache = env is None + env = env if env is not None else os.environ + cache_key = _env_cache_key(env) if use_cache else None + if ( + use_cache + and cache_key == _CONFIG_CACHE_KEY + and _CONFIG_CACHE_VALUE is not None + ): + return _CONFIG_CACHE_VALUE + + warnings: list[str] = [] + + path_graphspace, path_graph = _parse_graph_path( + env.get("HUGEGRAPH_GRAPH_PATH", "DEFAULT/hugegraph") + ) + graphspace = path_graphspace + graph = path_graph + + split_graphspace = env.get("HUGEGRAPH_GRAPHSPACE") + split_graph = env.get("HUGEGRAPH_GRAPH") + if env.get("HUGEGRAPH_GRAPH_PATH") is not None and ( + split_graphspace is not None or split_graph is not None + ): + warnings.append( + "HUGEGRAPH_GRAPHSPACE/HUGEGRAPH_GRAPH override HUGEGRAPH_GRAPH_PATH" + ) + + if split_graphspace is not None: + graphspace = _non_empty(split_graphspace, "DEFAULT") + if split_graph is not None: + graph = _non_empty(split_graph, "hugegraph") + + config = cls( + url=env.get("HUGEGRAPH_URL", "http://127.0.0.1:8080"), + graph=graph, + graphspace=graphspace, + user=env.get("HUGEGRAPH_USER", "admin"), + password=env.get("HUGEGRAPH_PASSWORD", ""), + readonly=_parse_bool(env.get("HUGEGRAPH_MCP_READONLY", "true")), + ai_url=env.get("HUGEGRAPH_AI_URL", "http://127.0.0.1:8001"), + ai_graph_url=_optional_non_empty(env.get("HUGEGRAPH_AI_GRAPH_URL")), + allow_ai=_parse_bool(env.get("HUGEGRAPH_MCP_ALLOW_AI", "")), + admin_mode=_parse_bool(env.get("HUGEGRAPH_MCP_ADMIN_MODE", "")), + timeout_seconds=_parse_int(env.get("HUGEGRAPH_MCP_TIMEOUT_SECONDS"), 30), + warnings=tuple(warnings), + ) + for warning in config.warnings: + LOGGER.warning(warning) + if use_cache: + _CONFIG_CACHE_KEY = cache_key + _CONFIG_CACHE_VALUE = config + return config + + def is_readonly(self) -> bool: + return self.readonly + + +def _parse_graph_path(graph_path: str) -> tuple[str, str]: + if "/" in graph_path: + graphspace, graph = graph_path.split("/", 1) + else: + graphspace, graph = "DEFAULT", graph_path + + return _non_empty(graphspace, "DEFAULT"), _non_empty(graph, "hugegraph") + + +def _parse_bool(value: str) -> bool: + return value.strip().lower() in TRUE_VALUES + + +def _parse_int(value: str | None, default: int) -> int: + if value is None or value.strip() == "": + return default + try: + parsed = int(value) + except ValueError: + LOGGER.warning( + "Invalid integer config value %r; using default %s", value, default + ) + return default + if parsed <= 0: + LOGGER.warning( + "Invalid integer config value %r; using default %s", value, default + ) + return default + return parsed + + +def _non_empty(value: str, default: str) -> str: + return value.strip() or default + + +def _optional_non_empty(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + + +def _env_cache_key(env: Mapping[str, str]) -> tuple[tuple[str, str | None], ...]: + return tuple((name, env.get(name)) for name in CONFIG_ENV_NAMES) + + +class RuntimeConfigProxy: + """Compatibility proxy for code that imports config directly.""" + + def __getattr__(self, name: str): + return getattr(MCPConfig.from_env(), name) + + def is_readonly(self) -> bool: + return MCPConfig.from_env().is_readonly() + + +config = RuntimeConfigProxy() diff --git a/hugegraph-mcp/hugegraph_mcp/envelope.py b/hugegraph-mcp/hugegraph_mcp/envelope.py new file mode 100644 index 000000000..2b1d38412 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/envelope.py @@ -0,0 +1,171 @@ +# 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. + +"""统一响应信封 — 所有 MCP 工具通过 envelope_ok/envelope_err 返回一致结构。 + +强制格式: {ok, data, error, warnings, next_actions, meta} +前端/Agent 无需猜测返回形状,始终可安全解析。""" + +from enum import Enum +from typing import Any +from uuid import uuid4 + +from hugegraph_mcp.config import MCPConfig + + +class ErrorType(str, Enum): + """标准化错误类型枚举 — 按能力域划分,便于 Agent 分类处理。""" + + CONNECTION_FAILED = "CONNECTION_FAILED" + AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED" + AUTHORIZATION_FAILED = "AUTHORIZATION_FAILED" + VALIDATION_ERROR = "VALIDATION_ERROR" + READONLY_VIOLATION = "READONLY_VIOLATION" + CONFIRM_REQUIRED = "CONFIRM_REQUIRED" + PLAN_HASH_MISMATCH = "PLAN_HASH_MISMATCH" + PLAN_EXPIRED = "PLAN_EXPIRED" + NOT_FOUND = "NOT_FOUND" + NO_INDEX = "NO_INDEX" + UNSAFE_GREMLIN = "UNSAFE_GREMLIN" + QUERY_SYNTAX_ERROR = "QUERY_SYNTAX_ERROR" + SERVER_ERROR = "SERVER_ERROR" + SCHEMA_MISMATCH = "SCHEMA_MISMATCH" + INVALID_GRAPH_DATA = "INVALID_GRAPH_DATA" + HUGEGRAPH_AI_UNAVAILABLE = "HUGEGRAPH_AI_UNAVAILABLE" + FEATURE_DISABLED = "FEATURE_DISABLED" + FLOW_EXECUTION_FAILED = "FLOW_EXECUTION_FAILED" + LLM_FAILED = "LLM_FAILED" + EMBEDDING_FAILED = "EMBEDDING_FAILED" + TIMEOUT = "TIMEOUT" + + +def generate_request_id() -> str: + return f"req-{uuid4().hex[:12]}" + + +def build_meta( + *, + duration_ms: float | int | None = None, + request_id: str | None = None, + graph: str | None = None, + graphspace: str | None = None, + readonly: bool | None = None, + extra_meta: dict[str, Any] | None = None, + **kwargs: Any, +) -> dict[str, Any]: + cfg = MCPConfig.from_env() + meta = { + "request_id": request_id or generate_request_id(), + "graph": cfg.graph if graph is None else graph, + "graphspace": cfg.graphspace if graphspace is None else graphspace, + "readonly": cfg.readonly if readonly is None else readonly, + } + + if duration_ms is not None: + meta["duration_ms"] = duration_ms + if extra_meta: + meta.update(extra_meta) + if kwargs: + meta.update(kwargs) + + return meta + + +def envelope_ok( + data: Any = None, + *, + duration_ms: float | int | None = None, + warnings: list[str] | tuple[str, ...] | None = None, + next_actions: list[str] | None = None, + meta: dict[str, Any] | None = None, + request_id: str | None = None, + graph: str | None = None, + graphspace: str | None = None, + readonly: bool | None = None, + **meta_fields: Any, +) -> dict[str, Any]: + envelope_meta = build_meta( + duration_ms=duration_ms, + request_id=request_id, + graph=graph, + graphspace=graphspace, + readonly=readonly, + extra_meta=meta, + **meta_fields, + ) + return { + "ok": True, + "data": data, + "error": None, + "warnings": list(warnings or []), + "next_actions": list(next_actions or []), + "meta": envelope_meta, + } + + +def envelope_err( + error_type: ErrorType | str, + message: str, + *, + suggestion: str | None = None, + retryable: bool = False, + source: str = "hugegraph-mcp", + details: Any = None, + duration_ms: float | int | None = None, + warnings: list[str] | tuple[str, ...] | None = None, + next_actions: list[str] | None = None, + meta: dict[str, Any] | None = None, + request_id: str | None = None, + graph: str | None = None, + graphspace: str | None = None, + readonly: bool | None = None, + **meta_fields: Any, +) -> dict[str, Any]: + error_value = ( + error_type.value if isinstance(error_type, ErrorType) else str(error_type) + ) + error: dict[str, Any] = { + "type": error_value, + "message": message, + "suggestion": suggestion, + "retryable": retryable, + "source": source, + "details": details if details is not None else {}, + } + + envelope_meta = build_meta( + duration_ms=duration_ms, + request_id=request_id, + graph=graph, + graphspace=graphspace, + readonly=readonly, + extra_meta=meta, + **meta_fields, + ) + return { + "ok": False, + "data": None, + "error": error, + "warnings": list(warnings or []), + "next_actions": list(next_actions or []), + "meta": envelope_meta, + } + + +# 向后兼容别名 — 旧代码可通过多种名称引用同一函数 +make_ok_envelope = envelope_ok +make_err_envelope = envelope_err +ok_envelope = envelope_ok +err_envelope = envelope_err +ok = envelope_ok +err = envelope_err diff --git a/hugegraph-mcp/hugegraph_mcp/gremlin_policy.py b/hugegraph-mcp/hugegraph_mcp/gremlin_policy.py new file mode 100644 index 000000000..99fc4b9f5 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/gremlin_policy.py @@ -0,0 +1,364 @@ +# 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. + +"""GremlinPolicy — 统一的 Gremlin 安全策略层。 + +所有 MCP Gremlin 读执行路径通过 GremlinPolicy.check_read() 做安全检查。 +返回结构化决策,包含 allowed、classification、reason、error_type、suggestion。 + +本模块同时拥有 Gremlin 安全分类器的实现(原 gremlin_safety.py)。 +gremlin_safety.py 保留为兼容 wrapper,重新导出公共 API。 +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from typing import Literal + +GremlinClassification = Literal["safe", "unsafe", "uncertain"] +GremlinSafety = GremlinClassification # 兼容别名 + +# ========== 分类器常量 ========== + +_METHOD_RE = re.compile(r"\.\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(") +_READ_START_RE = re.compile(r"^\s*g\s*\.\s*(?:V|E)\s*\(", re.IGNORECASE) +_DYNAMIC_MARKERS = ("${", "#{", "->") +_MAX_REPEAT_TIMES = 10 +_ALLOWED_ARG_TOKENS = {"true", "false", "null"} +_WRITE_METHODS = { + "addv", + "adde", + "drop", + "dropv", + "drope", + "remove", + "clear", + "sideeffect", + "io", + "call", + "program", +} +_READ_METHODS = { + "v", + "e", + "count", + "limit", + "range", + "has", + "haslabel", + "hasid", + "values", + "valuemap", + "id", + "label", + "keys", + "elementmap", + "properties", + "out", + "in", + "both", + "oute", + "ine", + "bothe", + "outv", + "inv", + "bothv", + "otherv", + "path", + "order", + "group", + "groupcount", + "by", + "dedup", + "sample", + "where", + "not", + "and", + "or", + "as", + "select", + "unfold", + "coalesce", + "optional", + "repeat", + "times", + "until", + "emit", + "simplepath", + "cyclicpath", + "skip", + "tail", + "tolist", + "toset", + "explain", + "profile", +} +_ANONYMOUS_READ_METHODS = {"outv", "inv", "bothv", "otherv"} + + +# ========== 分类器实现 ========== + + +def classify_gremlin_read_safety(gremlin_query: str) -> GremlinSafety: + """Classify a Gremlin query for use by the read-only execution tool. + + The classifier intentionally rejects ambiguous queries. It is a conservative + safety gate, not a complete Gremlin parser. + """ + + if not isinstance(gremlin_query, str) or not gremlin_query.strip(): + return "uncertain" + + query_without_strings = _strip_string_literals(gremlin_query) + methods = _extract_method_names(query_without_strings) + lowered_methods = [method.lower() for method in methods] + + if _has_unsafe_write_steps(query_without_strings, lowered_methods): + return "unsafe" + + if _has_dynamic_construction_markers(gremlin_query, query_without_strings): + return "uncertain" + + if not _READ_START_RE.search(query_without_strings): + return "uncertain" + + if any(method not in _READ_METHODS for method in lowered_methods): + return "uncertain" + + return "safe" + + +def is_safe_gremlin_read(gremlin_query: str) -> bool: + """Return True only when the query is confidently read-only.""" + return classify_gremlin_read_safety(gremlin_query) == "safe" + + +def _extract_method_names(query_without_strings: str) -> list[str]: + return _METHOD_RE.findall(query_without_strings) + + +def _has_unsafe_write_steps( + query_without_strings: str, lowered_methods: list[str] +) -> bool: + if any(method in _WRITE_METHODS for method in lowered_methods): + return True + + if "property" in lowered_methods: + return True + + if "iterate" in lowered_methods: + return True + + return bool( + re.search(r"\.\s*[VE]\s*\([^)]*\)\s*\.\s*drop\s*\(", query_without_strings) + ) + + +def _has_dynamic_construction_markers( + original_query: str, query_without_strings: str +) -> bool: + if any(marker in original_query for marker in _DYNAMIC_MARKERS): + return True + + if "+" in query_without_strings: + return True + + if "{" in query_without_strings or "}" in query_without_strings: + return True + + if ";" in query_without_strings: + return True + + if re.search( + r"(?:^|[;\s])(?:def|var|String|query)\s+\w+\s*=", query_without_strings + ): + return True + + return _has_bare_identifier_arguments(query_without_strings) + + +def _has_bare_identifier_arguments(query_without_strings: str) -> bool: + cleaned = re.sub( + r"\.\s*[A-Za-z_][A-Za-z0-9_]*", + " ", + query_without_strings, + ) + for method in _ANONYMOUS_READ_METHODS: + cleaned = re.sub( + rf"\b{method}\s*\(", + "(", + cleaned, + flags=re.IGNORECASE, + ) + cleaned = re.sub(r"^\s*[gG]\b", " ", cleaned) + for token_match in re.finditer(r"\b[A-Za-z_][A-Za-z0-9_]*\b", cleaned): + token = token_match.group(0).lower() + if token not in _ALLOWED_ARG_TOKENS: + return True + + return False + + +def _strip_string_literals(query: str) -> str: + """Replace string literal contents with spaces while preserving structure.""" + + result: list[str] = [] + quote: str | None = None + escaped = False + + for char in query: + if quote is None: + if char in {"'", '"'}: + quote = char + result.append(char) + else: + result.append(char) + continue + + if escaped: + escaped = False + result.append(" ") + elif char == "\\": + escaped = True + result.append(" ") + elif char == quote: + quote = None + result.append(char) + else: + result.append(" ") + + if quote is not None: + return query + + return "".join(result) + + +# ========== 结构化决策 ========== + + +@dataclass(frozen=True) +class GremlinDecision: + """Gremlin 安全检查的结构化决策。""" + + allowed: bool + classification: GremlinClassification + reason: str + error_type: str | None + suggestion: str | None + + +class GremlinPolicy: + """统一的 Gremlin 安全策略。 + + 所有 MCP Gremlin 读执行路径必须通过 check_read() 检查。 + """ + + def check_read(self, gremlin_query: str) -> GremlinDecision: + """检查 Gremlin 查询是否允许作为只读查询执行。""" + classification = classify_gremlin_read_safety(gremlin_query) + + if classification == "safe": + return GremlinDecision( + allowed=True, + classification="safe", + reason="Query is a known read-only traversal.", + error_type=None, + suggestion=None, + ) + + if classification == "unsafe": + return GremlinDecision( + allowed=False, + classification="unsafe", + reason="Query contains write or mutate operations.", + error_type="UNSAFE_GREMLIN", + suggestion=( + "Use execute_gremlin_write for write operations " + "when write access is enabled." + ), + ) + + # classification == "uncertain" + return GremlinDecision( + allowed=False, + classification="uncertain", + reason="Query contains unknown or ambiguous steps; cannot confirm read-only safety.", + error_type="UNSAFE_GREMLIN", + suggestion="Use a clearly read-only Gremlin traversal starting with g.V() or g.E().", + ) + + +# 模块级单例 +_policy = GremlinPolicy() + + +def check_gremlin_read(gremlin_query: str) -> GremlinDecision: + """便捷函数:使用默认策略检查 Gremlin 查询。""" + return _policy.check_read(gremlin_query) + + +def _parse_repeat_threshold() -> int: + value = os.getenv("HUGEGRAPH_MCP_MAX_REPEAT_TIMES") + if value is None or value.strip() == "": + return _MAX_REPEAT_TIMES + try: + parsed = int(value) + except ValueError: + return _MAX_REPEAT_TIMES + if parsed <= 0: + return _MAX_REPEAT_TIMES + return parsed + + +def gremlin_cost_warnings(gremlin_query: str) -> list[str]: + """轻量成本边界检查:只产 warning 不阻断。非完整 parser,仅挡明显风险。""" + + query_without_strings = _strip_string_literals(gremlin_query) + methods = [ + method.lower() for method in _extract_method_names(query_without_strings) + ] + method_set = set(methods) + warnings: list[str] = [] + + if method_set.isdisjoint({"limit", "range", "count"}): + warnings.append( + "Unbounded traversal: result set is not limited; consider adding " + ".limit() or .range()." + ) + + if "repeat" in method_set: + if "times" not in method_set: + warnings.append( + "repeat() without times() may recurse without an explicit depth bound." + ) + else: + max_times = _parse_repeat_threshold() + match = re.search(r"times\(\s*(\d+)\s*\)", query_without_strings) + if match is not None: + depth = int(match.group(1)) + if depth > max_times: + warnings.append( + f"repeat().times(n) depth {depth} exceeds recommended " + f"maximum {max_times}." + ) + + if any( + method in method_set for method in ("path", "group", "profile") + ) and method_set.isdisjoint({"limit", "range"}): + warnings.append( + "Heavy step (path/group/profile) without limit/range may be expensive." + ) + + return list(dict.fromkeys(warnings)) diff --git a/hugegraph-mcp/hugegraph_mcp/gremlin_safety.py b/hugegraph-mcp/hugegraph_mcp/gremlin_safety.py new file mode 100644 index 000000000..1ecd79202 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/gremlin_safety.py @@ -0,0 +1,34 @@ +# 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. + +"""Gremlin 安全分类器 — 兼容 wrapper。 + +.. deprecated:: + 实际实现已迁入 gremlin_policy.py。本模块保留公共 API 以兼容现有导入。 + 新代码应使用 gremlin_policy.check_gremlin_read() 或 GremlinPolicy.check_read()。 + +原模块文档: + 不是完整的 Gremlin parser,而是保守的安全门: + - safe: 明确只读遍历(g.V()/g.E() + 已知只读方法) + - unsafe: 检测到 write/mutate 方法或模式 + - uncertain: 无法确定 → 拒绝执行 + + 宁可误拒 ambiguous 查询,也不放行潜在写操作。 +""" + +# 兼容导入:从 gremlin_policy 重新导出公共 API +from hugegraph_mcp.gremlin_policy import ( # noqa: F401 + GremlinSafety, + classify_gremlin_read_safety, + is_safe_gremlin_read, +) diff --git a/hugegraph-mcp/hugegraph_mcp/gremlin_tools.py b/hugegraph-mcp/hugegraph_mcp/gremlin_tools.py new file mode 100644 index 000000000..3bbca4f60 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/gremlin_tools.py @@ -0,0 +1,389 @@ +# 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. + +"""Gremlin 执行层 — 封装 HugeGraph Gremlin 读写客户端。 + +所有 Gremlin 查询统一通过 GremlinExecutor 执行,对连接失败/认证错误/ +HTTP 错误/语法错误做结构化错误收集,不抛异常到上层。 +""" + +import time +from typing import Any + +import requests +from pyhugegraph.client import PyHugeClient + +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok +from hugegraph_mcp.gremlin_policy import check_gremlin_read, gremlin_cost_warnings +from hugegraph_mcp.guard import Capability, guard_write +from hugegraph_mcp.hugegraph_client import build_hugegraph_client + + +class GremlinExecutor: + """封装 HugeGraph Gremlin 读写客户端,自动处理 graphspace 兼容性。 + + HugeGraph 1.7.0+ 支持 graph space,配置为空时回退到默认客户端。 + """ + + def __init__(self, cfg: MCPConfig) -> None: + self._cfg = cfg + + def _build_client(self) -> PyHugeClient: + return build_hugegraph_client(self._cfg, client_cls=PyHugeClient) + + def get_read_client(self): + return self._build_client().gremlin() + + def get_write_client(self): + return self._build_client().gremlin() + + +_GREMLIN_ERROR_TYPE_MAP = { + "connection_error": ErrorType.CONNECTION_FAILED, + "timeout_error": ErrorType.TIMEOUT, + "authentication_error": ErrorType.AUTHENTICATION_FAILED, + "authorization_error": ErrorType.AUTHORIZATION_FAILED, + "no_index_error": ErrorType.NO_INDEX, + "query_syntax_error": ErrorType.QUERY_SYNTAX_ERROR, + "server_error": ErrorType.SERVER_ERROR, + "http_error": ErrorType.SERVER_ERROR, + "not_found_error": ErrorType.NOT_FOUND, + "unknown_error": ErrorType.SERVER_ERROR, +} + + +def _get_read_client(): + return GremlinExecutor(MCPConfig.from_env()).get_read_client() + + +def _get_write_client(): + return GremlinExecutor(MCPConfig.from_env()).get_write_client() + + +def _gremlin_error_envelope(result: dict[str, Any]) -> dict[str, Any]: + error_type = _GREMLIN_ERROR_TYPE_MAP.get( + result.get("error_type"), + ErrorType.SERVER_ERROR, + ) + suggestions = result.get("suggestions") or [] + suggestion = "; ".join(suggestions) if suggestions else None + return envelope_err( + error_type, + result.get("message", "Gremlin query failed"), + suggestion=suggestion, + retryable=_gremlin_error_retryable(result), + details=result, + duration_ms=result.get("duration_ms"), + ) + + +def _gremlin_error_retryable(result: dict[str, Any]) -> bool: + error_type = result.get("error_type") + if error_type in {"connection_error", "timeout_error"}: + return True + if error_type not in {"server_error", "http_error"}: + return False + + try: + status_code = int(result.get("status_code")) + except (TypeError, ValueError): + return error_type == "server_error" + return status_code in {500, 502, 503, 504} + + +def _gremlin_result_count(data: Any) -> int: + """Return the number of result items from HugeGraph/PyHugeGraph shapes.""" + if data is None: + return 0 + if isinstance(data, dict) and "data" in data: + inner_data = data.get("data") + if inner_data is None: + return 0 + if isinstance(inner_data, (list, tuple, set)): + return len(inner_data) + return 1 + if isinstance(data, (list, tuple, set)): + return len(data) + return 1 + + +def _is_no_index_error(message: Any) -> bool: + lowered = str(message).lower() + return "noindexexception" in lowered or "no index" in lowered + + +def _no_index_error_result( + message: str, + duration_ms: float, + operation_type: str, +) -> dict[str, Any]: + return { + "success": False, + "error_type": "no_index_error", + "message": message, + "suggestions": [ + "Create an index for the queried property before using has() filters", + "Use primary-key based lookups when possible", + "Check HugeGraph schema index labels with inspect_graph_tool", + ], + "duration_ms": duration_ms, + "operation_type": operation_type, + } + + +def _get_client_address(client) -> str: + """Extract server address from a pyhugegraph client for error messages. + + pyhugegraph does not expose a public URL getter; we read the private + ``_url`` attribute with a ``hasattr`` guard as a last resort. This is + safe for error-reporting purposes only and should not be relied on for + logic. + """ + if client is not None and hasattr(client, "_url"): + return str(client._url) + return "unknown address" + + +def _execute_gremlin_with_error_handling( + client, gremlin_query: str, operation_type: str = "read" +) -> dict[str, Any]: + """执行 Gremlin 查询并做结构化错误处理。 + + 连接失败、HTTP 错误、语法错误等均返回结构化 dict 而非抛异常, + 便于上层统一处理。区分 401/403/404/500 等状态码给出针对性建议。 + """ + start = time.perf_counter() + actual_client = None + + try: + actual_client = client() if callable(client) else client + data = actual_client.exec(gremlin_query) + duration_ms = (time.perf_counter() - start) * 1000.0 + + return { + "success": True, + "data": data, + "count": _gremlin_result_count(data), + "duration_ms": duration_ms, + "operation_type": operation_type, + } + + except requests.exceptions.ConnectionError: + address = _get_client_address(actual_client) + return { + "success": False, + "error_type": "connection_error", + "message": f"Cannot connect to HugeGraph server at {address}", + "suggestions": [ + "Check if HugeGraph server is running", + "Verify the HUGEGRAPH_URL environment variable", + "Check network connectivity to the server", + ], + "duration_ms": (time.perf_counter() - start) * 1000.0, + "operation_type": operation_type, + } + + except requests.exceptions.Timeout: + address = _get_client_address(actual_client) + return { + "success": False, + "error_type": "timeout_error", + "message": f"HugeGraph request timed out at {address}", + "suggestions": [ + "Retry the request after checking HugeGraph server health", + "Verify the query is bounded and can complete within the client timeout", + "Check network latency to the server", + ], + "duration_ms": (time.perf_counter() - start) * 1000.0, + "operation_type": operation_type, + } + + except requests.exceptions.HTTPError as e: + status_code = ( + e.response.status_code + if hasattr(e, "response") and e.response + else "unknown" + ) + + if status_code == 401: + error_type = "authentication_error" + message = "Authentication failed - invalid credentials" + suggestions = [ + "Check HUGEGRAPH_USER and HUGEGRAPH_PASSWORD environment variables", + "Verify user permissions in HugeGraph", + ] + elif status_code == 403: + error_type = "authorization_error" + message = "Authorization failed - insufficient permissions" + suggestions = [ + "Check if the user has permission to execute Gremlin queries", + "Verify graph space permissions if using graph spaces", + ] + elif status_code == 404: + error_type = "not_found_error" + message = "Graph or endpoint not found" + suggestions = [ + "Check if the graph name is correct", + "Verify the graph exists in HugeGraph", + ] + elif status_code == 500: + error_type = "server_error" + # 尝试从响应体中提取 HugeGraph 详细错误信息 + detail_message = "" + try: + if hasattr(e, "response") and e.response is not None: + error_json = e.response.json() + detail_message = error_json.get("exception") or "" + if not detail_message: + detail_message = ( + error_json.get("message") + or error_json.get("detail") + or error_json.get("error") + or str(error_json) + ) + except Exception: + pass + + if detail_message: + if _is_no_index_error(detail_message): + return _no_index_error_result( + f"Query requires an index: {detail_message}", + (time.perf_counter() - start) * 1000.0, + operation_type, + ) + message = f"HugeGraph server internal error: {detail_message}" + else: + message = "HugeGraph server internal error" + suggestions = [ + "Check the Gremlin query syntax", + "Verify all referenced vertex/edge labels exist", + "Check HugeGraph server logs for details", + "Ensure the query doesn't violate graph constraints", + ] + else: + error_type = "http_error" + message = f"HTTP error {status_code}" + suggestions = ["Check HugeGraph server status", "Verify the request format"] + + return { + "success": False, + "error_type": error_type, + "message": message, + "status_code": status_code, + "suggestions": suggestions, + "duration_ms": (time.perf_counter() - start) * 1000.0, + "operation_type": operation_type, + } + + except ValueError as e: + return { + "success": False, + "error_type": "query_syntax_error", + "message": f"Gremlin query syntax error: {e!s}", + "suggestions": [ + "Check Gremlin query syntax", + "Verify all steps and parameters are valid", + "Ensure proper use of Gremlin traversal steps", + ], + "duration_ms": (time.perf_counter() - start) * 1000.0, + "operation_type": operation_type, + } + + except Exception as e: + duration_ms = (time.perf_counter() - start) * 1000.0 + message = f"Unexpected error: {e!s}" + if _is_no_index_error(message): + return _no_index_error_result(message, duration_ms, operation_type) + return { + "success": False, + "error_type": "unknown_error", + "message": message, + "suggestions": [ + "Check HugeGraph server logs", + "Verify the query format and parameters", + "Try a simpler query to test connectivity", + ], + "duration_ms": duration_ms, + "operation_type": operation_type, + } + + +def execute_gremlin_read(gremlin_query: str) -> dict[str, Any]: + """执行只读 Gremlin 查询。 + + 通过 GremlinPolicy.check_read() 做安全检查, + 拒绝写入类和无法确定的查询,只放行明确安全的遍历。 + 返回 {data, total, duration_ms, is_read}。 + """ + + decision = check_gremlin_read(gremlin_query) + if not decision.allowed: + return envelope_err( + ErrorType.UNSAFE_GREMLIN, + decision.reason, + suggestion=decision.suggestion, + details={"classification": decision.classification}, + ) + + result = _execute_gremlin_with_error_handling( + _get_read_client, gremlin_query, "read" + ) + + if result.get("success"): + duration_ms = result["duration_ms"] + return envelope_ok( + { + "data": result["data"], + "total": result["count"], + "duration_ms": duration_ms, + "is_read": True, + }, + duration_ms=duration_ms, + warnings=gremlin_cost_warnings(gremlin_query), + ) + else: + return _gremlin_error_envelope(result) + + +def execute_gremlin_write( + gremlin_query: str, + *, + capability: Capability = Capability.DATA_WRITE, +) -> dict[str, Any]: + """执行 Gremlin 写查询。 + + readonly 模式下通过 guard_write 拒绝执行, + 正常模式返回 {success, affected, duration_ms, is_write}。 + """ + + violation = guard_write(capability) + if violation is not None: + return violation + + result = _execute_gremlin_with_error_handling( + _get_write_client, gremlin_query, "write" + ) + + if result.get("success"): + duration_ms = result["duration_ms"] + return envelope_ok( + { + "affected": result["count"], + "duration_ms": duration_ms, + "is_write": True, + }, + duration_ms=duration_ms, + ) + else: + return _gremlin_error_envelope(result) diff --git a/hugegraph-mcp/hugegraph_mcp/guard.py b/hugegraph-mcp/hugegraph_mcp/guard.py new file mode 100644 index 000000000..4c968b593 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/guard.py @@ -0,0 +1,85 @@ +# 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. + +"""权限守卫 — 统一的能力检查入口。 + +readonly 模式下只允许 READ + GENERATE 能力,其他操作返回 READONLY_VIOLATION 信封。 +不抛异常:调用方检查返回值是否是 envelope_err 即可。""" + +from enum import Enum +from typing import Any + +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.envelope import ErrorType, envelope_err + + +class Capability(str, Enum): + READ = "READ" + GENERATE = "GENERATE" + DATA_WRITE = "DATA_WRITE" + SCHEMA_WRITE = "SCHEMA_WRITE" + INDEX_WRITE = "INDEX_WRITE" + DEBUG_WRITE = "DEBUG_WRITE" + + +READONLY_ALLOWED_CAPABILITIES = frozenset( + { + Capability.READ, + Capability.GENERATE, + } +) + + +def is_allowed_in_readonly(capability: Capability) -> bool: + return capability in READONLY_ALLOWED_CAPABILITIES + + +def guard( + capability: Capability, + *, + cfg: MCPConfig | None = None, +) -> dict[str, Any] | None: + cfg = cfg or MCPConfig.from_env() + readonly = cfg.is_readonly() + + if not readonly or is_allowed_in_readonly(capability): + return None + + return envelope_err( + ErrorType.READONLY_VIOLATION, + f"{capability.value} capability is disabled in read-only mode", + suggestion="Disable HUGEGRAPH_MCP_READONLY to allow this operation.", + readonly=readonly, + graph=cfg.graph, + graphspace=cfg.graphspace, + capability=capability.value, + ) + + +def guard_write( + capability: Capability = Capability.DATA_WRITE, + *, + cfg: MCPConfig | None = None, +) -> dict[str, Any] | None: + return guard(capability, cfg=cfg) + + +def require_capability( + capability: Capability, + *, + cfg: MCPConfig | None = None, +) -> None: + violation = guard(capability, cfg=cfg) + if violation is not None: + message = violation["error"]["message"] + raise PermissionError(message) diff --git a/hugegraph-mcp/hugegraph_mcp/hugegraph_ai_client.py b/hugegraph-mcp/hugegraph_mcp/hugegraph_ai_client.py new file mode 100644 index 000000000..afbb61d5c --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/hugegraph_ai_client.py @@ -0,0 +1,213 @@ +# 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. + +"""HugeGraph-AI HTTP 客户端 — 统一请求层。 + +所有 AI 调用经 request() 统一处理:allow_ai 开关检查、超时控制、 +Basic Auth 注入、结构化错误返回。不抛异常。 +""" + +import time +from typing import Any + +import requests + +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok + + +def request( + method: str, + path: str, + *, + cfg: MCPConfig | None = None, + json: Any = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> dict[str, Any]: + """调用 HugeGraph-AI 并返回标准化信封。 + + allow_ai=False 时直接拒绝,连接超时/HTTP 错误/JSON 解析失败均返回 + HUGEGRAPH_AI_UNAVAILABLE 信封,不抛异常。 + """ + + start = time.perf_counter() + cfg = cfg or MCPConfig.from_env() + method = method.upper() + url = _build_url(cfg.ai_url, path) + + if not cfg.allow_ai: + return _ai_error( + "AI calls are disabled", + duration_ms=_duration_ms(start), + details={"method": method, "url": url}, + ) + + try: + kwargs: dict[str, Any] = { + "params": params, + "headers": headers, + "timeout": cfg.timeout_seconds, + } + if json is not None: + kwargs["json"] = json + if cfg.password: + kwargs["auth"] = (cfg.user, cfg.password) + + response = requests.request(method, url, **kwargs) + response.raise_for_status() + data = response.json() + return envelope_ok(data, duration_ms=_duration_ms(start)) + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc: + return _ai_error( + _exception_message("HugeGraph-AI is unavailable", exc), + duration_ms=_duration_ms(start), + retryable=True, + details={"method": method, "url": url}, + ) + except requests.exceptions.HTTPError as exc: + status_code = _status_code(exc) + details = {"method": method, "url": url, "status_code": status_code} + if status_code in {401, 403}: + return envelope_err( + ErrorType.AUTHORIZATION_FAILED, + _exception_message("HugeGraph-AI authorization failed", exc), + retryable=False, + details=details, + duration_ms=_duration_ms(start), + ) + if isinstance(status_code, int) and 400 <= status_code < 500: + return _ai_error( + _exception_message("HugeGraph-AI request failed", exc), + duration_ms=_duration_ms(start), + retryable=status_code == 429, + details=details, + ) + return _ai_error( + _exception_message("HugeGraph-AI is unavailable", exc), + duration_ms=_duration_ms(start), + retryable=True, + details=details, + ) + except ValueError as exc: + return _ai_error( + _exception_message("HugeGraph-AI returned invalid JSON", exc), + duration_ms=_duration_ms(start), + details={"method": method, "url": url}, + ) + except requests.exceptions.RequestException as exc: + return _ai_error( + _exception_message("HugeGraph-AI request failed", exc), + duration_ms=_duration_ms(start), + retryable=True, + details={"method": method, "url": url}, + ) + + +def get( + path: str, + *, + cfg: MCPConfig | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> dict[str, Any]: + return request("GET", path, cfg=cfg, params=params, headers=headers) + + +def post( + path: str, + *, + cfg: MCPConfig | None = None, + json: Any = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> dict[str, Any]: + return request("POST", path, cfg=cfg, json=json, params=params, headers=headers) + + +def health_check(*, cfg: MCPConfig | None = None) -> dict[str, Any]: + """尽力而为的 AI 健康检查 — 尝试多个端点探测 HugeGraph-AI 可用性。 + + 优先探查 /graph-index-info,失败时回退到 /openapi.json, + 401/403 立即返回不重试。 + """ + + attempts: list[str] = [] + last_result: dict[str, Any] | None = None + for path in ("/graph-index-info", "/openapi.json"): + result = get(path, cfg=cfg) + if result.get("ok"): + data = result.get("data") + if isinstance(data, dict): + result["data"] = { + "status": "available", + "health_endpoint": path, + **data, + } + else: + result["data"] = { + "status": "available", + "health_endpoint": path, + "response": data, + } + if attempts: + result["warnings"] = [*result.get("warnings", []), *attempts] + return result + + last_result = result + error = result.get("error") or {} + details = error.get("details") or {} + status_code = details.get("status_code") + attempts.append(f"{path}: {error.get('message', 'unavailable')}") + if status_code in {401, 403}: + return result + + if last_result is not None and attempts: + last_result["warnings"] = [*last_result.get("warnings", []), *attempts] + return last_result or get("/openapi.json", cfg=cfg) + + +def _build_url(base_url: str, path: str) -> str: + if path.startswith(("http://", "https://")): + return path + return f"{base_url.rstrip('/')}/{path.lstrip('/')}" + + +def _duration_ms(start: float) -> float: + return (time.perf_counter() - start) * 1000.0 + + +def _status_code(exc: requests.exceptions.HTTPError) -> int | None: + response = getattr(exc, "response", None) + return getattr(response, "status_code", None) + + +def _exception_message(prefix: str, exc: Exception) -> str: + message = str(exc).strip() + return f"{prefix}: {message}" if message else prefix + + +def _ai_error( + message: str, + *, + duration_ms: float, + retryable: bool = False, + details: dict[str, Any] | None = None, +) -> dict[str, Any]: + return envelope_err( + ErrorType.HUGEGRAPH_AI_UNAVAILABLE, + message, + retryable=retryable, + details=details, + duration_ms=duration_ms, + ) diff --git a/hugegraph-mcp/hugegraph_mcp/hugegraph_client.py b/hugegraph-mcp/hugegraph_mcp/hugegraph_client.py new file mode 100644 index 000000000..6bdf9b08d --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/hugegraph_client.py @@ -0,0 +1,39 @@ +# 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. + +"""HugeGraph Server client construction helpers.""" + +from typing import Any + +from pyhugegraph.client import PyHugeClient + +from hugegraph_mcp.config import MCPConfig + + +def build_hugegraph_client( + cfg: MCPConfig, + *, + client_cls: type[Any] = PyHugeClient, +) -> Any: + """Build a PyHugeClient while preserving optional graphspace compatibility.""" + + graphspace = cfg.graphspace.strip() if cfg.graphspace else "" + kwargs = { + "url": cfg.url, + "graph": cfg.graph, + "user": cfg.user, + "pwd": cfg.password, + } + if graphspace: + kwargs["graphspace"] = graphspace + return client_cls(**kwargs) diff --git a/hugegraph-mcp/hugegraph_mcp/plan_hash.py b/hugegraph-mcp/hugegraph_mcp/plan_hash.py new file mode 100644 index 000000000..49bd7f4d9 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/plan_hash.py @@ -0,0 +1,210 @@ +# 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. + +"""Target-bound plan hash — 防止跨图、跨用户、过期重放。 + +PlanContext 将工具名、模式、图目标、主体、readonly、payload 摘要、 +schema 摘要、nonce 和过期时间绑定到一个哈希中。 +confirm 时重新计算并比较,拒绝不匹配或过期的计划。 +""" + +from __future__ import annotations + +import hashlib +import json +import time +from dataclasses import asdict, dataclass, field +from typing import Any + +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.envelope import ErrorType + + +# 默认计划有效期(秒) +DEFAULT_PLAN_TTL_SECONDS = 600 # 10 分钟 + + +@dataclass(frozen=True) +class PlanContext: + """计划上下文 — 绑定到特定工具、目标和时间窗口。""" + + tool_name: str + mode: str + graph_url: str + graph_name: str + graphspace: str + principal: str + readonly: bool + payload_digest: str + schema_hash: str | None + nonce: str + expires_at: int + extra_context: dict[str, Any] = field(default_factory=dict) + + +def compute_plan_hash(context: PlanContext) -> str: + """计算目标绑定的计划哈希。 + + 使用 canonical JSON 序列化(sorted keys, stable normalization), + 对完整 PlanContext 做 SHA256 哈希。expires_at 参与哈希,防止 + 调用方在 confirm 时延长计划有效期。 + """ + payload = asdict(context) + payload = _canonicalize(payload) + encoded = json.dumps(payload, sort_keys=True, default=str) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:32] + + +def build_plan_context( + tool_name: str, + mode: str, + payload_digest: str, + schema_hash: str | None = None, + nonce: str | None = None, + ttl_seconds: int = DEFAULT_PLAN_TTL_SECONDS, + extra_context: dict[str, Any] | None = None, +) -> tuple[PlanContext, str]: + """构建 PlanContext 并计算 plan_hash。 + + Returns: + (PlanContext, plan_hash) 元组。 + """ + cfg = MCPConfig.from_env() + now = int(time.time()) + + if nonce is None: + nonce = _generate_nonce() + + context = PlanContext( + tool_name=tool_name, + mode=mode, + graph_url=cfg.url, + graph_name=cfg.graph, + graphspace=cfg.graphspace or "DEFAULT", + principal=cfg.user, + readonly=cfg.is_readonly(), + payload_digest=payload_digest, + schema_hash=schema_hash, + nonce=nonce, + expires_at=now + ttl_seconds, + extra_context=extra_context or {}, + ) + + plan_hash = compute_plan_hash(context) + return context, plan_hash + + +def verify_plan_hash( + submitted_hash: str, + tool_name: str, + mode: str, + payload_digest: str, + schema_hash: str | None = None, + nonce: str | None = None, + expires_at: float | int | None = None, + extra_context: dict[str, Any] | None = None, +) -> tuple[bool, str | None, dict[str, Any] | None]: + """验证提交的 plan_hash。 + + 重新读取 config 和 schema,重新计算哈希,检查过期。 + + Returns: + (valid, error_type, details) 元组。 + valid=True 时 error_type 和 details 为 None。 + """ + cfg = MCPConfig.from_env() + + if nonce is None: + return ( + False, + ErrorType.PLAN_HASH_MISMATCH, + {"reason": "Missing nonce in plan context."}, + ) + + expires_at_seconds = _coerce_expires_at(expires_at) + + # 检查过期。expires_at 是 dry_run 返回的计划上下文字段,confirm 时必须传回; + # 缺失或格式错误时按过期处理,避免调用方用 None 绕过有效期校验。 + if expires_at_seconds is None or int(time.time()) > expires_at_seconds: + return ( + False, + ErrorType.PLAN_EXPIRED, + { + "expires_at": expires_at, + "current_time": int(time.time()), + "reason": "Plan has expired. Run dry_run again.", + }, + ) + + # 重建上下文(使用当前 config,不是提交时的 config) + context = PlanContext( + tool_name=tool_name, + mode=mode, + graph_url=cfg.url, + graph_name=cfg.graph, + graphspace=cfg.graphspace or "DEFAULT", + principal=cfg.user, + readonly=cfg.is_readonly(), + payload_digest=payload_digest, + schema_hash=schema_hash, + nonce=nonce, + expires_at=expires_at_seconds, + extra_context=extra_context or {}, + ) + + expected_hash = compute_plan_hash(context) + + if submitted_hash != expected_hash: + return ( + False, + ErrorType.PLAN_HASH_MISMATCH, + { + "expected_hash": expected_hash, + "provided_hash": submitted_hash, + "reason": "Plan context has changed since dry_run.", + }, + ) + + return True, None, None + + +def compute_payload_digest(payload: Any) -> str: + """计算 payload 的规范化摘要。""" + normalized = _canonicalize(payload) + encoded = json.dumps(normalized, sort_keys=True, default=str) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:32] + + +def _canonicalize(obj: Any) -> Any: + """Canonical JSON 序列化 — 递归排序 dict keys。""" + if isinstance(obj, dict): + return {k: _canonicalize(v) for k, v in sorted(obj.items())} + if isinstance(obj, (list, tuple)): + return [_canonicalize(item) for item in obj] + return obj + + +def _generate_nonce() -> str: + """生成随机 nonce。""" + from uuid import uuid4 + + return uuid4().hex[:12] + + +def _coerce_expires_at(value: float | int | None) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None diff --git a/hugegraph-mcp/hugegraph_mcp/schema_tools.py b/hugegraph-mcp/hugegraph_mcp/schema_tools.py new file mode 100644 index 000000000..238077be0 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/schema_tools.py @@ -0,0 +1,125 @@ +# 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. + +"""HugeGraph Schema 操作层 — schema 读取和设计指导。 + +get_live_schema() 从 HugeGraph 拉取全量 schema 并生成 LLM 精简版, +design_schema() 提供链式思维引导的 schema 设计框架。 +""" + +from typing import Any + +from pyhugegraph.client import PyHugeClient + +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.hugegraph_client import build_hugegraph_client + + +def _build_client() -> PyHugeClient: + return build_hugegraph_client(MCPConfig.from_env(), client_cls=PyHugeClient) + + +def _simple_schema(schema: dict[str, Any]) -> dict[str, Any]: + """精简 schema 供 LLM 消费 — 去掉冗余字段,只保留 name/properties/source_label/target_label。 + + 与 hugegraph_llm.SchemaManager.simple_schema 行为一致。 + """ + + mini_schema: dict[str, Any] = {} + + if schema.get("vertexlabels"): + mini_schema["vertexlabels"] = [] + for vertex in schema["vertexlabels"]: + new_vertex = { + key: vertex[key] + for key in ("id", "name", "properties") + if key in vertex + } + mini_schema["vertexlabels"].append(new_vertex) + + if schema.get("edgelabels"): + mini_schema["edgelabels"] = [] + for edge in schema["edgelabels"]: + new_edge = { + key: edge[key] + for key in ("name", "source_label", "target_label", "properties") + if key in edge + } + mini_schema["edgelabels"].append(new_edge) + + return mini_schema + + +def get_live_schema() -> dict[str, Any]: + """从 HugeGraph 拉取活跃 schema,同时返回原始版和 LLM 精简版。 + + 返回: {schema, simple_schema, graphspace?, readonly} + """ + + client = _build_client() + raw_schema = client.schema().getSchema() + + if raw_schema is None: + raise ValueError("Failed to retrieve schema from HugeGraph server") + + result: dict[str, Any] = { + "schema": raw_schema, + "simple_schema": _simple_schema(raw_schema), + } + + graphspace = MCPConfig.from_env().graphspace + if graphspace: + result["graphspace"] = graphspace + + result["readonly"] = MCPConfig.from_env().is_readonly() + + return result + + +def design_schema( + thought: str, + thought_number: int, + total_thoughts: int = 4, + next_thought_needed: bool = True, + is_revision: bool = False, + revision_of: int | None = None, +) -> dict: + """Schema 设计引导工具 — 参考 Sequential Thinking 模式。 + + HugeGraph 使用 schema-based 图模型,数据写入前必须先定义 PropertyKeys、 + VertexLabels、EdgeLabels、IndexLabels。本函数提供分步引导框架。 + + Args: + thought: reserved for future use (no-op, accepted for Sequential Thinking + protocol compatibility) + thought_number: current thought step number + total_thoughts: estimated total thought steps + next_thought_needed: whether the caller expects another step + is_revision: reserved for future use (no-op) + revision_of: reserved for future use (no-op) + + 【最佳实践】 + 1. 先定义 PropertyKeys — 所有属性必须在 VertexLabel/EdgeLabel 中预定义 + 2. 选择合适的 DataType (TEXT/INT/DATE/DOUBLE) 和 Cardinality (SINGLE/SET/LIST) + 3. VertexLabel 需指定 primaryKeys 用于顶点唯一标识 + 4. EdgeLabel 需指定 sourceLabel 和 targetLabel + 5. EdgeLabel 的 frequency 可选 SINGLE 或 MULTIPLE + 6. 使用 nullableKeys 允许属性为空 + 7. 为常用查询字段创建 IndexLabels (secondary/range/search) + """ + + return { + "thought_number": thought_number, + "total_thoughts": total_thoughts, + "next_thought_needed": next_thought_needed, + } diff --git a/hugegraph-mcp/hugegraph_mcp/server.py b/hugegraph-mcp/hugegraph_mcp/server.py new file mode 100644 index 000000000..2d9c9a67f --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/server.py @@ -0,0 +1,447 @@ +# 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. + +"""FastMCP 服务器入口 — MCP 工具注册和轻量 mode 路由。 + +每个 @mcp.tool() 装饰的函数就是一个对外暴露的 MCP 工具。 +server.py 只负责参数校验和 mode 分发,具体业务逻辑委托给 tools/ 下的模块。 +""" + +import logging +import logging.handlers +import os +import time +from typing import Any + +# ---- 启动时 patch:阻止 pyhugegraph 模块级日志初始化写入文件 ---- +# pyhugegraph 在 import 时会创建 RotatingFileHandler 写入 'logs/' 目录, +# 在 MCP stdio 模式下这会破坏 JSON 协议流,因此拦截 makedirs 和 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: + # ---- patch 作用域内,安全导入依赖 pyhugegraph 的模块 ---- + 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.manage_graph_data import manage_graph_data + from hugegraph_mcp.tools.manage_schema import manage_schema + from hugegraph_mcp.tools.refresh_vid_embeddings import refresh_vid_embeddings +finally: + os.makedirs = _original_makedirs + logging.handlers.RotatingFileHandler = _OriginalRotatingFileHandler + +READONLY = MCPConfig.from_env().is_readonly() + +mcp = FastMCP("HugeGraph MCP") + + +def _align_public_tool_envelope( + result: dict[str, Any], + *, + tool_name: str, + duration_ms: float, +) -> dict[str, Any]: + """Add public wrapper metadata without changing the inner tool payload.""" + aligned = dict(result) + meta = dict(aligned.get("meta") or {}) + meta.setdefault("duration_ms", duration_ms) + aligned["meta"] = meta + + if aligned.get("ok") is False and isinstance(aligned.get("error"), dict): + error = dict(aligned["error"]) + error["source"] = tool_name + aligned["error"] = error + + return aligned + + +def _call_public_tool(tool_name: str, func, *args, **kwargs) -> dict[str, Any]: + start = time.perf_counter() + try: + result = func(*args, **kwargs) + except Exception as exc: + return envelope_err( + ErrorType.FLOW_EXECUTION_FAILED, + f"{tool_name} failed: {exc!s}", + source=tool_name, + details={"tool": tool_name}, + duration_ms=(time.perf_counter() - start) * 1000.0, + ) + return _align_public_tool_envelope( + result, + tool_name=tool_name, + duration_ms=(time.perf_counter() - start) * 1000.0, + ) + + +def _is_admin_mode_enabled() -> bool: + return MCPConfig.from_env().admin_mode + + +def _admin_gate(tool_name: str, *, requires_write: bool = False) -> dict | None: + """Return FEATURE_DISABLED envelope if admin mode is not enabled, else None.""" + if not _is_admin_mode_enabled(): + enable_env = {"admin_mode": "HUGEGRAPH_MCP_ADMIN_MODE"} + suggestion = f"Set HUGEGRAPH_MCP_ADMIN_MODE=true to enable {tool_name}." + if requires_write: + enable_env["readonly"] = "HUGEGRAPH_MCP_READONLY" + suggestion = ( + f"Set HUGEGRAPH_MCP_ADMIN_MODE=true and HUGEGRAPH_MCP_READONLY=false " + f"to enable {tool_name}." + ) + return envelope_err( + ErrorType.FEATURE_DISABLED, + f"{tool_name} is disabled by default in V1. Enable with HUGEGRAPH_MCP_ADMIN_MODE=true.", + suggestion=suggestion, + source=tool_name, + details={"tool": tool_name, "enable_env": enable_env}, + ) + + if requires_write and MCPConfig.from_env().is_readonly(): + return envelope_err( + ErrorType.READONLY_VIOLATION, + f"{tool_name} requires HUGEGRAPH_MCP_READONLY=false.", + suggestion=( + "Set HUGEGRAPH_MCP_ADMIN_MODE=true and HUGEGRAPH_MCP_READONLY=false " + "before retrying this admin write tool." + ), + source=tool_name, + details={ + "tool": tool_name, + "required_env": { + "HUGEGRAPH_MCP_ADMIN_MODE": "true", + "HUGEGRAPH_MCP_READONLY": "false", + }, + }, + readonly=True, + ) + return None + + +# ========== 工具 1:检视图状态和 schema ========== + + +@mcp.tool() +def inspect_graph_tool(include_raw_schema: bool = False) -> dict: + """检视 HugeGraph 服务器状态、schema 摘要、点边计数和 AI 状态。 + + 推荐作为连接后第一个调用的工具。 + """ + return _call_public_tool( + "inspect_graph_tool", + inspect_graph, + include_raw_schema=include_raw_schema, + ) + + +# ========== V1 稳定工具 ========== + + +@mcp.tool() +def generate_gremlin_tool( + query: str, + execute: bool = False, + output_types: list[str] | None = None, +) -> dict: + """V1 稳定工具:自然语言 → Gremlin 生成。 + + 默认不执行(execute=false),返回生成的 Gremlin 查询。 + 设置 execute=true 可执行生成的只读 Gremlin。 + """ + return _call_public_tool( + "generate_gremlin_tool", + generate_gremlin, + query=query, + execute=execute, + output_types=output_types, + ) + + +@mcp.tool() +def execute_gremlin_read_tool(gremlin_query: str) -> dict: + """V1 稳定工具:执行只读 Gremlin 遍历查询。 + + 经过 GremlinPolicy 安全检查后执行。 + """ + return _call_public_tool( + "execute_gremlin_read_tool", + execute_gremlin_read, + gremlin_query, + ) + + +@mcp.tool() +def extract_graph_data_tool( + text: str, + graph_schema: dict | None = None, + example_prompt: str | None = None, +) -> dict: + """V1 稳定工具:自然语言文本 → 候选 graph_data(不写入)。 + + 返回提取的顶点和边数据,供后续导入使用。 + graph_schema 可传入 HugeGraph schema;为空时使用当前图名作为 schema 引用。 + """ + return _call_public_tool( + "extract_graph_data_tool", + extract_graph_data, + text=text, + schema=graph_schema, + example_prompt=example_prompt, + ) + + +@mcp.tool() +def design_schema_tool(operations: list[dict] | None = None) -> dict: + """V1 稳定工具:schema 设计指导。 + + 提供 schema 设计建议和最佳实践。 + """ + return _call_public_tool( + "design_schema_tool", + manage_schema, + mode="design", + operations=operations, + ) + + +@mcp.tool() +def apply_schema_tool( + mode: str, + operations: list[dict] | None = None, + confirm: bool = False, + plan_hash: str | None = None, +) -> dict: + """V1 稳定工具:schema 校验和预览。 + + 支持 validate 和 dry_run 模式。apply 模式在 V1 中返回 FEATURE_DISABLED。 + """ + start = time.perf_counter() + if mode == "apply": + return envelope_err( + ErrorType.FEATURE_DISABLED, + "Schema apply is disabled in V1. Use validate or dry_run mode.", + suggestion="Use mode='validate' or mode='dry_run' to preview schema changes.", + source="apply_schema_tool", + details={"mode": mode, "tool": "apply_schema_tool"}, + duration_ms=(time.perf_counter() - start) * 1000.0, + ) + return _call_public_tool( + "apply_schema_tool", + manage_schema, + mode=mode, + operations=operations, + confirm=confirm, + plan_hash=plan_hash, + ) + + +# ========== 图数据导入入口 ========== + + +@mcp.tool() +def import_graph_data_tool( + mode: str, + text: str | None = None, + graph_schema: dict | None = None, + example_prompt: str | None = None, + graph_data: dict | None = None, + table_data: dict | None = None, + mapping: dict | None = None, + dry_run: bool = True, + confirm: bool = False, + plan_hash: str | None = None, + nonce: str | None = None, + expires_at: float | None = None, +) -> dict: + """V1 图数据导入入口。 + + mode="extract": 自然语言文本 → 候选 graph_data + mode="ingest": MCP 本地校验+dry_run/confirm+Gremlin 导入 graph_data + mode="table": V1 禁用(返回 FEATURE_DISABLED) + """ + start = time.perf_counter() + + if mode == "extract": + if not text: + return envelope_err( + ErrorType.VALIDATION_ERROR, + "text is required for mode='extract'", + source="import_graph_data_tool", + duration_ms=(time.perf_counter() - start) * 1000.0, + ) + return _call_public_tool( + "import_graph_data_tool", + extract_graph_data, + text=text, + schema=graph_schema, + example_prompt=example_prompt, + ) + + if mode == "ingest": + if graph_data is None: + return envelope_err( + ErrorType.VALIDATION_ERROR, + "graph_data is required for mode='ingest'", + source="import_graph_data_tool", + duration_ms=(time.perf_counter() - start) * 1000.0, + ) + return _call_public_tool( + "import_graph_data_tool", + manage_graph_data, + mode="import", + graph_data=graph_data, + dry_run=dry_run, + confirm=confirm, + plan_hash=plan_hash, + nonce=nonce, + expires_at=expires_at, + plan_tool_name="import_graph_data_tool", + ) + + if mode == "table": + return envelope_err( + ErrorType.FEATURE_DISABLED, + "Table import is not available in V1.", + suggestion="Use mode='extract' with extract_graph_data_tool instead.", + source="import_graph_data_tool", + details={"mode": mode, "tool": "import_graph_data_tool"}, + duration_ms=(time.perf_counter() - start) * 1000.0, + ) + + return envelope_err( + ErrorType.VALIDATION_ERROR, + f"Unknown mode: {mode!r}. Use 'extract' or 'ingest'.", + source="import_graph_data_tool", + details={"mode": mode}, + duration_ms=(time.perf_counter() - start) * 1000.0, + ) + + +# ========== 受控图数据删除入口 ========== + + +@mcp.tool() +def delete_graph_data_tool( + change_plan: dict, + dry_run: bool = True, + confirm: bool = False, + plan_hash: str | None = None, + nonce: str | None = None, + expires_at: float | None = None, +) -> dict: + """V1 稳定工具:受控删除图数据。 + + 只支持精确 delete_vertex/delete_edge change_plan。 + 必须经过 dry_run -> plan_hash -> confirm;不支持批量条件删除或级联删除。 + """ + return _call_public_tool( + "delete_graph_data_tool", + manage_graph_data, + mode="delete", + change_plan=change_plan, + dry_run=dry_run, + confirm=confirm, + plan_hash=plan_hash, + nonce=nonce, + expires_at=expires_at, + plan_tool_name="delete_graph_data_tool", + ) + + +# ========== 高级调试工具 ========== + + +@mcp.tool() +def refresh_vid_embeddings_tool(confirm: bool = False) -> dict: + """手动刷新 VID 嵌入 — 需 admin mode 且 readonly=false。""" + blocked = _admin_gate("refresh_vid_embeddings_tool", requires_write=True) + if blocked: + return blocked + return _call_public_tool( + "refresh_vid_embeddings_tool", + refresh_vid_embeddings, + confirm=confirm, + ) + + +@mcp.tool() +def execute_gremlin_write_tool(gremlin_query: str) -> dict: + """执行 Gremlin 写查询 — 需 admin mode 且 readonly=false。""" + blocked = _admin_gate("execute_gremlin_write_tool", requires_write=True) + if blocked: + return blocked + return _call_public_tool( + "execute_gremlin_write_tool", + execute_gremlin_write, + gremlin_query, + capability=Capability.DEBUG_WRITE, + ) + + +def main() -> None: + """CLI 入口 — 默认 stdio 模式。""" + mcp.run() + + +if __name__ == "__main__": # pragma: no cover - manual launch + main() diff --git a/hugegraph-mcp/hugegraph_mcp/tools/__init__.py b/hugegraph-mcp/hugegraph_mcp/tools/__init__.py new file mode 100644 index 000000000..b1312a090 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/__init__.py @@ -0,0 +1,16 @@ +# +# 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. +# diff --git a/hugegraph-mcp/hugegraph_mcp/tools/extract_graph_data.py b/hugegraph-mcp/hugegraph_mcp/tools/extract_graph_data.py new file mode 100644 index 000000000..0c76b69d8 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/extract_graph_data.py @@ -0,0 +1,156 @@ +# 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. + +"""自然语言抽取图数据 — 从文本中提取候选 vertices/edges,不写入 HugeGraph。 + +通过 HugeGraph-AI /graph-extract 端点将自然语言描述转为结构化的 +{vertices: [...], edges: [...]} 图数据,供用户审阅后再导入。 +""" + +import json +from typing import Any + +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok +from hugegraph_mcp.hugegraph_ai_client import post + + +DEFAULT_GRAPH_EXTRACT_PROMPT_ZH = """## 主要任务 +只抽取输入文本和给定图谱 schema 共同支持的顶点与边。只返回合法 JSON。 + +## 输出格式 +必须返回唯一 JSON 对象:{"vertices": [...], "edges": [...]}。 +顶点对象:{"id":"顶点 id","label":"顶点标签","properties":{"属性名":"属性值", ...}}。 +边对象:{"label":"边标签","outV":"源顶点 id","outVLabel":"源顶点标签","inV":"目标顶点 id","inVLabel":"目标顶点标签","properties":{"属性名":"属性值", ...}}。 + +## 抽取规则 +1. 只能使用 schema 中已经存在的 vertex label、edge label 和 property key。 +2. 可以把中文关系语义映射到 schema 中已有的英文标签,例如"同事"可映射为 schema 中的 colleague;但不要创造 schema 中不存在的标签。 +3. 顶点 id 必须按 schema 的 vertexlabels[].id 与 primary_keys 生成;单主键格式为 "{vertexLabelID}:{properties.}"。 +4. outV 和 inV 必须引用本次输出 vertices 中的 id,outVLabel/inVLabel 必须匹配边 schema 的 source_label/target_label。 +5. 保持属性类型,移除空属性,不要编造文本中没有的事实。 +6. 不要输出 Markdown、解释、注释或额外文本。""" + + +def extract_graph_data( + text: str, + schema: dict[str, Any] | str | None = None, + example_prompt: str | None = None, +) -> dict[str, Any]: + """从自然语言文本中抽取候选图数据 — 不写入 HugeGraph。 + + 返回 standard envelope,其中 graph_data 含 vertices/edges 供用户审阅。 + """ + + schema_message = _schema_message(schema) + prompt_message = _example_prompt_message(example_prompt) + ai_result = post( + "/graph-extract", + json={ + "text": text, + "schema": schema_message, + "example_prompt": prompt_message, + "language": "zh", + }, + ) + if not ai_result.get("ok"): + return ai_result + + payload = _unwrap_ai_payload(ai_result.get("data")) + if isinstance(payload, dict) and payload.get("ok") is False: + return payload + + graph_data = _extract_graph_data(payload) + if graph_data is None: + return envelope_err( + ErrorType.INVALID_GRAPH_DATA, + "HugeGraph-AI did not return graph_data with vertices and edges.", + details={"data": payload}, + ) + + cfg = MCPConfig.from_env() + return envelope_ok( + { + "graph_data": { + "schema_ref": { + "schema_source": "graph", + "graph": cfg.graph, + "graphspace": cfg.graphspace, + "version": None, + }, + "vertices": graph_data.get("vertices", []), + "edges": graph_data.get("edges", []), + "warnings": graph_data.get("warnings", []), + "raw": graph_data.get("raw"), + }, + "raw_summary": graph_data.get("raw_summary"), + "schema_warnings": graph_data.get("schema_warnings", []), + } + ) + + +def _schema_message(schema: Any) -> str: + if schema is None: + return MCPConfig.from_env().graph + if isinstance(schema, str): + return schema + return json.dumps(schema, sort_keys=True, default=str) + + +def _example_prompt_message(example_prompt: str | None) -> str: + if example_prompt is None: + return DEFAULT_GRAPH_EXTRACT_PROMPT_ZH + return example_prompt + + +def _unwrap_ai_payload(data: Any) -> Any: + """解包 AI 返回的双层信封 {ok, data} -> 内层 data。""" + if isinstance(data, dict) and "ok" in data and "data" in data: + if data.get("ok") is False: + return data + return data.get("data") + return data + + +def _extract_graph_data(data: Any) -> dict[str, Any] | None: + parsed = _parse_json_if_needed(data) + if isinstance(parsed, dict) and "graph_data" in parsed: + parsed = _parse_json_if_needed(parsed.get("graph_data")) + + if not isinstance(parsed, dict): + return None + + vertices = parsed.get("vertices") + edges = parsed.get("edges") + if not isinstance(vertices, list) or not isinstance(edges, list): + return None + + return { + "vertices": vertices, + "edges": edges, + "warnings": parsed.get("warnings", []), + "raw": parsed.get("raw"), + "raw_summary": parsed.get("raw_summary"), + "schema_warnings": parsed.get("schema_warnings", []), + } + + +def _parse_json_if_needed(data: Any) -> Any: + """AI 可能返回 JSON 字符串而非已解析对象,这里做兼容处理。""" + if not isinstance(data, str): + return data + + try: + return json.loads(data) + except json.JSONDecodeError: + return data diff --git a/hugegraph-mcp/hugegraph_mcp/tools/generate_gremlin.py b/hugegraph-mcp/hugegraph_mcp/tools/generate_gremlin.py new file mode 100644 index 000000000..e5f955be1 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/generate_gremlin.py @@ -0,0 +1,135 @@ +# 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. + +"""NL-to-Gremlin 生成 — 通过 HugeGraph-AI /text2gremlin 将自然语言转为 Gremlin。 + +默认只返回生成的 Gremlin + 安全元数据,不自动执行。 +execute=True 且安全分类为 safe 时才自动执行,不安全查询只返回不执行。 +""" + +from typing import Any + +from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok +from hugegraph_mcp.gremlin_policy import check_gremlin_read +from hugegraph_mcp.gremlin_tools import execute_gremlin_read +from hugegraph_mcp.hugegraph_ai_client import post + + +def generate_gremlin( + query: str, + execute: bool = False, + output_types: list[str] | None = None, +) -> dict[str, Any]: + """将自然语言转为 Gremlin — 默认只生成不执行。 + + execute=True 时通过 GremlinPolicy 检查安全性:只有 safe 的查询才会执行。 + """ + + payload: dict[str, Any] = {"query": query} + if output_types is not None: + payload["output_types"] = output_types + + ai_result = post("/text2gremlin", json=payload) + if not ai_result.get("ok"): + return ai_result + + ai_data = ai_result.get("data") or {} + if not isinstance(ai_data, dict): + return envelope_err( + ErrorType.FLOW_EXECUTION_FAILED, + "HugeGraph-AI returned an invalid text2gremlin response.", + details={"response": ai_data}, + ) + + template_gremlin = ai_data.get("template_gremlin") + raw_gremlin = ai_data.get("raw_gremlin") + gremlin = template_gremlin or raw_gremlin or ai_data.get("gremlin") + if not gremlin: + return envelope_err( + ErrorType.FLOW_EXECUTION_FAILED, + "HugeGraph-AI did not return Gremlin.", + details={"response": ai_data}, + ) + + requires_index = ai_data.get("requires_index", False) + assumptions = ai_data.get("assumptions") + + decision = check_gremlin_read(gremlin) if gremlin else None + safety = decision.classification if decision else "uncertain" + is_readonly = decision.allowed if decision else False + risk_level = _risk_level(safety) + + data = { + "gremlin": gremlin, + "template_gremlin": template_gremlin, + "raw_gremlin": raw_gremlin, + "is_readonly": is_readonly, + "risk_level": risk_level, + "requires_index": requires_index, + "assumptions": assumptions, + "executed": False, + "execution_result": None, + } + + if not execute: + return envelope_ok(data) + + if not is_readonly: + return envelope_err( + ErrorType.UNSAFE_GREMLIN, + "Generated Gremlin is not safe to execute automatically", + details={ + "classification": safety, + "gremlin": gremlin, + "risk_level": risk_level, + }, + ) + + data["executed"] = True + execution_result = execute_gremlin_read(gremlin) + if isinstance(execution_result, dict) and execution_result.get("ok") is False: + data["execution_result"] = execution_result + error = execution_result.get("error") or {} + return envelope_err( + error.get("type", ErrorType.FLOW_EXECUTION_FAILED), + error.get("message", "Generated Gremlin execution failed."), + suggestion=error.get("suggestion"), + retryable=error.get("retryable", False), + details={ + "gremlin": gremlin, + "generation": data, + "execution_error": error, + }, + warnings=execution_result.get("warnings", []), + ) + if isinstance(execution_result, dict) and execution_result.get("success") is False: + data["execution_result"] = execution_result + return envelope_err( + ErrorType.FLOW_EXECUTION_FAILED, + "Generated Gremlin execution failed.", + details={"gremlin": gremlin, "generation": data}, + ) + if isinstance(execution_result, dict) and execution_result.get("ok") is True: + data["execution_result"] = execution_result.get("data") + data["execution_meta"] = execution_result.get("meta") + else: + data["execution_result"] = execution_result + return envelope_ok(data) + + +def _risk_level(safety: str) -> str: + if safety == "safe": + return "low" + if safety == "unsafe": + return "high" + return "medium" diff --git a/hugegraph-mcp/hugegraph_mcp/tools/graph_data_execute.py b/hugegraph-mcp/hugegraph_mcp/tools/graph_data_execute.py new file mode 100644 index 000000000..75f09c666 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/graph_data_execute.py @@ -0,0 +1,795 @@ +# 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. + +"""Graph data change plan execution. + +Dry-run preview, write execution, pre-execution matched_count verification, +and plan hash computation. +""" + +import hashlib +import json +from typing import Any + +from hugegraph_mcp import gremlin_tools +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 ( + _edge_match_query, + _g, + _source_vertex_match_query, + _target_vertex_match_query, + _vertex_match_query, + _write_query, +) +from hugegraph_mcp.tools.graph_data_validate import ( + WRITE_OPS, + ValidationError, + _operations, + _validation_error, + validate_graph_change_plan, +) +from hugegraph_mcp.tools.live_schema import fetch_live_schema_or_none +from hugegraph_mcp.tools.schema_utils import ( + normalized_schema_summary, + primary_key_names, + schema_payload, +) + + +# ---- Gremlin 执行辅助 ---- + + +def _read_count(gremlin_query: str) -> dict[str, Any]: + result = gremlin_tools.execute_gremlin_read(f"{gremlin_query}.count()") + # execute_gremlin_read 已逐步迁移到统一 envelope,但这里仍兼容旧的 + # success=false 形状,避免低层工具格式差异破坏 dry-run 安全链。 + if isinstance(result, dict) and result.get("ok") is False: + return result + if isinstance(result, dict) and result.get("success") is False: + return envelope_err( + ErrorType.CONNECTION_FAILED, + "HugeGraph read query failed during graph change dry run.", + details=result, + retryable=True, + ) + data = result.get("data") if isinstance(result, dict) else result + count = _extract_count_value(data) + try: + matched_count = int(count) + except (TypeError, ValueError): + return envelope_err( + ErrorType.INVALID_GRAPH_DATA, + "HugeGraph count query returned a non-numeric result.", + details={"query": gremlin_query, "data": data}, + ) + return envelope_ok({"matched_count": matched_count}) + + +def _extract_count_value(data: Any) -> Any: + if isinstance(data, dict) and "data" in data: + return _extract_count_value(data.get("data")) + if isinstance(data, list): + if not data: + return 0 + return _extract_count_value(data[0]) + return data + + +def _read_values(gremlin_query: str) -> dict[str, Any]: + result = gremlin_tools.execute_gremlin_read(gremlin_query) + if isinstance(result, dict) and result.get("ok") is False: + return result + if isinstance(result, dict) and result.get("success") is False: + return envelope_err( + ErrorType.CONNECTION_FAILED, + "HugeGraph read query failed during graph change dry run.", + details=result, + retryable=True, + ) + data = result.get("data") if isinstance(result, dict) else result + if isinstance(data, dict) and "data" in data: + data = data["data"] + return envelope_ok({"values": data if isinstance(data, list) else [data]}) + + +def _mutation_summary(operations: list[dict[str, Any]]) -> dict[str, int]: + counts: dict[str, int] = {} + for operation in operations: + op = str(operation.get("op") or operation.get("type") or "unknown") + counts[op] = counts.get(op, 0) + 1 + return counts + + +# ---- Plan Hash 计算 — 防篡改校验 ---- + + +def calculate_graph_change_plan_hash( + change_plan: Any, + graph: str | None = None, + graphspace: str | None = None, + schema_summary: dict[str, Any] | None = None, + extra_hash_context: dict[str, Any] | None = None, +) -> str: + """基于 change_plan + graph/schema 上下文计算确定性哈希。 + + 用于防篡改安全链:dry_run 返回 plan_hash,执行时校验匹配。 + """ + cfg = MCPConfig.from_env() + payload: dict[str, Any] = { + "change_plan": change_plan, + "graph": cfg.graph if graph is None else graph, + "graphspace": cfg.graphspace if graphspace is None else graphspace, + } + if schema_summary is not None: + payload["schema_summary"] = schema_summary + if extra_hash_context is not None: + # 上游链路可把来源摘要和映射配置放进额外上下文,避免不同来源 + # 复用同一个确认 hash。 + payload["extra_hash_context"] = extra_hash_context + encoded = json.dumps(payload, sort_keys=True, default=str) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:32] + + +# ---- 干跑预览 ---- + + +def dry_run_graph_change_plan( + change_plan: Any, + live_schema: dict[str, Any], + extra_hash_context: dict[str, Any] | None = None, +) -> dict[str, Any]: + """干跑 — 校验 + 预览每个操作的影响(matched_count),不执行写入。 + + delete 操作通过只读 Gremlin 查询验证 matched_count==1, + delete_vertex cascade=false 时检查关联边。 + """ + validation = validate_graph_change_plan(change_plan, live_schema) + if not validation["valid"]: + return validation + + operations = _operations(change_plan) + preview: list[dict[str, Any]] = [] + errors: list[ValidationError] = [] + + for idx, operation in enumerate(operations): + op = str(operation.get("op") or operation.get("type")) + item = { + "operation_index": idx, + "op": op, + "label": operation.get("label"), + "action": op, + } + if op == "create_edge": + endpoint_failed = _append_edge_endpoint_counts( + idx=idx, + operation=operation, + op=op, + item=item, + errors=errors, + planned_operations=operations, + ) + preview.append(item) + if endpoint_failed: + continue + item["matched_count"] = None + continue + + if op == "create_vertex": + _append_create_vertex_identity_counts( + idx=idx, + operation=operation, + item=item, + errors=errors, + live_schema=live_schema, + ) + item["matched_count"] = None + preview.append(item) + continue + + if op not in WRITE_OPS: + # create 操作没有“必须命中唯一旧数据”的要求;真正的 schema/payload + # 合法性已经在 validate 阶段检查,因此 dry-run 只展示计划。 + item["matched_count"] = None + preview.append(item) + continue + + if op == "delete_edge": + # 边删除先分别确认两个端点唯一,再确认边本身唯一。 + # 这样错误能定位到 source/target,而不是只得到一条模糊的边匹配失败。 + endpoint_failed = _append_edge_endpoint_counts( + idx=idx, + operation=operation, + op=op, + item=item, + errors=errors, + ) + if endpoint_failed: + 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"): + errors.append( + _validation_error( + idx, + operation, + "matched_count query failed", + "Verify HugeGraph Server is available and retry the dry run.", + ) + ) + continue + matched_count = count_result["data"]["matched_count"] + item["matched_count"] = matched_count + + if op in {"delete_vertex", "delete_edge"} and matched_count != 1: + errors.append( + _validation_error( + idx, + operation, + f"{op} matched_count must be 1, got {matched_count}", + "Narrow the match criteria so exactly one graph element is affected.", + ) + ) + + if ( + op == "delete_vertex" + and operation.get("cascade", False) is False + and matched_count == 1 + ): + # 默认禁止删除带边的顶点,避免 HugeGraph 侧级联行为造成不可预期的数据损失。 + # 用户需要先显式删除关联边,再删除顶点。 + edge_count_result = _read_count(f"{match_query}.bothE()") + if not edge_count_result.get("ok"): + errors.append( + _validation_error( + idx, + operation, + "associated edge count query failed", + "Verify HugeGraph Server is available and retry the dry run.", + ) + ) + else: + edge_count = edge_count_result["data"]["matched_count"] + item["associated_edge_count"] = edge_count + if edge_count > 0: + errors.append( + _validation_error( + idx, + operation, + "delete_vertex cascade=false but vertex has associated edges", + "Set cascade=true or delete associated edges first.", + "BLOCKED_BY_RELATIONSHIPS", + ) + ) + elif op == "delete_vertex" and operation.get("cascade", False) is True: + edge_result = _read_values(f"{match_query}.bothE().elementMap()") + if not edge_result.get("ok"): + errors.append( + _validation_error( + idx, + operation, + "associated edge preview query failed", + "Verify HugeGraph Server is available and retry the dry run.", + ) + ) + else: + item["associated_edges"] = edge_result["data"]["values"] + # 目前 cascade=true 只做关联边预览,不执行级联删除。 + # 这是有意的保守策略:真实级联删除需要单独的产品决策和测试覆盖。 + errors.append( + _validation_error( + idx, + operation, + "delete_vertex cascade=true is not enabled in this phase", + "Delete associated edges explicitly, then delete the vertex with cascade=false.", + "CASCADE_NOT_ENABLED", + ) + ) + preview.append(item) + + if errors: + return { + "valid": False, + "errors": errors, + "warnings": validation.get("warnings", []), + "preview": preview, + } + + return { + "valid": True, + "plan_hash": calculate_graph_change_plan_hash( + change_plan, + schema_summary=normalized_schema_summary(live_schema), + extra_hash_context=extra_hash_context, + ), + "mutation_summary": _mutation_summary(operations), + "preview": preview, + "warnings": validation.get("warnings", []), + } + + +def _append_edge_endpoint_counts( + *, + idx: int, + operation: dict[str, Any], + op: str, + item: dict[str, Any], + errors: list[ValidationError], + planned_operations: list[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( + operation=operation, + endpoint=endpoint, + planned_operations=planned_operations, + ) + 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 + live_count = endpoint_count_result["data"]["matched_count"] + total_count = planned_count + live_count + item[f"{endpoint}_planned_count"] = planned_count + item[f"{endpoint}_live_count"] = live_count + item[f"{endpoint}_matched_count"] = total_count + if total_count != 1: + errors.append( + _validation_error( + idx, + operation, + f"{op} {endpoint} endpoint matched_count must be 1, got {total_count}", + "Narrow the endpoint match criteria so exactly one vertex is selected.", + ) + ) + endpoint_failed = True + return endpoint_failed + + +def _planned_endpoint_match_count( + *, + operation: dict[str, Any], + endpoint: str, + planned_operations: list[Any] | None, +) -> int: + if not planned_operations: + return 0 + + 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 0 + + count = 0 + for planned in planned_operations: + if not isinstance(planned, dict): + continue + planned_op = str(planned.get("op") or planned.get("type") or "") + if planned_op != "create_vertex" or planned.get("label") != label: + continue + if _planned_vertex_matches(planned, match): + count += 1 + return count + + +def _planned_vertex_matches(operation: dict[str, Any], match: dict[str, Any]) -> bool: + for key, value in match.items(): + if key == "id": + if operation.get("id") != value: + return False + continue + properties = operation.get("properties") + if not isinstance(properties, dict) or properties.get(key) != value: + return False + return bool(match) + + +# ---- 执行 — 写入前再次校验 matched_count ---- + + +def execute_graph_change_plan( + change_plan: Any, + live_schema: dict[str, Any] | None = None, +) -> dict[str, Any]: + """执行变更计划 — 写入前对每个操作再次校验 matched_count。 + + 防止 dry_run 和 execute 之间状态变化导致的误操作。 + """ + operations = _operations(change_plan) + results: list[dict[str, Any]] = [] + for idx, operation in enumerate(operations): + op = str(operation.get("op") or operation.get("type")) + if op == "create_vertex": + conflict = _create_vertex_identity_conflict( + operation=operation, + operation_index=idx, + live_schema=live_schema, + ) + if conflict is not None: + 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, + ) + + if op in WRITE_OPS: + # 执行前再次读取 matched_count,处理 dry-run 和 confirm 之间图状态变化 + # 的 TOCTOU 风险;只要匹配不再唯一,就拒绝写入。 + 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: + return _execution_failure( + envelope_err( + ErrorType.INVALID_GRAPH_DATA, + f"{op} matched_count must be 1 before execution.", + details={ + "operation_index": idx, + "matched_count": matched_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( + "CASCADE_NOT_ENABLED", + "delete_vertex cascade=true is not enabled in this phase.", + suggestion="Delete associated edges explicitly, then delete the vertex with cascade=false.", + details={"operation_index": idx}, + ), + operation, + idx, + results, + ) + write_result = gremlin_tools.execute_gremlin_write( + _write_query(operation), + capability=Capability.DATA_WRITE, + ) + if isinstance(write_result, dict) and write_result.get("ok") is False: + return _execution_failure(write_result, operation, idx, results) + if isinstance(write_result, dict) and write_result.get("success") is False: + return _execution_failure( + envelope_err( + ErrorType.CONNECTION_FAILED, + "HugeGraph write query failed during graph change execution.", + details=write_result, + retryable=True, + ), + operation, + idx, + results, + ) + if op in {"create_vertex", "create_edge"}: + affected = _write_affected_count(write_result) + if affected != 1: + 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, + "affected": affected, + "write_result": write_result, + }, + ), + operation, + idx, + results, + ) + if op in {"delete_vertex", "delete_edge"}: + # 删除后立即反查,确保 HugeGraph 已经实际移除目标。 + # 这能捕获后端静默失败或异步状态异常,而不是只信任写接口返回。 + verify_query = ( + _edge_match_query(operation) + if op == "delete_edge" + else _vertex_match_query(operation) + ) + verify_result = _read_count(verify_query) + if not verify_result.get("ok"): + return _execution_failure(verify_result, operation, idx, results) + if verify_result["data"]["matched_count"] != 0: + return _execution_failure( + envelope_err( + "DELETE_VERIFY_FAILED", + f"{op} execution did not remove the matched element.", + suggestion="Inspect the graph state and retry after confirming the match criteria.", + details={ + "operation_index": idx, + "op": op, + "matched_count": verify_result["data"]["matched_count"], + }, + ), + operation, + idx, + results, + ) + results.append( + { + "operation_index": idx, + "op": op, + "label": operation.get("label"), + "result": write_result, + } + ) + return { + "success": True, + "results": results, + "mutation_summary": _mutation_summary(operations), + } + + +def _write_affected_count(write_result: Any) -> int | None: + data = write_result + if isinstance(write_result, dict) and "ok" in write_result: + data = write_result.get("data") + if not isinstance(data, dict): + return None + for key in ("affected", "count"): + if key not in data: + continue + try: + return int(data.get(key)) + except (TypeError, ValueError): + return None + return None + + +def _append_create_vertex_identity_counts( + *, + idx: int, + operation: dict[str, Any], + item: dict[str, Any], + errors: list[ValidationError], + live_schema: dict[str, Any] | None, +) -> None: + for identity_type, query in _create_vertex_identity_queries( + operation, + live_schema, + ): + count_result = _read_count(query) + if not count_result.get("ok"): + errors.append( + _validation_error( + idx, + operation, + f"create_vertex {identity_type} identity count query failed", + "Verify HugeGraph Server is available and retry the dry run.", + ) + ) + continue + live_count = count_result["data"]["matched_count"] + item[f"{identity_type}_live_count"] = live_count + if live_count > 0: + errors.append( + _validation_error( + idx, + operation, + f"create_vertex {identity_type} identity already exists in live graph", + "Use a new vertex identity or remove the existing vertex before importing.", + ErrorType.INVALID_GRAPH_DATA.value, + ) + ) + + +def _create_vertex_identity_conflict( + *, + operation: dict[str, Any], + operation_index: int, + live_schema: dict[str, Any] | None, +) -> dict[str, Any] | None: + for identity_type, query in _create_vertex_identity_queries( + operation, + live_schema, + ): + count_result = _read_count(query) + if not count_result.get("ok"): + return count_result + live_count = count_result["data"]["matched_count"] + if live_count > 0: + return envelope_err( + ErrorType.INVALID_GRAPH_DATA, + f"create_vertex {identity_type} identity already exists before execution.", + details={ + "operation_index": operation_index, + "identity_type": identity_type, + "matched_count": live_count, + }, + ) + return None + + +def _create_vertex_identity_queries( + operation: dict[str, Any], + live_schema: dict[str, Any] | None, +) -> list[tuple[str, str]]: + label = operation.get("label") + if not isinstance(label, str) or not label: + return [] + + queries: list[tuple[str, str]] = [] + explicit_id = operation.get("id") + if explicit_id not in (None, ""): + queries.append(("id", f"g.V().hasLabel({_g(label)}).hasId({_g(explicit_id)})")) + + primary_keys = _create_vertex_primary_keys(label, live_schema) + properties = operation.get("properties") + if primary_keys and isinstance(properties, dict): + if 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 + ) + queries.append(("primary_key", query)) + + return queries + + +def _create_vertex_primary_keys( + label: str, + live_schema: dict[str, Any] | None, +) -> list[str]: + raw_schema = schema_payload(live_schema) or {} + for vertex_label in raw_schema.get("vertexlabels", []): + if not isinstance(vertex_label, dict): + continue + if vertex_label.get("name") == label: + return primary_key_names(vertex_label) + return [] + + +def _execution_failure( + error_result: dict[str, Any], + operation: dict[str, Any], + operation_index: int, + results: list[dict[str, Any]], +) -> dict[str, Any]: + if not results: + return error_result + + error = _extract_execution_error(error_result) + return { + "success": False, + "status": "partial", + "results": results, + "failed_items": [ + { + "operation_index": operation_index, + "op": operation.get("op") or operation.get("type"), + "label": operation.get("label"), + "error": error, + } + ], + "warnings": ["Graph change execution stopped after a partial write."], + "mutation_summary": _mutation_summary(_operations({"operations": results})), + } + + +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"] + return { + "type": ErrorType.CONNECTION_FAILED.value, + "message": "Graph change execution failed.", + "details": error_result if isinstance(error_result, dict) else {}, + } + + +# ---- Live Schema 获取 ---- + + +def _fetch_live_schema() -> dict[str, Any] | None: + return fetch_live_schema_or_none() diff --git a/hugegraph-mcp/hugegraph_mcp/tools/graph_data_gremlin.py b/hugegraph-mcp/hugegraph_mcp/tools/graph_data_gremlin.py new file mode 100644 index 000000000..b15630720 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/graph_data_gremlin.py @@ -0,0 +1,141 @@ +# 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. + +"""Gremlin query generation for graph data change operations. + +Uses conservative literal escaping for injection prevention. +""" + +import json +from typing import Any + + +# ---- Gremlin 查询生成 — 单引号字符串防止 Groovy GString 插值 ---- + + +def _g(value: Any) -> str: + if isinstance(value, str): + escaped = _escape_groovy_single_quoted(value) + return f"'{escaped}'" + if isinstance(value, dict): + # Groovy/Gremlin map literal uses [...] not {...} + items = ", ".join(f"{_g(k)}: {_g(v)}" for k, v in sorted(value.items())) + return f"[{items}]" + if isinstance(value, (list, tuple)): + items = ", ".join(_g(v) for v in value) + return f"[{items}]" + return json.dumps(value) + + +def _escape_groovy_single_quoted(value: str) -> str: + chars: list[str] = [] + for char in value: + if char == "\\": + chars.append("\\\\") + elif char == "'": + chars.append("\\'") + elif char == "\n": + chars.append("\\n") + elif char == "\r": + chars.append("\\r") + elif char == "\t": + chars.append("\\t") + elif char == "\b": + chars.append("\\b") + elif char == "\f": + chars.append("\\f") + elif ord(char) < 0x20: + chars.append(f"\\u{ord(char):04x}") + else: + chars.append(char) + return "".join(chars) + + +def _has_steps(match: dict[str, Any]) -> str: + steps: list[str] = [] + for key, value in match.items(): + if key == "id": + steps.append(f".hasId({_g(value)})") + else: + steps.append(f".has({_g(key)},{_g(value)})") + return "".join(steps) + + +def _vertex_match_query(operation: dict[str, Any]) -> str: + return f"g.V().hasLabel({_g(operation['label'])}){_has_steps(operation['match'])}" + + +def _edge_match_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") + return ( + f"g.V().hasLabel({_g(source_label)}){_has_steps(operation['source_match'])}" + f".outE({_g(operation['label'])})" + f".where(inV().hasLabel({_g(target_label)}){_has_steps(operation['target_match'])})" + ) + + +def _source_vertex_match_query(operation: dict[str, Any]) -> str: + source_label = operation.get("source_label") or operation.get("outVLabel") + return f"g.V().hasLabel({_g(source_label)}){_has_steps(operation['source_match'])}" + + +def _target_vertex_match_query(operation: dict[str, Any]) -> str: + target_label = operation.get("target_label") or operation.get("inVLabel") + return f"g.V().hasLabel({_g(target_label)}){_has_steps(operation['target_match'])}" + + +# ---- Gremlin 写入语句生成 — label 和 values 均为 schema 约束值 + JSON 转义 ---- + + +def _create_vertex_query(operation: dict[str, Any]) -> str: + query = f"g.addV({_g(operation['label'])})" + if operation.get("id") not in (None, ""): + query += f".property(T.id,{_g(operation['id'])})" + for prop, value in (operation.get("properties") or {}).items(): + query += f".property({_g(prop)},{_g(value)})" + return query + + +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".addE({_g(operation['label'])}).from('s')" + ) + for prop, value in (operation.get("properties") or {}).items(): + query += f".property({_g(prop)},{_g(value)})" + return query + + +def _delete_vertex_query(operation: dict[str, Any]) -> str: + return f"{_vertex_match_query(operation)}.drop()" + + +def _delete_edge_query(operation: dict[str, Any]) -> str: + return f"{_edge_match_query(operation)}.drop()" + + +def _write_query(operation: dict[str, Any]) -> str: + op = str(operation.get("op") or operation.get("type")) + if op == "create_vertex": + return _create_vertex_query(operation) + if op == "create_edge": + return _create_edge_query(operation) + if op == "delete_vertex": + return _delete_vertex_query(operation) + if op == "delete_edge": + return _delete_edge_query(operation) + raise ValueError(f"Unsupported op: {op}") diff --git a/hugegraph-mcp/hugegraph_mcp/tools/graph_data_mapping.py b/hugegraph-mcp/hugegraph_mcp/tools/graph_data_mapping.py new file mode 100644 index 000000000..3af2a5e4e --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/graph_data_mapping.py @@ -0,0 +1,195 @@ +# 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. + +"""graph_data → change_plan 映射层 — 将用户输入的 {vertices, edges} 转为内部变更计划。 + +graph_data_to_change_plan() 是核心转换函数: +- 每个 vertex → create_vertex 操作 +- 每个 edge → create_edge 操作(含端点匹配) +- 数字 id 和字符串 id 归一化处理 + +该模块不依赖 manage_graph_data.py,避免循环导入。 +""" + +from typing import Any + +from hugegraph_mcp.tools.schema_utils import primary_key_names, schema_payload + +GraphChangePlan = dict[str, list[dict[str, Any]]] + + +def _change_plan_from_operations(operations: list[dict[str, Any]]) -> GraphChangePlan: + return {"operations": operations} + + +def graph_data_to_change_plan( + graph_data: dict[str, Any], + live_schema: dict[str, Any] | None = None, +) -> GraphChangePlan: + """将图数据 {vertices, edges} 转为 change_plan {operations: [...]}。 + + vertices 全部映射为 create_vertex,edges 全部映射为 create_edge。 + 边端点优先保留 extract_graph_data 输出的 id 契约,避免退化成非唯一属性匹配。 + """ + operations: list[dict[str, Any]] = [] + vertex_ids = _vertex_ids_by_label(graph_data) + single_primary_keys = _single_primary_keys_by_label(live_schema) + primary_key_values = _vertex_primary_key_values( + graph_data, + single_primary_keys=single_primary_keys, + ) + for vertex in graph_data.get("vertices") or []: + if not isinstance(vertex, dict): + continue + operation = { + "op": "create_vertex", + "label": vertex.get("label"), + "properties": vertex.get("properties") or {}, + } + if vertex.get("id") not in (None, ""): + operation["id"] = vertex.get("id") + operations.append(operation) + for edge in graph_data.get("edges") or []: + if not isinstance(edge, dict): + continue + source_label = edge.get("source_label") or edge.get("outVLabel") + target_label = edge.get("target_label") or edge.get("inVLabel") + operations.append( + { + "op": "create_edge", + "label": edge.get("label"), + "source_label": source_label, + "target_label": target_label, + "source_match": _edge_endpoint_match( + edge=edge, + endpoint="source", + endpoint_label=source_label, + vertex_ids=vertex_ids, + single_primary_keys=single_primary_keys, + primary_key_values=primary_key_values, + ), + "target_match": _edge_endpoint_match( + edge=edge, + endpoint="target", + endpoint_label=target_label, + vertex_ids=vertex_ids, + single_primary_keys=single_primary_keys, + primary_key_values=primary_key_values, + ), + "properties": edge.get("properties") or {}, + } + ) + return _change_plan_from_operations(operations) + + +def _vertex_ids_by_label(graph_data: dict[str, Any]) -> dict[tuple[str, str], Any]: + ids: dict[tuple[str, str], Any] = {} + for vertex in graph_data.get("vertices") or []: + if not isinstance(vertex, dict): + continue + label = vertex.get("label") + vertex_id = vertex.get("id") + if isinstance(label, str) and vertex_id not in (None, ""): + ids[(label, str(vertex_id))] = vertex_id + return ids + + +def _single_primary_keys_by_label( + live_schema: dict[str, Any] | None, +) -> dict[str, str]: + raw_schema = schema_payload(live_schema) + if raw_schema is None: + return {} + + result: dict[str, str] = {} + for vertex_label in raw_schema.get("vertexlabels") or []: + if not isinstance(vertex_label, dict): + continue + label = vertex_label.get("name") + primary_keys = primary_key_names(vertex_label) + if isinstance(label, str) and len(primary_keys) == 1: + result[label] = primary_keys[0] + return result + + +def _vertex_primary_key_values( + graph_data: dict[str, Any], + *, + single_primary_keys: dict[str, str], +) -> dict[tuple[str, str], Any]: + values: dict[tuple[str, str], Any] = {} + for vertex in graph_data.get("vertices") or []: + if not isinstance(vertex, dict): + continue + label = vertex.get("label") + if not isinstance(label, str): + continue + primary_key = single_primary_keys.get(label) + properties = vertex.get("properties") + if primary_key is None or not isinstance(properties, dict): + continue + value = properties.get(primary_key) + if value not in (None, ""): + values[(label, str(value))] = value + return values + + +def _edge_endpoint_match( + *, + edge: dict[str, Any], + endpoint: str, + endpoint_label: str | None, + vertex_ids: dict[tuple[str, str], Any], + single_primary_keys: dict[str, str], + primary_key_values: dict[tuple[str, str], Any], +) -> dict[str, Any]: + """确定边端点的匹配条件。 + + source/target 对象保持原样;scalar 在单主键 schema 下优先匹配主键, + 允许指向已存在顶点;显式 payload id 与 outV/inV 始终保持 id 语义。 + """ + explicit_endpoint = endpoint in edge + if endpoint == "source": + explicit = edge.get("source") + vertex_id = edge.get("outV") + else: + explicit = edge.get("target") + vertex_id = edge.get("inV") + + if isinstance(explicit, dict): + return explicit + fallback_id = explicit if explicit not in (None, "") else vertex_id + if isinstance(endpoint_label, str) and fallback_id not in (None, ""): + identity_key = (endpoint_label, str(fallback_id)) + matched_vertex_id = vertex_ids.get(identity_key) + if matched_vertex_id is not None: + return {"id": matched_vertex_id} + + if explicit_endpoint: + primary_key = single_primary_keys.get(endpoint_label) + primary_key_value = primary_key_values.get(identity_key) + if ( + primary_key_value is None + and isinstance(fallback_id, str) + and ":" in fallback_id + ): + primary_key_value = primary_key_values.get( + (endpoint_label, fallback_id.split(":", 1)[1]) + ) + if primary_key is not None: + return { + primary_key: primary_key_value + if primary_key_value is not None + else fallback_id + } + return {"id": fallback_id} diff --git a/hugegraph-mcp/hugegraph_mcp/tools/graph_data_validate.py b/hugegraph-mcp/hugegraph_mcp/tools/graph_data_validate.py new file mode 100644 index 000000000..261ae5d57 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/graph_data_validate.py @@ -0,0 +1,486 @@ +# 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. + +"""Graph data change plan validation. + +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.schema_utils import ( + edge_schema_endpoint_label as _edge_schema_endpoint_label, + normalized_schema_summary, + primary_key_names as _primary_key_names, + property_names as _property_names, + schema_name, + schema_payload, +) + + +ALLOWED_OPS = frozenset( + { + "create_vertex", + "create_edge", + "delete_vertex", + "delete_edge", + } +) + +VERTEX_OPS = frozenset({"create_vertex", "delete_vertex"}) +EDGE_OPS = frozenset({"create_edge", "delete_edge"}) +WRITE_OPS = frozenset({"delete_vertex", "delete_edge"}) +# 每种 mode 只允许特定操作类型,防止错误使用 +MODE_OPS = { + "import": frozenset({"create_vertex", "create_edge"}), + "delete": frozenset({"delete_vertex", "delete_edge"}), +} + +ValidationError = dict[str, Any] + + +# ---- Schema 辅助函数 ---- + + +def _schema_payload(live_schema: dict[str, Any] | None) -> dict[str, Any]: + # 兼容旧测试对私有 helper 的直接引用;真实实现集中在 schema_utils。 + return schema_payload(live_schema) or {} + + +def _schema_name(item: Any) -> str | None: + # 兼容 HugeGraph schema 中字符串/对象两种属性表示方式。 + return schema_name(item) + + +def _vertex_labels(raw_schema: dict[str, Any]) -> dict[str, dict[str, Any]]: + labels: dict[str, dict[str, Any]] = {} + for label in raw_schema.get("vertexlabels", []): + if not isinstance(label, dict): + continue + name = label.get("name") + if isinstance(name, str): + labels[name] = label + return labels + + +def _edge_labels(raw_schema: dict[str, Any]) -> dict[str, dict[str, Any]]: + labels: dict[str, dict[str, Any]] = {} + for label in raw_schema.get("edgelabels", []): + if not isinstance(label, dict): + continue + name = label.get("name") + if isinstance(name, str): + labels[name] = label + return labels + + +def _schema_summary(live_schema: dict[str, Any] | None) -> dict[str, Any] | None: + return normalized_schema_summary(live_schema) + + +def _operations(change_plan: Any) -> list[Any]: + if isinstance(change_plan, list): + return change_plan + if not isinstance(change_plan, dict): + return [] + operations = change_plan.get("operations") + return operations if isinstance(operations, list) else [] + + +def _validation_error( + operation_index: int, + operation: Any, + reason: str, + suggestion: str, + error_type: str | None = None, +) -> ValidationError: + error = { + "operation_index": operation_index, + "operation": operation, + "reason": reason, + "suggestion": suggestion, + } + if error_type is not None: + error["error_type"] = error_type + return error + + +# ---- 校验辅助 ---- + + +def _validate_field_map( + *, + idx: int, + operation: dict[str, Any], + field: str, + allowed_properties: set[str], + errors: list[ValidationError], + allow_id: bool = False, +) -> None: + values = operation.get(field) + if values is None: + return + if not isinstance(values, dict): + errors.append( + _validation_error( + idx, + operation, + f"{field} must be an object", + f"Use an object mapping property names to values for {field}.", + ) + ) + return + allowed = set(allowed_properties) + if allow_id: + allowed.add("id") + unknown = sorted(set(values) - allowed) + if unknown: + errors.append( + _validation_error( + idx, + operation, + f"{field} references property not on label: {', '.join(unknown)}", + "Use only properties defined on the referenced label.", + ) + ) + + +def _validate_primary_key_match( + *, + idx: int, + operation: dict[str, Any], + field: str, + primary_keys: list[str], + errors: list[ValidationError], +) -> None: + match = operation.get(field) + if not isinstance(match, dict): + errors.append( + _validation_error( + idx, + operation, + f"{field} must be an object", + f"Use {field} to identify a vertex by its primary key.", + ) + ) + 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, "") + ] + if missing: + errors.append( + _validation_error( + idx, + operation, + f"{field} must contain primary key(s): {', '.join(missing)}", + "Include every primary key from the referenced vertex label.", + ) + ) + + +# ---- 变更计划校验 ---- + + +def validate_graph_change_plan( + change_plan: Any, + live_schema: dict[str, Any], +) -> dict[str, Any]: + """校验 change_plan 中的操作是否与 live schema 兼容。 + + 检查项:op 类型白名单、label 存在性、properties/match 字段合法性、 + 主键匹配、边端点合法性。 + """ + errors: list[ValidationError] = [] + warnings: list[str] = [] + + if not isinstance(change_plan, (dict, list)): + return { + "valid": False, + "errors": [ + _validation_error( + -1, + change_plan, + "change_plan must be an object with operations", + "Pass {'operations': [...]} as the graph change plan.", + ) + ], + "warnings": [], + } + + if isinstance(change_plan, dict) and not isinstance( + change_plan.get("operations"), list + ): + return { + "valid": False, + "errors": [ + _validation_error( + -1, + change_plan, + "change_plan.operations must be a list", + "Pass graph change operations as a JSON array.", + ) + ], + "warnings": [], + } + + operations = _operations(change_plan) + raw_schema = _schema_payload(live_schema) + # 先把 live schema 建成按 label/name 查找的索引,后续每个操作只做 O(1) + # 查找;同时保证校验依据始终来自同一份 schema 快照。 + 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() + } + + for idx, operation in enumerate(operations): + if not isinstance(operation, dict): + errors.append( + _validation_error( + idx, + operation, + "operation must be an object", + "Replace this item with a graph change operation object.", + ) + ) + continue + + op = str(operation.get("op") or operation.get("type") or "") + if op not in ALLOWED_OPS: + errors.append( + _validation_error( + idx, + operation, + f"unsupported op: {op}", + "Use one of: create_vertex, create_edge, delete_vertex, delete_edge.", + ) + ) + continue + + label = operation.get("label") + if not isinstance(label, str) or not label: + errors.append( + _validation_error( + idx, + operation, + "missing required field: label", + "Add the schema label targeted by this operation.", + ) + ) + continue + + if "set" in operation: + errors.append( + _validation_error( + idx, + operation, + "set is not supported by V1 graph data operations", + "Use properties for create operations, or submit update support in a later V2 change.", + ) + ) + + if op in VERTEX_OPS: + if label not in vertex_labels: + errors.append( + _validation_error( + idx, + operation, + f"label references undefined vertex label: {label}", + "Use an existing vertex label from the live schema.", + ) + ) + continue + 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" + ) + # properties/match 都只能引用该 label 已定义的属性。 + # 这里先做字段白名单检查,再按 op 做更严格的业务约束。 + _validate_field_map( + idx=idx, + operation=operation, + field="properties", + allowed_properties=allowed, + errors=errors, + ) + _validate_field_map( + idx=idx, + operation=operation, + field="match", + allowed_properties=allowed, + errors=errors, + allow_id=True, + ) + if op == "delete_vertex": + _validate_primary_key_match( + idx=idx, + operation=operation, + field="match", + primary_keys=pks, + errors=errors, + ) + else: + if label not in edge_labels: + errors.append( + _validation_error( + idx, + operation, + f"label references undefined edge label: {label}", + "Use an existing edge label from the live schema.", + ) + ) + continue + edge_schema = edge_labels[label] + source_label = operation.get("source_label") or operation.get("outVLabel") + target_label = operation.get("target_label") or operation.get("inVLabel") + # 边操作必须同时满足两层约束: + # 1. 请求里的端点 label 存在; + # 2. 请求里的端点方向与 edge label 在 schema 中定义的方向一致。 + for endpoint_name, endpoint_label in ( + ("source_label", source_label), + ("target_label", target_label), + ): + if ( + not isinstance(endpoint_label, str) + or endpoint_label not in vertex_labels + ): + errors.append( + _validation_error( + idx, + operation, + f"{endpoint_name} references undefined vertex label: {endpoint_label}", + "Use existing source and target vertex labels from the live schema.", + ) + ) + expected_source = _edge_schema_endpoint_label(edge_schema, "source") + expected_target = _edge_schema_endpoint_label(edge_schema, "target") + if expected_source and source_label and source_label != expected_source: + errors.append( + _validation_error( + idx, + operation, + f"source_label does not match edge label '{label}' source_label '{expected_source}'", + "Use the source label defined by the edge schema.", + ) + ) + if expected_target and target_label and target_label != expected_target: + errors.append( + _validation_error( + idx, + operation, + f"target_label does not match edge label '{label}' target_label '{expected_target}'", + "Use the target label defined by the edge schema.", + ) + ) + allowed = edge_properties.get(label, set()) + _validate_field_map( + idx=idx, + operation=operation, + field="properties", + allowed_properties=allowed, + errors=errors, + ) + if isinstance(source_label, str) and source_label in vertex_labels: + _validate_primary_key_match( + idx=idx, + operation=operation, + field="source_match", + primary_keys=primary_keys.get(source_label, []), + errors=errors, + ) + _validate_field_map( + idx=idx, + operation=operation, + field="source_match", + allowed_properties=vertex_properties.get(source_label, set()), + errors=errors, + allow_id=True, + ) + if isinstance(target_label, str) and target_label in vertex_labels: + _validate_primary_key_match( + idx=idx, + operation=operation, + field="target_match", + primary_keys=primary_keys.get(target_label, []), + errors=errors, + ) + _validate_field_map( + idx=idx, + operation=operation, + field="target_match", + allowed_properties=vertex_properties.get(target_label, set()), + errors=errors, + allow_id=True, + ) + + return {"valid": not bool(errors), "errors": errors, "warnings": warnings} + + +# ---- Mode 操作约束校验 ---- + + +def _validate_mode_operations( + mode: str, change_plan: GraphChangePlan +) -> dict[str, Any]: + """确保 mode 下的所有操作类型匹配,例如 import 模式不允许删除。""" + allowed = MODE_OPS.get(mode) + if allowed is None: + return { + "valid": False, + "errors": [ + _validation_error( + -1, + change_plan, + f"unknown mode: {mode}", + "Use mode='import' or mode='delete'.", + ) + ], + "warnings": [], + } + + errors = [] + for idx, operation in enumerate(_operations(change_plan)): + if not isinstance(operation, dict): + errors.append( + _validation_error( + idx, + operation, + "operation must be an object", + "Replace this item with a graph change operation object.", + ) + ) + continue + op = str(operation.get("op") or operation.get("type") or "") + if op not in allowed: + errors.append( + _validation_error( + idx, + operation, + f"op {op} is not allowed in mode='{mode}'", + f"Use only {', '.join(sorted(allowed))} operations for mode='{mode}'.", + ) + ) + return {"valid": not bool(errors), "errors": errors, "warnings": []} diff --git a/hugegraph-mcp/hugegraph_mcp/tools/ingest_graph_data.py b/hugegraph-mcp/hugegraph_mcp/tools/ingest_graph_data.py new file mode 100644 index 000000000..87cb6230c --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/ingest_graph_data.py @@ -0,0 +1,1197 @@ +# 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. + +"""图数据导入 — 结构化 graph_data 校验和 legacy AI-backed 写入链路。 + +=== MCP V1 导入路径说明 === + +当前存在两条导入路径: + +1. **Public V1 路径(推荐)** + import_graph_data_tool(mode="ingest") 路由到 manage_graph_data() + → graph_data_to_change_plan() → 本地 Gremlin 写入。 + 此路径在 server.py:_import_graph_data() 中分发,不依赖 + HugeGraph-AI 服务。 + +2. **Legacy AI-backed 路径(兼容保留)** + ingest_graph_data() 真实实现为 ingest_graph_data_via_ai(), + 通过 HugeGraph-AI /graph-import HTTP 接口写入。 + 仅供需要 AI 辅助属性映射的内部/legacy 场景使用, + 不做为 MCP V1 公共工具的默认导入链路。 + +validate_graph_payload() 对 vertices/edges 做全面 schema 校验: +- label 是否存在于 live schema +- properties 字段是否在对应 label 中定义 +- 主键是否提供 +- 边端点是否可解析 +- 类型匹配 +""" + +import json +from copy import deepcopy +from typing import Any +from uuid import uuid4 + +from hugegraph_mcp.config import MCPConfig +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.schema_utils import ( + edge_schema_endpoint_label as _edge_schema_endpoint_label, + normalized_schema_summary, + primary_key_names as _primary_key_names, + property_names as _property_names, + schema_payload as _schema_payload, +) +from hugegraph_mcp.tools.live_schema import fetch_live_schema_or_none + + +def _property_types(raw_schema: dict[str, Any]) -> dict[str, str]: + types: dict[str, str] = {} + for prop in raw_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): + types[name] = data_type.upper() + return types + + +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) + return True + + +def _indexed_labels(raw_schema: dict[str, Any]) -> dict[str, set[str]]: + indexed = {"VERTEX": set(), "EDGE": set()} + for index in raw_schema.get("indexlabels", []): + if not isinstance(index, dict): + continue + base_label = index.get("base_label") or index.get("baseLabel") + if not isinstance(base_label, str): + continue + base_type = str(index.get("base_type") or index.get("baseType") or "").upper() + if base_type in {"VERTEX", "VERTEX_LABEL"}: + indexed["VERTEX"].add(base_label) + elif base_type in {"EDGE", "EDGE_LABEL"}: + indexed["EDGE"].add(base_label) + return indexed + + +def _edge_endpoint(edge: dict[str, Any], endpoint: str) -> tuple[Any, Any]: + if endpoint == "source": + label = edge.get("source_label") or edge.get("outVLabel") + value = edge.get("source") if "source" in edge else edge.get("outV") + else: + label = edge.get("target_label") or edge.get("inVLabel") + value = edge.get("target") if "target" in edge else edge.get("inV") + return label, value + + +def _has_mixed_endpoint_forms(edge: dict[str, Any], endpoint: str) -> bool: + if endpoint == "source": + return "source" in edge and "outV" in edge + return "target" in edge and "inV" in edge + + +def _identity_value_present(value: Any) -> bool: + return value is not None and value != "" + + +def _format_endpoint_value(value: Any) -> str: + return repr(value) + + +def _endpoint_identities( + label: str, + value: Any, + schema_primary_keys: dict[str, list[str]], +) -> tuple[list[tuple[str, str, Any]], str | None]: + # 边端点支持两种写法:直接给后端 id,或按顶点主键给对象。 + # 返回多个候选身份,是为了兼容用户传入 "1:alice" 这类后端 id + # 和 {"name": "alice"} 这类主键对象两种输入。 + identities: list[tuple[str, str, Any]] = [] + primary_keys = schema_primary_keys.get(label, []) + + if isinstance(value, dict): + explicit_id = value.get("id") + 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)) + ] + if missing: + if identities: + return identities, None + return identities, missing[0] + identities.append( + (label, "pk", tuple(value.get(pk) for pk in primary_keys)) + ) + return identities, None + + if _identity_value_present(value): + identities.append((label, "id", value)) + if len(primary_keys) == 1: + pk_value = value + if isinstance(value, str) and ":" in value: + pk_value = value.split(":", 1)[1] + identities.append((label, "pk", (pk_value,))) + return identities, None + + +def _schema_plan_summary(live_schema: dict[str, Any] | None) -> dict[str, Any] | None: + raw = _schema_payload(live_schema) + if raw is None: + return None + return { + "vertexlabels": raw.get("vertexlabels", []), + "edgelabels": raw.get("edgelabels", []), + "propertykeys": raw.get("propertykeys", []), + "indexlabels": raw.get("indexlabels", []), + } + + +def _canonical_json_key(value: Any) -> str: + return json.dumps(value, sort_keys=True, default=str, separators=(",", ":")) + + +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)) + } + if isinstance(value, list): + return [_normalize_value(item) for item in value] + return value + + +def _vertex_sort_key( + vertex: Any, + schema_primary_keys: dict[str, list[str]], +) -> tuple[str, str]: + if not isinstance(vertex, dict): + return ("", _canonical_json_key(vertex)) + label = str(vertex.get("label") or "") + if _identity_value_present(vertex.get("id")): + identity = vertex.get("id") + else: + props = vertex.get("properties") + primary_keys = schema_primary_keys.get(label, []) + if isinstance(props, dict) and primary_keys: + identity = props.get(primary_keys[0]) + elif isinstance(props, dict) and props: + first_key = sorted(props, key=lambda item: str(item))[0] + identity = props.get(first_key) + else: + identity = None + return (label, _canonical_json_key(identity)) + + +def _edge_sort_key(edge: Any) -> tuple[str, str, str, str]: + if not isinstance(edge, dict): + return ("", "", "", _canonical_json_key(edge)) + source_label, source = _edge_endpoint(edge, "source") + target_label, target = _edge_endpoint(edge, "target") + return ( + str(edge.get("label") or ""), + str(source_label or ""), + str(target_label or ""), + _canonical_json_key( + { + "source": source, + "target": target, + "properties": edge.get("properties", {}), + } + ), + ) + + +def _normalize_graph_data( + graph_data: dict[str, Any], + schema_summary: dict[str, Any] | None, +) -> dict[str, Any]: + # plan_hash 需要对输入顺序不敏感:同一批顶点/边即使 JSON 数组顺序不同, + # 也应得到同一个 hash;但属性值、schema 主键等安全相关内容必须参与 hash。 + normalized = _normalize_value(graph_data) + if not isinstance(normalized, dict): + return normalized + + schema_primary_keys: dict[str, list[str]] = {} + if schema_summary: + for vertex_label in schema_summary.get("vertexlabels", []): + if isinstance(vertex_label, dict): + name = vertex_label.get("name") + primary_keys = vertex_label.get("primary_keys") + if isinstance(name, str) and isinstance(primary_keys, list): + schema_primary_keys[name] = primary_keys + + vertices = normalized.get("vertices") + if isinstance(vertices, list): + normalized["vertices"] = sorted( + vertices, + key=lambda vertex: _vertex_sort_key(vertex, schema_primary_keys), + ) + + edges = normalized.get("edges") + if isinstance(edges, list): + normalized["edges"] = sorted(edges, key=_edge_sort_key) + + return normalized + + +def _schema_vertex_info(raw_schema: dict[str, Any]) -> dict[str, dict[str, Any]]: + info: dict[str, dict[str, Any]] = {} + for vertex_label in raw_schema.get("vertexlabels", []): + if not isinstance(vertex_label, dict): + continue + name = vertex_label.get("name") + if isinstance(name, str): + info[name] = { + "id": vertex_label.get("id"), + "primary_keys": _primary_key_names(vertex_label), + } + return info + + +def _canonical_primary_key_id( + label: str, + values: tuple[Any, ...], + vertex_info: dict[str, dict[str, Any]], +) -> str | None: + label_id = vertex_info.get(label, {}).get("id") + if label_id is None: + return None + return f"{label_id}:{'!'.join(str(value) for value in values)}" + + +def _vertex_backend_id( + vertex: dict[str, Any], + vertex_info: dict[str, dict[str, Any]], +) -> Any: + # HugeGraph PRIMARY_KEY 顶点的后端 id 由 label id 和主键值拼接而成。 + # 在导入前补齐 id,能让边端点在同一批 payload 内稳定引用刚创建的顶点。 + explicit_id = vertex.get("id") + if _identity_value_present(explicit_id): + return explicit_id + + label = vertex.get("label") + props = vertex.get("properties") + if not isinstance(label, str) or not isinstance(props, dict): + return None + + 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 + ): + return None + + values = tuple(props.get(pk) for pk in primary_keys) + return _canonical_primary_key_id(label, values, vertex_info) + + +def _vertex_identity_map( + vertices: list[Any], + raw_schema: dict[str, Any], +) -> tuple[dict[tuple[str, str, Any], Any], dict[str, list[str]]]: + # 建立"用户可表达的身份"到 HugeGraph 后端 id 的映射。 + # 同一顶点可能同时拥有显式 id、PRIMARY_KEY 后端 id 和主键 tuple, + # 边端点解析时任意一种命中都应该指向同一个后端顶点。 + vertex_info = _schema_vertex_info(raw_schema) + 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: + if not isinstance(vertex, dict): + continue + label = vertex.get("label") + if not isinstance(label, str): + continue + + backend_id = _vertex_backend_id(vertex, vertex_info) + if _identity_value_present(backend_id): + vertex.setdefault("id", backend_id) + identities[(label, "id", backend_id)] = backend_id + + explicit_id = vertex.get("id") + if _identity_value_present(explicit_id): + identities[(label, "id", explicit_id)] = backend_id or explicit_id + + props = vertex.get("properties") + primary_keys = schema_primary_keys.get(label, []) + if isinstance(props, dict) and primary_keys: + if 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 + + return identities, schema_primary_keys + + +def _endpoint_backend_id( + label: str, + value: Any, + identities: dict[tuple[str, str, Any], Any], + schema_primary_keys: dict[str, list[str]], + vertex_info: dict[str, dict[str, Any]], +) -> Any: + # 边端点优先解析为本批 payload 中的顶点,保证"先创建顶点再创建边" + # 的批量导入场景不需要用户提前知道 HugeGraph 后端 id。 + endpoint_identities, _missing_pk = _endpoint_identities( + label, + value, + schema_primary_keys, + ) + for identity in endpoint_identities: + if identity in identities: + return identities[identity] + + if isinstance(value, dict): + explicit_id = value.get("id") + if _identity_value_present(explicit_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 + ): + values = tuple(value.get(pk) for pk in primary_keys) + return _canonical_primary_key_id(label, values, vertex_info) + return None + + if _identity_value_present(value): + return value + return None + + +def _prepare_graph_import_data( + graph_data: dict[str, Any], + live_schema: dict[str, Any], +) -> dict[str, Any]: + # HugeGraph-AI 的 graph-import 接口需要 outV/inV/outVLabel/inVLabel。 + # MCP 对用户暴露更友好的 source/target,因此这里在写入前做一次格式转换。 + prepared = deepcopy(graph_data) + raw_schema = _schema_payload(live_schema) or {} + vertex_info = _schema_vertex_info(raw_schema) + vertices = prepared.get("vertices") or [] + edges = prepared.get("edges") or [] + identities, schema_primary_keys = _vertex_identity_map(vertices, raw_schema) + + for edge in edges: + if not isinstance(edge, dict): + continue + src_label, source = _edge_endpoint(edge, "source") + tgt_label, target = _edge_endpoint(edge, "target") + edge.setdefault("properties", {}) + + if isinstance(src_label, str): + source_id = _endpoint_backend_id( + src_label, + source, + identities, + schema_primary_keys, + vertex_info, + ) + if _identity_value_present(source_id): + edge["outV"] = source_id + edge.setdefault("outVLabel", src_label) + + if isinstance(tgt_label, str): + target_id = _endpoint_backend_id( + tgt_label, + target, + identities, + schema_primary_keys, + vertex_info, + ) + if _identity_value_present(target_id): + edge["inV"] = target_id + edge.setdefault("inVLabel", tgt_label) + + return prepared + + +def validate_graph_payload( + graph_data: Any, + live_schema: dict[str, Any] | None = None, +) -> dict[str, Any]: + """校验 graph_data (vertices/edges) 与 live schema 的兼容性。 + + 覆盖:label 存在性、properties 字段合法性、主键完整性、 + 边端点可解析性、类型匹配、重复检测、索引建议。 + """ + errors: list[str] = [] + warnings: list[str] = [] + + if not isinstance(graph_data, dict): + return { + "valid": False, + "errors": ["graph_data must be an object"], + "warnings": warnings, + } + + vertices = graph_data.get("vertices") + edges = graph_data.get("edges") + + if not isinstance(vertices, list): + errors.append("vertices must be a list") + if not isinstance(edges, list): + errors.append("edges must be a list") + + schema_vlabels: set[str] = set() + schema_props: dict[str, set[str]] = {} + schema_primary_keys: dict[str, list[str]] = {} + schema_property_types: dict[str, str] = {} + schema_elabels: dict[str, dict[str, Any]] = {} + schema_eprops: dict[str, set[str]] = {} + indexed_labels = {"VERTEX": set(), "EDGE": set()} + raw = _schema_payload(live_schema) if live_schema is not None else None + schema_available = raw is not None + if schema_available: + # 把 live schema 摘成 label -> 属性/主键/类型表,后续校验只依赖这份快照。 + # 这避免遍历过程中 schema 被重复读取导致前后判断不一致。 + schema_property_types = _property_types(raw) + for vl in raw.get("vertexlabels", []): + if not isinstance(vl, dict): + continue + name = vl.get("name") + if name: + schema_vlabels.add(name) + schema_props[name] = _property_names(vl.get("properties", [])) + schema_primary_keys[name] = _primary_key_names(vl) + for el in raw.get("edgelabels", []): + if not isinstance(el, dict): + continue + name = el.get("name") + if isinstance(name, str): + schema_elabels[name] = el + schema_eprops[name] = _property_names(el.get("properties", [])) + indexed_labels = _indexed_labels(raw) + + vertex_labels: set[str] = set() + vertex_identity_index: dict[tuple[str, str, Any], int] = {} + if isinstance(vertices, list): + for idx, vertex in enumerate(vertices): + if not isinstance(vertex, dict): + errors.append(f"vertex {idx} must be an object") + continue + label = vertex.get("label") + if label in (None, ""): + errors.append(f"vertex {idx} missing required field: label") + continue + vertex_labels.add(label) + if schema_available and label not in schema_vlabels: + errors.append(f"vertex {idx} label '{label}' does not exist in schema") + + props = vertex.get("properties") + if isinstance(props, dict): + 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}'" + ) + data_type = schema_property_types.get(prop_name) + if data_type and not _value_matches_type(prop_value, data_type): + errors.append( + f"vertex {idx} property '{prop_name}' expects {data_type}, got {type(prop_value).__name__}" + ) + primary_keys = schema_primary_keys.get(label, []) + if primary_keys: + # PRIMARY_KEY label 必须提供完整主键;否则 HugeGraph 无法构造稳定 id, + # 后续边端点也无法可靠解析到这个顶点。 + if not isinstance(props, dict): + 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 + ): + identity = ( + label, + "pk", + tuple(props.get(pk) for pk in primary_keys), + ) + if identity in vertex_identity_index: + errors.append( + f"vertex {idx} duplicate primary key identity for label '{label}': " + f"values={tuple(props.get(pk) for pk in primary_keys)} " + f"already used by vertex {vertex_identity_index[identity]}" + ) + else: + vertex_identity_index[identity] = idx + explicit_id = vertex.get("id") + if _identity_value_present(explicit_id): + # 同一个 payload 内不允许重复 id 或重复主键身份,避免边端点解析到多义目标。 + identity = (label, "id", explicit_id) + if identity in vertex_identity_index: + errors.append( + f"vertex {idx} duplicate id '{explicit_id}' for label '{label}' " + f"already used by vertex {vertex_identity_index[identity]}" + ) + else: + vertex_identity_index[identity] = idx + + edge_labels: set[str] = set() + if isinstance(edges, list): + for idx, edge in enumerate(edges): + if not isinstance(edge, dict): + errors.append(f"edge {idx} must be an object") + continue + label = edge.get("label") + src_label, source = _edge_endpoint(edge, "source") + 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" + ) + 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" + ) + if label in (None, ""): + errors.append(f"edge {idx} missing required field: label") + if src_label in (None, ""): + errors.append(f"edge {idx} missing required field: source_label") + if tgt_label in (None, ""): + errors.append(f"edge {idx} missing required field: target_label") + 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" + ) + 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" + ) + if tgt_label and tgt_label not in schema_vlabels: + 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}'" + ) + 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}'" + ) + 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}'" + ) + data_type = schema_property_types.get(prop_name) + if data_type and not _value_matches_type(prop_value, data_type): + errors.append( + f"edge {idx} property '{prop_name}' expects {data_type}, got {type(prop_value).__name__}" + ) + if source is None and target is None: + continue + if source is None: + errors.append(f"edge {idx} has target but missing source") + if 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), + ("target", tgt_label, target), + ): + if endpoint_value is None or not isinstance(endpoint_label, str): + continue + identities, missing_pk = _endpoint_identities( + endpoint_label, + endpoint_value, + schema_primary_keys, + ) + if missing_pk: + # 端点对象缺主键时直接报错,不退化为字符串匹配; + # 退化匹配会把错误数据静默写成悬空边。 + errors.append( + f"edge {idx} {endpoint_name} endpoint missing primary key for label '{endpoint_label}': {missing_pk}" + ) + continue + if not identities: + # 无 missing_pk 但也无 identities —— 退化状态,显式报错 + # 而非静默通过(例如 endpoint_value 为空对象 {})。 + errors.append( + f"edge {idx} {endpoint_name} endpoint has no resolvable identity " + f"for label '{endpoint_label}': {_format_endpoint_value(endpoint_value)}" + ) + continue + matched_vertex_indices = { + vertex_identity_index[identity] + for identity in identities + if identity in vertex_identity_index + } + if len(matched_vertex_indices) > 1: + errors.append( + f"edge {idx} {endpoint_name} scalar endpoint is ambiguous for " + f"label '{endpoint_label}': {_format_endpoint_value(endpoint_value)} " + "matches different vertices by id and primary key" + ) + + if isinstance(edges, list): + edge_pairs = [] + for e in edges: + if isinstance(e, dict): + edge_pairs.append( + ( + e.get("label"), + e.get("source_label"), + e.get("target_label"), + e.get("source"), + e.get("target"), + ) + ) + if len(edge_pairs) > len( + set(json.dumps(p, sort_keys=True) for p in edge_pairs) + ): + warnings.append("potential duplicate edges detected") + + if indexed_labels["VERTEX"] or indexed_labels["EDGE"]: + for label in sorted(vertex_labels - indexed_labels["VERTEX"]): + warnings.append(f"no vertex index found in schema for label: {label}") + for label in sorted(edge_labels - indexed_labels["EDGE"]): + warnings.append(f"no edge index found in schema for label: {label}") + elif vertex_labels or edge_labels: + warnings.append("verify that appropriate indexes exist for queried properties") + + return { + "valid": not bool(errors), + "errors": errors, + "warnings": warnings, + } + + +def calculate_plan_hash( + graph_data: dict[str, Any], + live_schema: dict[str, Any] | None = None, +) -> str: + """计算图数据导入的计划哈希(兼容旧接口)。""" + from hugegraph_mcp.plan_hash import compute_payload_digest + + cfg = MCPConfig.from_env() + schema_summary = normalized_schema_summary(live_schema) + payload = { + "graph_data": _normalize_graph_data(graph_data, schema_summary), + "graph": cfg.graph, + "graphspace": cfg.graphspace, + } + if schema_summary is not None: + payload["schema_summary"] = schema_summary + return compute_payload_digest(payload) + + +def _fetch_live_schema() -> dict[str, Any] | None: + return fetch_live_schema_or_none() + + +def ingest_graph_data( + graph_data: dict[str, Any], + dry_run: bool = True, + confirm: bool = False, + plan_hash: str | None = None, + nonce: str | None = None, + expires_at: float | None = None, +) -> dict[str, Any]: + """兼容入口:通过 HugeGraph-AI /graph-import 导入图数据。 + + Public MCP V1 `import_graph_data_tool(mode="ingest")` 不调用此函数; + 它使用 manage_graph_data() 的本地校验、dry-run/hash/confirm 和 direct Gremlin 写入。 + """ + + return ingest_graph_data_via_ai( + graph_data=graph_data, + dry_run=dry_run, + confirm=confirm, + plan_hash=plan_hash, + nonce=nonce, + expires_at=expires_at, + ) + + +def ingest_graph_data_via_ai( + graph_data: dict[str, Any], + dry_run: bool = True, + confirm: bool = False, + plan_hash: str | None = None, + nonce: str | None = None, + expires_at: float | None = None, +) -> dict[str, Any]: + """AI-backed 图数据导入 — legacy/internal 安全链入口。 + + dry_run=True: schema 校验 + plan_hash 生成,不写入 + dry_run=False + confirm=True + plan_hash 匹配: 执行写入 + nonce/expires_at: dry_run 返回的 plan_context 中的字段,confirm 时必须传回 + """ + live_schema = _fetch_live_schema() + if live_schema is None: + return envelope_err( + ErrorType.CONNECTION_FAILED, + "Cannot read live schema from HugeGraph Server. Schema validation is required before import.", + suggestion="Ensure HugeGraph Server is running and accessible, then retry.", + retryable=True, + ) + validation = validate_graph_payload(graph_data, live_schema=live_schema) + if not validation["valid"]: + return envelope_err( + ErrorType.SCHEMA_MISMATCH, + "Graph data does not match live schema.", + details={"errors": validation["errors"]}, + ) + + from hugegraph_mcp.plan_hash import ( + build_plan_context, + compute_plan_hash, + compute_payload_digest, + ) + + mutation_summary = _mutation_summary(graph_data) + warnings = validation["warnings"] + + # 构建目标绑定的计划上下文(包含 url、user、readonly、nonce、expires_at) + cfg = MCPConfig.from_env() + schema_summary = normalized_schema_summary(live_schema) + payload_for_digest = { + "graph_data": _normalize_graph_data(graph_data, schema_summary), + "graph": cfg.graph, + "graphspace": cfg.graphspace, + } + if schema_summary is not None: + payload_for_digest["schema_summary"] = schema_summary + + payload_digest = compute_payload_digest(payload_for_digest) + schema_hash = compute_payload_digest(schema_summary) if schema_summary else None + plan_context, _ = build_plan_context( + tool_name="ingest_graph_data", + mode="import", + payload_digest=payload_digest, + schema_hash=schema_hash, + nonce=nonce, + ) + + if dry_run: + return envelope_ok( + { + "plan_hash": compute_plan_hash(plan_context), + "plan_context": { + "nonce": plan_context.nonce, + "expires_at": plan_context.expires_at, + "graph_url": plan_context.graph_url, + "graph_name": plan_context.graph_name, + "graphspace": plan_context.graphspace, + "principal": plan_context.principal, + "readonly": plan_context.readonly, + }, + "mutation_summary": mutation_summary, + "warnings": warnings, + }, + warnings=warnings, + ) + + violation = guard(Capability.DATA_WRITE) + if violation is not None: + return violation + + if not confirm: + 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.", + ) + + # 使用目标绑定验证:重新读取 config 和 schema,重新计算哈希 + # nonce 必须从 dry_run 返回的 plan_context 中传回 + from hugegraph_mcp.plan_hash import verify_plan_hash + + valid, error_type, details = verify_plan_hash( + 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: + message = ( + "Plan has expired. Run dry_run=True again and use the returned plan_hash." + if error_type == ErrorType.PLAN_EXPIRED + else "Plan hash mismatch: config, schema, or payload has changed since dry_run." + ) + return envelope_err( + error_type or ErrorType.PLAN_HASH_MISMATCH, + message, + suggestion="Run dry_run=True again and use the returned plan_hash.", + details=details, + ) + + 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) + # 真实写入仍走 HugeGraph-AI 的 graph-import HTTP 接口;MCP 只负责 + # schema 校验、安全确认和 payload 规范化,不直接导入 hugegraph-llm 流程。 + try: + ai_result = post( + "/graph-import", + json={"data": json.dumps(import_data, sort_keys=True), "schema": cfg.graph}, + ) + except Exception as exc: + 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: 归一化 AI 错误响应,而非透传原始结果 + 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: 规范化导入结果 + normalized = _normalize_import_result( + ai_result=import_result, + planned=planned, + batch_id=batch_id, + request_id=request_id, + cfg=cfg, + ) + + 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 _mutation_summary(graph_data: dict[str, Any]) -> dict[str, int]: + return { + "vertices": len(graph_data.get("vertices") or []), + "edges": len(graph_data.get("edges") or []), + } + + +def _unwrap_ai_payload(data: Any) -> Any: + if isinstance(data, dict) and "ok" in data and "data" in data: + if data.get("ok") is False: + return data + return data.get("data") + return data + + +def _normalize_import_result( + ai_result: Any, + planned: dict[str, int], + batch_id: str, + request_id: str, + cfg: MCPConfig, +) -> dict[str, Any]: + """将 AI 导入结果规范化为 V1 标准格式。 + + M5: success / partial / degraded / error + """ + target = { + "graph_url": cfg.url, + "graph_name": cfg.graph, + "graphspace": cfg.graphspace or "DEFAULT", + } + + if ai_result is None or ( + isinstance(ai_result, dict) and ai_result.get("ok") is False + ): + return { + "status": "error", + "planned": planned, + "written": {"vertices": 0, "edges": 0}, + "failed_items": [], + "warnings": [], + "retryable": True, + "compensation_suggestions": ["Retry the import with dry_run first."], + "target": target, + "batch_id": batch_id, + "request_id": request_id, + } + + written, count_source = _extract_written_counts(ai_result, planned) + failed_items = _extract_failed_items(ai_result) + warnings = _extract_import_warnings(ai_result) + remote_success = ai_result.get("success") if isinstance(ai_result, dict) else None + remote_status = ( + str(ai_result.get("status")).lower() + if isinstance(ai_result, dict) and ai_result.get("status") is not None + else None + ) + + if written is None: + 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." + ) + elif remote_success is False: + written = {"vertices": 0, "edges": 0} + status = "error" + 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." + ) + elif remote_success is False and (written["vertices"] > 0 or written["edges"] > 0): + status = "partial" + elif remote_success is False: + status = "error" + elif remote_status in {"partial", "error", "degraded"}: + status = remote_status + elif written == planned and not failed_items: + status = "success" + elif written["vertices"] > 0 or written["edges"] > 0: + status = "partial" + elif failed_items: + status = "error" + else: + status = "degraded" + + if count_source == "total": + warnings.append( + "HugeGraph-AI import returned only a total written count; vertices/edges were estimated from the planned import order." + ) + + return { + "status": status, + "planned": planned, + "written": written, + "failed_items": failed_items, + "warnings": warnings, + "retryable": status in ("partial", "degraded", "error"), + "compensation_suggestions": ( + ["Review failed_items and retry partial import."] + if status in {"partial", "degraded", "error"} + else [] + ), + "target": target, + "batch_id": batch_id, + "request_id": request_id, + } + + +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" 表示 + 只有总数,vertices/edges 是按导入顺序估算出来的。 + """ + if not isinstance(ai_result, dict): + return None, None + + # 常见的 AI 返回格式 + for key in ("written", "imported", "created", "result"): + data = ai_result.get(key) + if isinstance(data, dict): + verts = data.get("vertices") + if verts is None: + verts = data.get("vertex_count") + edges = data.get("edges") + if edges is None: + edges = data.get("edge_count") + if verts is not None or edges is not None: + return { + "vertices": _safe_int(verts), + "edges": _safe_int(edges), + }, "explicit" + + # 顶层直接有计数 + verts = ai_result.get("vertices_written") + if verts is None: + verts = ai_result.get("vertex_count") + edges = ai_result.get("edges_written") + if edges is None: + edges = ai_result.get("edge_count") + if verts is not None or edges is not None: + return { + "vertices": _safe_int(verts), + "edges": _safe_int(edges), + }, "explicit" + + for key in ("inserted", "total_inserted", "created_count", "imported_count"): + if key in ai_result: + return _split_total_written(_safe_int(ai_result.get(key)), planned), "total" + + return None, None + + +def _safe_int(value: Any) -> int: + if value in (None, ""): + return 0 + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def _split_total_written(total: int, planned: dict[str, int]) -> dict[str, int]: + total = max(0, total) + planned_vertices = max(0, int(planned.get("vertices", 0))) + planned_edges = max(0, int(planned.get("edges", 0))) + vertices = min(planned_vertices, total) + edges = min(planned_edges, max(0, total - vertices)) + return {"vertices": vertices, "edges": edges} + + +def _extract_failed_items(ai_result: Any) -> list[dict[str, Any]]: + """从 AI 结果中提取失败项,统一为对象数组。""" + if not isinstance(ai_result, dict): + return [] + + for key in ("failed_items", "errors", "failures"): + items = ai_result.get(key) + if isinstance(items, list): + normalized = [] + for item in items[:100]: + if isinstance(item, dict): + normalized.append(item) + elif isinstance(item, str): + normalized.append({"message": item}) + else: + normalized.append({"item": str(item)}) + return normalized + + return [] + + +def _extract_import_warnings(ai_result: Any) -> list[str]: + if not isinstance(ai_result, dict): + return [] + raw_warnings = ai_result.get("warnings") + if not isinstance(raw_warnings, list): + return [] + return [str(warning) for warning in raw_warnings[:100]] + + +def _import_error_result( + planned: dict[str, int], + batch_id: str, + request_id: str, + cfg: MCPConfig, +) -> dict[str, Any]: + """构建导入执行错误的规范化结果。""" + return { + "status": "error", + "planned": planned, + "written": {"vertices": 0, "edges": 0}, + "failed_items": [], + "warnings": ["Import execution failed before any writes."], + "retryable": True, + "compensation_suggestions": ["Check HugeGraph-AI service availability."], + "target": { + "graph_url": cfg.url, + "graph_name": cfg.graph, + "graphspace": cfg.graphspace or "DEFAULT", + }, + "batch_id": batch_id, + "request_id": request_id, + } diff --git a/hugegraph-mcp/hugegraph_mcp/tools/inspect_graph.py b/hugegraph-mcp/hugegraph_mcp/tools/inspect_graph.py new file mode 100644 index 000000000..d1ad51389 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/inspect_graph.py @@ -0,0 +1,199 @@ +# 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. + +"""图状态检视 — Agent 连接后的首个推荐工具。 + +inspect_graph() 做尽力而为的状态检查:HugeGraph Server 连接、schema 摘要、 +点边计数、HugeGraph-AI 可用性。任何环节失败都不抛异常, +而是作为 warning 包含在 ok 信封中返回。 +""" + +import time +from typing import Any + +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.envelope import envelope_ok +from hugegraph_mcp.gremlin_tools import execute_gremlin_read +from hugegraph_mcp.hugegraph_ai_client import health_check +from hugegraph_mcp.schema_tools import get_live_schema + + +def _extract_count(result: dict[str, Any]) -> int | None: + if result.get("ok") is False or result.get("success") is False: + return None + + return _extract_count_value(result.get("data")) + + +def _extract_count_value(data: Any) -> int | None: + if isinstance(data, dict) and "data" in data: + return _extract_count_value(data.get("data")) + if isinstance(data, list): + if not data: + return 0 + first = data[0] + return first if isinstance(first, int) else None + if isinstance(data, int): + return data + return None + + +def _count_indexes(raw_schema: dict[str, Any] | None) -> int | None: + if raw_schema is None: + return None + index_labels = raw_schema.get("indexlabels") + if isinstance(index_labels, list): + return len(index_labels) + return 0 + + +def _has_graph_index_info(graph_index_info: Any) -> bool: + if graph_index_info is None: + return False + if isinstance(graph_index_info, dict): + if graph_index_info.get("health_endpoint") == "/openapi.json": + return False + explicit_values = ( + graph_index_info.get("vid_embedding"), + graph_index_info.get("vid_embedding_status"), + graph_index_info.get("vid_index"), + graph_index_info.get("vid_index_status"), + graph_index_info.get("embedding_index"), + graph_index_info.get("embedding_index_status"), + graph_index_info.get("index_status"), + ) + return any(_is_ready_index_value(value) for value in explicit_values) + return False + + +def _is_ready_index_value(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, dict): + if value.get("available") is True or value.get("ready") is True: + return True + status = value.get("status") or value.get("state") + return _is_ready_index_value(status) + if isinstance(value, str): + return value.strip().lower() in {"available", "ready", "loaded", "enabled"} + return False + + +def _warning_from_exception(prefix: str, exc: Exception) -> str: + message = str(exc).strip() + return f"{prefix}: {message}" if message else prefix + + +def _check_ai_status(cfg: MCPConfig) -> tuple[str, Any, list[str]]: + """探测 HugeGraph-AI 是否可用 — 复用统一客户端的认证、超时和回退逻辑。""" + + result = health_check(cfg=cfg) + warnings = list(result.get("warnings") or []) + if result.get("ok"): + return "available", result.get("data"), warnings + + error = result.get("error") or {} + message = error.get("message", "HugeGraph-AI is unavailable") + return "unavailable", None, [message, *warnings] + + +def inspect_graph(include_raw_schema: bool = False) -> dict[str, Any]: + """检视 HugeGraph 服务器状态、schema 摘要、点边计数和 AI 状态。 + + 这是 Agent 连接后的推荐第一个工具。全部失败信息作为 warnings 包含在 ok 信封中, + 不会因为某个组件不可用而阻断整体返回。 + """ + + start = time.time() + cfg = MCPConfig.from_env() + warnings: list[str] = [] + + server_status = "available" + schema_summary: dict[str, Any] | None = None + raw_schema: dict[str, Any] | None = None + simple_schema: dict[str, Any] | None = None + readonly: bool | None = None + vertex_count: int | None = None + edge_count: int | None = None + + try: + schema_result = get_live_schema() + simple_schema = schema_result.get("simple_schema") + schema_summary = simple_schema + raw_schema = schema_result.get("schema") + readonly = schema_result.get("readonly") + except Exception as exc: + server_status = "unavailable" + warnings.append(_warning_from_exception("HugeGraph Server is unavailable", exc)) + + if server_status == "available": + vertex_count = _run_count_query("g.V().count()", "vertex", warnings) + edge_count = _run_count_query("g.E().count()", "edge", warnings) + + ai_status, graph_index_info, ai_warnings = _check_ai_status(cfg) + warnings.extend(ai_warnings) + + data: dict[str, Any] = { + "graph": cfg.graph, + "graphspace": cfg.graphspace, + "hugegraph_server_status": server_status, + "hugegraph_ai_status": ai_status, + "vid_embedding_status": "available" + if _has_graph_index_info(graph_index_info) + else "unknown", + "schema_summary": schema_summary, + "vertex_count": vertex_count, + "edge_count": edge_count, + "index_status": {"total": _count_indexes(raw_schema)}, + "readonly": readonly if readonly is not None else cfg.is_readonly(), + } + if include_raw_schema: + data["raw_schema"] = raw_schema + data["simple_schema"] = simple_schema + + duration_ms = (time.time() - start) * 1000.0 + return envelope_ok( + data, + duration_ms=duration_ms, + warnings=warnings, + next_actions=_next_actions(data), + readonly=data["readonly"], + ) + + +def _run_count_query(query: str, label: str, warnings: list[str]) -> int | None: + try: + result = execute_gremlin_read(query) + count = _extract_count(result) + if count is None: + warnings.append(f"Failed to fetch {label} count") + return count + except Exception as exc: + warnings.append(_warning_from_exception(f"Failed to fetch {label} count", exc)) + return None + + +def _next_actions(data: dict[str, Any]) -> list[str]: + """根据当前状态给出下一步建议,引导 Agent 使用正确的后续工具。""" + actions = [ + "Use inspect_graph_tool with include_raw_schema=true for full schema details" + ] + if data.get("hugegraph_server_status") == "available": + actions.append("Use execute_gremlin_read_tool for read-only graph exploration") + else: + actions.append("Check HugeGraph Server URL, graph name, and credentials") + if data.get("hugegraph_ai_status") != "available": + actions.append( + "Check HugeGraph-AI URL if embedding or graph index features are needed" + ) + return actions diff --git a/hugegraph-mcp/hugegraph_mcp/tools/live_schema.py b/hugegraph-mcp/hugegraph_mcp/tools/live_schema.py new file mode 100644 index 000000000..bbae564d2 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/live_schema.py @@ -0,0 +1,39 @@ +# 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. + +"""Live schema fetch helpers shared by V1 tools.""" + +import logging +from typing import Any + +from hugegraph_mcp import schema_tools + +LOGGER = logging.getLogger("hugegraph_mcp.live_schema") + + +def current_live_schema(live_schema: dict[str, Any] | None = None) -> dict[str, Any]: + """Return the provided schema or fetch the current HugeGraph schema.""" + + if live_schema is not None: + return live_schema + return schema_tools.get_live_schema() + + +def fetch_live_schema_or_none() -> dict[str, Any] | None: + """Best-effort live schema fetch for write paths that return envelopes.""" + + try: + return current_live_schema() + except Exception as exc: + LOGGER.warning("Failed to fetch live schema: %s", exc, exc_info=True) + return None diff --git a/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py b/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py new file mode 100644 index 000000000..2668a56b9 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py @@ -0,0 +1,390 @@ +# 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. + + +"""Graph data management orchestration layer. + +manage_graph_data() keeps the V1 import/delete safety-chain entry point while +validation, Gremlin generation, and execution helpers live in focused modules. +""" + +from typing import Any + +from hugegraph_mcp import gremlin_tools, schema_tools +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok +from hugegraph_mcp.guard import Capability, guard +from hugegraph_mcp.plan_hash import ( + build_plan_context, + compute_payload_digest, + compute_plan_hash, + verify_plan_hash, +) +from hugegraph_mcp.tools import ingest_graph_data +from hugegraph_mcp.tools.graph_data_execute import ( + _fetch_live_schema, + _mutation_summary, + calculate_graph_change_plan_hash, + dry_run_graph_change_plan, + execute_graph_change_plan, +) +from hugegraph_mcp.tools.graph_data_mapping import ( + GraphChangePlan, + _change_plan_from_operations, + graph_data_to_change_plan, +) +from hugegraph_mcp.tools.graph_data_validate import ( + _operations, + _schema_summary, + _validate_mode_operations, + validate_graph_change_plan, +) + +# Only export public entry points; import private helpers from graph_data_* modules. +__all__ = [ + "GraphChangePlan", + "calculate_graph_change_plan_hash", + "dry_run_graph_change_plan", + "execute_graph_change_plan", + "gremlin_tools", + "graph_data_to_change_plan", + "manage_graph_data", + "schema_tools", + "validate_graph_change_plan", +] + + +# ---- 统一入口 ---- + + +def manage_graph_data( + mode: str, + graph_data: dict[str, Any] | None = None, + change_plan: dict[str, Any] | list[dict[str, Any]] | None = None, + dry_run: bool = True, + confirm: bool = False, + plan_hash: str | None = None, + nonce: str | None = None, + expires_at: float | None = None, + extra_hash_context: dict[str, Any] | None = None, + plan_tool_name: str = "manage_graph_data", +) -> dict[str, Any]: + """统一图数据管理入口。 + + 安全链:validate → dry_run → confirm check → plan_hash match → execute + 每个环节失败均返回结构化错误,不抛异常。 + """ + if mode == "import": + if graph_data is None: + return envelope_err( + ErrorType.VALIDATION_ERROR, + "graph_data is required for mode='import'", + ) + if not isinstance(graph_data, dict): + return envelope_err( + ErrorType.VALIDATION_ERROR, + "graph_data must be an object for mode='import'.", + details={"received_type": type(graph_data).__name__}, + ) + elif mode == "delete": + if change_plan is None: + return envelope_err( + 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) + ) + else: + return envelope_err( + ErrorType.VALIDATION_ERROR, + f"Unknown mode: {mode!r}. Use 'import' or 'delete'.", + details={"mode": mode}, + ) + + # 写入前必须读取 live schema。这里不允许在 schema 缺失时降级执行, + # 因为主键、端点和属性合法性都依赖当前图的真实 schema。 + live_schema = _fetch_live_schema() + if live_schema is None: + return envelope_err( + ErrorType.CONNECTION_FAILED, + "Cannot read live schema from HugeGraph Server. Schema validation is required before graph data changes.", + suggestion="Ensure HugeGraph Server is running and accessible, then retry.", + retryable=True, + ) + + if mode == "import" and graph_data is not None: + # import 额外校验原始 graph_data,覆盖 change_plan 不容易表达的规则: + # 顶点主键是否齐全、边端点是否能解析、payload 内部是否有重复身份。 + payload_validation = ingest_graph_data.validate_graph_payload( + graph_data, + live_schema=live_schema, + ) + if not payload_validation["valid"]: + return envelope_err( + ErrorType.SCHEMA_MISMATCH, + "Graph data does not match live schema.", + details={"errors": payload_validation["errors"]}, + ) + # public import 的 scalar source/target 需要结合 live schema 才能区分 + # 单主键值与显式 id;outV/inV 则始终保持 id 语义。 + plan = graph_data_to_change_plan(graph_data, live_schema=live_schema) + + # mode 和 op 的关系是第一道边界:import 只能 create,delete + # 只能执行对应操作,避免用户把高风险操作塞进低风险入口。 + mode_validation = _validate_mode_operations(mode, plan) + if not mode_validation["valid"]: + return envelope_err( + ErrorType.INVALID_GRAPH_DATA, + "Graph change plan contains operations outside the selected mode.", + 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", []), + ) + + plan_context, _ = _build_manage_graph_data_plan_context( + tool_name=plan_tool_name, + mode=mode, + plan=plan, + live_schema=live_schema, + nonce=nonce, + extra_hash_context=extra_hash_context, + ) + target_bound_hash = compute_plan_hash(plan_context) + dry_run_result["plan_hash"] = target_bound_hash + dry_run_result["plan_context"] = _plan_context_payload(plan_context) + dry_run_result["confirmable"] = True + + if dry_run: + warnings = list(dry_run_result.get("warnings", [])) + next_actions: list[str] = [] + if MCPConfig.from_env().is_readonly(): + dry_run_result["confirmable"] = False + dry_run_result["readonly_preview_only"] = True + warnings.append( + "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_actions.append( + "Set HUGEGRAPH_MCP_READONLY=false and rerun dry_run before confirm." + ) + return envelope_ok( + dry_run_result, + warnings=warnings, + next_actions=next_actions, + ) + + # readonly 需要在执行期再检查,不能只依赖 server 注册时隐藏写工具; + # 长运行进程和测试中都可能动态切换配置或直接调用内部函数。 + violation = guard(Capability.DATA_WRITE) + if violation is not None: + return violation + + if not confirm: + return envelope_err( + ErrorType.CONFIRM_REQUIRED, + "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.", + ) + + schema_summary = _schema_summary(live_schema) + valid, error_type, details = verify_plan_hash( + submitted_hash=plan_hash, + tool_name=plan_tool_name, + mode=mode, + payload_digest=_manage_graph_data_payload_digest( + plan, + extra_hash_context=extra_hash_context, + ), + schema_hash=compute_payload_digest(schema_summary) if schema_summary else None, + nonce=nonce, + expires_at=expires_at, + extra_context={"extra_hash_context": extra_hash_context or {}}, + ) + if not valid: + message = ( + "Graph data change plan has expired." + if error_type == ErrorType.PLAN_EXPIRED + else "Provided plan_hash does not match the current graph data change plan." + ) + return envelope_err( + error_type or ErrorType.PLAN_HASH_MISMATCH, + message, + suggestion="Run dry_run=True again and use the returned plan_hash.", + details=details, + ) + + execute_result = execute_graph_change_plan(plan, live_schema=live_schema) + if isinstance(execute_result, dict) and execute_result.get("ok") is False: + return envelope_err( + execute_result["error"]["type"], + execute_result["error"]["message"], + suggestion=execute_result["error"].get("suggestion"), + retryable=execute_result["error"].get("retryable", False), + details=_normalize_execute_result(execute_result, plan), + ) + + normalized = _normalize_execute_result(execute_result, plan) + if normalized.get("success") is False or normalized.get("status") in { + "partial", + "error", + "degraded", + }: + return envelope_err( + ErrorType.FLOW_EXECUTION_FAILED, + "Graph change execution did not complete successfully.", + retryable=bool(normalized.get("retryable")), + details=normalized, + warnings=normalized.get("warnings", []), + ) + + return envelope_ok(normalized) + + +def _build_manage_graph_data_plan_context( + *, + tool_name: str, + mode: str, + plan: Any, + live_schema: dict[str, Any], + nonce: str | None, + extra_hash_context: dict[str, Any] | None, +): + schema_summary = _schema_summary(live_schema) + return build_plan_context( + tool_name=tool_name, + mode=mode, + payload_digest=_manage_graph_data_payload_digest( + plan, + extra_hash_context=extra_hash_context, + ), + schema_hash=compute_payload_digest(schema_summary) if schema_summary else None, + nonce=nonce, + extra_context={"extra_hash_context": extra_hash_context or {}}, + ) + + +def _manage_graph_data_payload_digest( + plan: Any, + *, + extra_hash_context: dict[str, Any] | None, +) -> str: + payload: dict[str, Any] = {"change_plan": plan} + if extra_hash_context is not None: + payload["extra_hash_context"] = extra_hash_context + return compute_payload_digest(payload) + + +def _plan_context_payload(plan_context) -> dict[str, Any]: + return { + "nonce": plan_context.nonce, + "expires_at": plan_context.expires_at, + "graph_url": plan_context.graph_url, + "graph_name": plan_context.graph_name, + "graphspace": plan_context.graphspace, + "principal": plan_context.principal, + "readonly": plan_context.readonly, + } + + +def _normalize_execute_result(execute_result: Any, plan: Any) -> dict[str, Any]: + operations = _operations(plan) + planned = _mutation_summary(operations) + + if isinstance(execute_result, dict) and execute_result.get("ok") is False: + return { + "status": "error", + "success": False, + "planned": planned, + "written": {}, + "failed_items": [execute_result["error"]], + "warnings": execute_result.get("warnings", []), + "retryable": execute_result["error"].get("retryable", False), + "compensation_suggestions": [], + "results": [], + "mutation_summary": planned, + } + + if not isinstance(execute_result, dict): + return { + "status": "degraded", + "success": False, + "planned": planned, + "written": {}, + "failed_items": [{"result": execute_result}], + "warnings": ["Graph change execution returned an unrecognized result."], + "retryable": True, + "compensation_suggestions": ["Inspect graph state before retrying."], + "results": [], + "mutation_summary": planned, + } + + raw_results = execute_result.get("results") + results = raw_results if isinstance(raw_results, list) else [] + failed_items = execute_result.get("failed_items") + 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" + else: + status = "degraded" + + written = _mutation_summary( + [operation for operation, _result in zip(operations, results, strict=False)] + ) + return { + "status": status, + "success": status == "success", + "planned": planned, + "written": written, + "failed_items": failed_items, + "warnings": execute_result.get("warnings", []), + "retryable": status in ("partial", "degraded", "error"), + "compensation_suggestions": ( + ["Inspect graph state before retrying remaining operations."] + if status in ("partial", "degraded") + else [] + ), + "results": results, + "mutation_summary": execute_result.get("mutation_summary", planned), + } diff --git a/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py b/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py new file mode 100644 index 000000000..4f0c8076b --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py @@ -0,0 +1,546 @@ +# 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. + +"""Schema 管理统一入口 — design / validate / dry_run 三种模式。 + +V1 仅提供 schema 设计、校验和 dry-run 预览。公共 apply 工具保留 +FEATURE_DISABLED 响应,实际 schema 写入留到后续版本。 +""" + +import hashlib +import json +from copy import deepcopy +from typing import Any + +from hugegraph_mcp import schema_tools +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok +from hugegraph_mcp.tools.live_schema import current_live_schema +from hugegraph_mcp.tools.schema_utils import normalized_schema_summary + + +ALLOWED_OPERATION_TYPES = frozenset( + { + "create_property_key", + "create_vertex_label", + "create_edge_label", + "create_index_label", + } +) + +REQUIRED_FIELDS = { + "create_property_key": ("name", "data_type"), + "create_vertex_label": ("name",), + "create_edge_label": ("name", "source_label", "target_label"), + "create_index_label": ("name", "base_type", "base_label"), +} + + +ValidationError = dict[str, Any] + + +def _operation_type(operation: dict[str, Any]) -> str: + return str(operation.get("type", "")) + + +def _is_delete_operation(op_type: str) -> bool: + lowered = op_type.lower() + return "delete" in lowered or "drop" in lowered + + +def _validation_error( + operation_index: int, + operation: Any, + reason: str, + suggestion: str, +) -> ValidationError: + return { + "operation_index": operation_index, + "operation": operation, + "reason": reason, + "suggestion": suggestion, + } + + +def _schema_items(live_schema: dict[str, Any], key: str) -> set[str]: + schema = live_schema.get("schema", {}) + return { + item.get("name") + for item in schema.get(key, []) + if isinstance(item, dict) and item.get("name") + } + + +def _collect_planned_creates( + operations: list[dict[str, Any]], +) -> tuple[dict[str, set[str]], list[ValidationError]]: + planned = { + "property_keys": set(), + "vertex_labels": set(), + "edge_labels": set(), + "index_labels": set(), + } + errors: list[ValidationError] = [] + create_type_to_key = { + "create_property_key": "property_keys", + "create_vertex_label": "vertex_labels", + "create_edge_label": "edge_labels", + "create_index_label": "index_labels", + } + create_type_to_label = { + "create_property_key": "property_key", + "create_vertex_label": "vertex_label", + "create_edge_label": "edge_label", + "create_index_label": "index_label", + } + + for idx, operation in enumerate(operations): + if not isinstance(operation, dict): + continue + + op_type = _operation_type(operation) + planned_key = create_type_to_key.get(op_type) + if planned_key is None: + continue + + name = operation.get("name") + if not name: + continue + + if name in planned[planned_key]: + errors.append( + _validation_error( + 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." + ), + ) + ) + continue + + planned[planned_key].add(name) + + return planned, errors + + +def _validate_property_references( + *, + idx: int, + operation: dict[str, Any], + field: str, + property_keys: set[str], + errors: list[ValidationError], +) -> None: + values = operation.get(field, []) + if values in (None, ""): + return + if not isinstance(values, list): + errors.append( + _validation_error( + idx, + operation, + f"{field} must be a list", + f"Use an array of existing property key names for {field}.", + ) + ) + return + + missing_properties = [name for name in values if name not in property_keys] + if missing_properties: + errors.append( + _validation_error( + idx, + operation, + f"{field} references undefined property key(s): {', '.join(missing_properties)}", + "Create these property keys first and rerun validation after they exist in the live schema.", + ) + ) + + +def _validation_warnings(operations: list[dict[str, Any]]) -> list[str]: + warnings: list[str] = [] + for idx, operation in enumerate(operations): + if not isinstance(operation, dict): + continue + if operation.get("type") == "create_vertex_label" and not operation.get( + "primary_keys" + ): + warnings.append( + f"operation {idx} (create_vertex_label) has no primary_keys definition" + ) + return warnings + + +def validate_schema_operations( + operations: list[dict[str, Any]], live_schema: dict[str, Any] | None = None +) -> dict[str, Any]: + """校验 schema 操作与 live schema 的兼容性。 + + 检查操作类型白名单、必填字段、property key 存在性、 + 边端点 label 存在性、索引 base_label 存在性、重复定义检测。 + """ + errors: list[ValidationError] = [] + + if not isinstance(operations, list): + return { + "valid": False, + "errors": [ + _validation_error( + -1, + operations, + "operations must be a list", + "Pass schema operations as a JSON array.", + ) + ], + "warnings": [], + } + + live_schema = current_live_schema(live_schema) + live_property_keys = _schema_items(live_schema, "propertykeys") + live_vertex_labels = _schema_items(live_schema, "vertexlabels") + live_edge_labels = _schema_items(live_schema, "edgelabels") + live_index_labels = _schema_items(live_schema, "indexlabels") + # 同一批 schema 操作允许前面的 create 被后续操作引用,例如先创建 + # property key,再创建使用它的 vertex label。planned_creates 用来模拟 + # 批内依赖,避免合法 migration 被误判为引用不存在。 + planned_creates, duplicate_errors = _collect_planned_creates(operations) + errors.extend(duplicate_errors) + + property_keys = live_property_keys | planned_creates["property_keys"] + vertex_labels = live_vertex_labels | planned_creates["vertex_labels"] + edge_labels = live_edge_labels | planned_creates["edge_labels"] + + for idx, operation in enumerate(operations): + if not isinstance(operation, dict): + errors.append( + _validation_error( + idx, + operation, + "operation must be an object", + "Replace this item with a schema operation object.", + ) + ) + continue + + op_type = _operation_type(operation) + if _is_delete_operation(op_type): + errors.append( + _validation_error( + idx, + operation, + f"unsupported delete/drop type: {op_type}", + "Use create-only schema operations; destructive schema changes are not supported.", + ) + ) + continue + + if op_type not in ALLOWED_OPERATION_TYPES: + errors.append( + _validation_error( + idx, + operation, + f"unsupported type: {op_type}", + "Use one of: create_property_key, create_vertex_label, create_edge_label, create_index_label.", + ) + ) + continue + + for field in REQUIRED_FIELDS[op_type]: + if field not in operation or operation[field] in (None, ""): + errors.append( + _validation_error( + idx, + operation, + f"missing required field: {field}", + 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] + ): + continue + + name = operation.get("name") + if op_type == "create_property_key" and name in live_property_keys: + errors.append( + _validation_error( + idx, + operation, + f"property key already exists: {name}", + "Use a new property key name or remove this create_property_key operation.", + ) + ) + elif op_type == "create_vertex_label": + if name in live_vertex_labels: + errors.append( + _validation_error( + idx, + operation, + f"vertex label already exists: {name}", + "Use a new vertex label name or remove this create_vertex_label operation.", + ) + ) + _validate_property_references( + idx=idx, + operation=operation, + field="properties", + property_keys=property_keys, + errors=errors, + ) + elif op_type == "create_edge_label": + if name in live_edge_labels: + errors.append( + _validation_error( + idx, + operation, + f"edge label already exists: {name}", + "Use a new edge label name or remove this create_edge_label operation.", + ) + ) + for field in ("source_label", "target_label"): + label = operation[field] + if label not in vertex_labels: + errors.append( + _validation_error( + idx, + operation, + f"{field} references undefined vertex label: {label}", + "Create the referenced vertex label first and rerun validation after it exists in the live schema.", + ) + ) + _validate_property_references( + idx=idx, + operation=operation, + field="properties", + property_keys=property_keys, + errors=errors, + ) + elif op_type == "create_index_label": + if name in live_index_labels: + errors.append( + _validation_error( + idx, + operation, + f"index label already exists: {name}", + "Use a new index label name or remove this create_index_label operation.", + ) + ) + base_type = str(operation.get("base_type", "")).upper() + base_label = operation["base_label"] + if base_type == "VERTEX": + if base_label not in vertex_labels: + errors.append( + _validation_error( + idx, + operation, + f"base_label references undefined vertex label: {base_label}", + "Create the referenced vertex label first and rerun validation after it exists in the live schema.", + ) + ) + elif base_type == "EDGE": + if base_label not in edge_labels: + errors.append( + _validation_error( + idx, + operation, + f"base_label references undefined edge label: {base_label}", + "Create the referenced edge label first and rerun validation after it exists in the live schema.", + ) + ) + else: + errors.append( + _validation_error( + idx, + operation, + f"unsupported base_type for index label: {base_type}", + "Use base_type='VERTEX' or base_type='EDGE'.", + ) + ) + _validate_property_references( + idx=idx, + operation=operation, + field="fields", + property_keys=property_keys, + errors=errors, + ) + + return { + "valid": not bool(errors), + "errors": errors, + "warnings": _validation_warnings(operations), + } + + +def _current_plan_context( + operations: list[dict[str, Any]], live_schema: dict[str, Any] | None = None +) -> dict[str, Any]: + cfg = MCPConfig.from_env() + live_schema = current_live_schema(live_schema) + # plan_hash 只绑定写入语义相关的 schema 摘要,不绑定 id/status/create_time + # 等元数据,减少 dry-run 到 apply 之间的无关字段抖动。 + return { + "operations": deepcopy(operations), + "graph": cfg.graph, + "graphspace": cfg.graphspace, + "schema_summary": normalized_schema_summary(live_schema), + } + + +def calculate_plan_hash( + operations: list[dict[str, Any]], live_schema: dict[str, Any] | None = None +) -> str: + payload = _current_plan_context(operations, live_schema) + encoded = json.dumps(payload, sort_keys=True, default=str) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:32] + + +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") + vertex_labels = _schema_items(live_schema, "vertexlabels") + edge_labels = _schema_items(live_schema, "edgelabels") + index_labels = _schema_items(live_schema, "indexlabels") + planned_creates, _ = _collect_planned_creates(operations) + + 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") + } + + for operation in operations: + op_type = operation.get("type") + name = operation.get("name") + if op_type == "create_property_key" and name in property_keys: + warnings.append(f"property key already exists: {name}") + elif op_type == "create_vertex_label" and name in vertex_labels: + warnings.append(f"vertex label already exists: {name}") + elif op_type == "create_edge_label" and name in edge_labels: + warnings.append(f"edge label already exists: {name}") + elif op_type == "create_index_label" and name in index_labels: + warnings.append(f"index label already exists: {name}") + + for label in created_vertex_labels | created_edge_labels: + if label not in indexed_labels: + warnings.append(f"no index operation included for label: {label}") + + return warnings + + +def _mutation_summary(operations: list[dict[str, Any]]) -> str: + counts: dict[str, int] = {} + for operation in operations: + op_type = operation.get("type", "unknown") + counts[op_type] = counts.get(op_type, 0) + 1 + + if not counts: + return "No schema operations planned." + + parts = [f"{op_type}={count}" for op_type, count in sorted(counts.items())] + return "Schema operations planned: " + ", ".join(parts) + + +def _safe_fetch_live_schema() -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + try: + return current_live_schema(), None + except Exception as exc: + 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." + ), + retryable=True, + details={"stage": "schema_fetch", "error": str(exc)}, + ) + + +def dry_run_schema_operations( + operations: list[dict[str, Any]], live_schema: dict[str, Any] | None = None +) -> dict[str, Any]: + live_schema = current_live_schema(live_schema) + validation = validate_schema_operations(operations, live_schema) + if not validation["valid"]: + return validation + + return { + "valid": True, + "plan_hash": calculate_plan_hash(operations, live_schema), + "mutation_summary": _mutation_summary(operations), + "warnings": validation.get("warnings", []) + + _risk_warnings(operations, live_schema), + } + + +def _design_from_operations(operations: list[dict[str, Any]]) -> dict[str, Any]: + params = operations[0] if operations else {} + return schema_tools.design_schema( + thought=params.get("thought", ""), + thought_number=params.get("thought_number", 1), + total_thoughts=params.get("total_thoughts", 4), + next_thought_needed=params.get("next_thought_needed", True), + is_revision=params.get("is_revision", False), + revision_of=params.get("revision_of"), + ) + + +def manage_schema( + mode: str, + operations: list[dict[str, Any]] | None = None, + confirm: bool = False, + plan_hash: str | None = None, +) -> dict[str, Any]: + """统一 schema 管理入口 — 三种模式。 + + - design: 获取分步 schema 设计引导 + - validate: 基于 live schema 校验操作合法性 + - dry_run: 校验 + 生成 plan_hash + 风险警告 + + Args: + mode: 操作模式 ("design" / "validate" / "dry_run") + operations: schema 操作列表 + confirm: reserved for future use (no-op) + plan_hash: reserved for future use (no-op) + """ + operations = operations or [] + + if mode == "design": + return envelope_ok(_design_from_operations(operations)) + + if mode == "validate": + live_schema, error = _safe_fetch_live_schema() + if error: + return error + return envelope_ok(validate_schema_operations(operations, live_schema)) + + if mode == "dry_run": + live_schema, error = _safe_fetch_live_schema() + if error: + return error + return envelope_ok(dry_run_schema_operations(operations, live_schema)) + + return envelope_err( + ErrorType.SCHEMA_MISMATCH, + f"Unsupported manage_schema mode: {mode}", + suggestion="Use one of: design, validate, dry_run.", + ) diff --git a/hugegraph-mcp/hugegraph_mcp/tools/refresh_vid_embeddings.py b/hugegraph-mcp/hugegraph_mcp/tools/refresh_vid_embeddings.py new file mode 100644 index 000000000..8956aa87c --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/refresh_vid_embeddings.py @@ -0,0 +1,88 @@ +# 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. + +"""VID 嵌入刷新 — 手动触发 HugeGraph-AI 重新索引顶点嵌入。 + +需要 INDEX_WRITE 权限 + confirm=True,readonly 模式下拒绝执行。 +""" + +import re +from typing import Any + +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 + + +def refresh_vid_embeddings(confirm: bool = False) -> dict[str, Any]: + """手动刷新 VID 嵌入 — 需要 confirm=True 确认。 + + readonly 模式通过 guard(Capability.INDEX_WRITE) 拒绝。 + """ + + violation = guard(Capability.INDEX_WRITE) + if violation is not None: + return violation + + if not confirm: + return envelope_err( + ErrorType.CONFIRM_REQUIRED, + "VID embedding refresh requires confirm=True.", + suggestion="Pass confirm=True to refresh VID embeddings.", + ) + + ai_result = post("/vid-embeddings/refresh", json={}) + if not ai_result.get("ok"): + return ai_result + + data = _unwrap_ai_payload(ai_result.get("data")) + if isinstance(data, dict) and data.get("ok") is False: + return data + + added, removed, summary = _parse_refresh_result(data) + return envelope_ok( + { + "added": added, + "removed": removed, + "summary": summary, + } + ) + + +def _unwrap_ai_payload(data: Any) -> Any: + if isinstance(data, dict) and "ok" in data and "data" in data: + if data.get("ok") is False: + return data + return data.get("data") + return data + + +def _parse_refresh_result(data: Any) -> tuple[int, int, Any]: + if isinstance(data, dict): + return data.get("added", 0), data.get("removed", 0), data.get("summary") + if isinstance(data, str): + added, removed = _parse_numbers_from_string(data) + return added, removed, data + return 0, 0, str(data) if data is not None else None + + +def _parse_numbers_from_string(text: str) -> tuple[int, int]: + added = 0 + removed = 0 + match = re.search(r"(?:added|add)\s+(\d+)", text, re.IGNORECASE) + if match: + added = int(match.group(1)) + match = re.search(r"(?:removed|remove)\s+(\d+)", text, re.IGNORECASE) + if match: + removed = int(match.group(1)) + return added, removed diff --git a/hugegraph-mcp/hugegraph_mcp/tools/schema_utils.py b/hugegraph-mcp/hugegraph_mcp/tools/schema_utils.py new file mode 100644 index 000000000..96b304218 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/schema_utils.py @@ -0,0 +1,164 @@ +# 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 HugeGraph schema parsing and normalization helpers.""" + +from typing import Any + + +__all__ = [ + "edge_schema_endpoint_label", + "normalized_schema_summary", + "primary_key_names", + "property_names", + "schema_name", + "schema_payload", +] + + +def schema_name(item: Any) -> str | None: + if isinstance(item, str): + return item + if isinstance(item, dict): + name = item.get("name") + return name if isinstance(name, str) else None + return None + + +def property_names(properties: Any) -> set[str]: + if not isinstance(properties, list): + return set() + return {name for prop in properties if (name := schema_name(prop))} + + +def primary_key_names(vertex_label: dict[str, Any]) -> list[str]: + primary_keys = vertex_label.get("primary_keys") + if primary_keys is None: + primary_keys = vertex_label.get("primaryKeys") + if not isinstance(primary_keys, list): + return [] + return [name for pk in primary_keys if (name := schema_name(pk))] + + +def schema_payload(live_schema: dict[str, Any] | None) -> dict[str, Any] | None: + # inspect_graph/get_live_schema 可能返回 {"schema": {...}},也可能直接返回 + # schema 本体;共享入口统一解包,避免各工具各自判断格式。 + if live_schema is None or not isinstance(live_schema, dict): + return None + if "schema" in live_schema: + raw = live_schema.get("schema") + else: + raw = live_schema + return raw if isinstance(raw, dict) else None + + +def edge_schema_endpoint_label(edge_schema: dict[str, Any], endpoint: str) -> Any: + if endpoint == "source": + return edge_schema.get("source_label") or edge_schema.get("sourceLabel") + return edge_schema.get("target_label") or edge_schema.get("targetLabel") + + +def _field_value(item: dict[str, Any], *names: str) -> Any: + for name in names: + if name in item: + return item.get(name) + return None + + +def _normalize_named_list(values: Any) -> list[str]: + if not isinstance(values, list): + return [] + names = [name for value in values if (name := schema_name(value))] + return sorted(names) + + +def _normalize_schema_items( + items: Any, + field_aliases: list[tuple[str, tuple[str, ...]]], +) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + if not isinstance(items, list): + return normalized + + for item in items: + if not isinstance(item, dict): + continue + name = item.get("name") + if not isinstance(name, str): + continue + result: dict[str, Any] = {"name": name} + for output_name, aliases in field_aliases: + value = _field_value(item, *aliases) + if value is None: + continue + if output_name in { + "fields", + "nullable_keys", + "primary_keys", + "properties", + }: + value = _normalize_named_list(value) + result[output_name] = value + normalized.append(result) + + return sorted(normalized, key=lambda value: value["name"]) + + +def normalized_schema_summary( + live_schema: dict[str, Any] | None, +) -> dict[str, Any] | None: + """Return the security-relevant schema subset used for plan hashes.""" + raw = schema_payload(live_schema) + if raw is None: + return None + + # plan hash 只关心会影响写入语义的 schema 字段: + # 属性类型、label 的属性/主键/端点、索引定义。id、状态、创建时间等 + # 元数据被刻意忽略,防止无关字段变化导致 confirm 阶段误拒。 + return { + "propertykeys": _normalize_schema_items( + raw.get("propertykeys"), + [ + ("data_type", ("data_type", "dataType")), + ("cardinality", ("cardinality",)), + ], + ), + "vertexlabels": _normalize_schema_items( + raw.get("vertexlabels"), + [ + ("properties", ("properties",)), + ("primary_keys", ("primary_keys", "primaryKeys")), + ("nullable_keys", ("nullable_keys", "nullableKeys")), + ], + ), + "edgelabels": _normalize_schema_items( + raw.get("edgelabels"), + [ + ("source_label", ("source_label", "sourceLabel")), + ("target_label", ("target_label", "targetLabel")), + ("properties", ("properties",)), + ("nullable_keys", ("nullable_keys", "nullableKeys")), + ("frequency", ("frequency",)), + ], + ), + "indexlabels": _normalize_schema_items( + raw.get("indexlabels"), + [ + ("base_type", ("base_type", "baseType")), + ("base_label", ("base_label", "baseLabel")), + ("index_type", ("index_type", "indexType")), + ("fields", ("fields",)), + ("unique", ("unique",)), + ], + ), + } diff --git a/hugegraph-mcp/pyproject.toml b/hugegraph-mcp/pyproject.toml new file mode 100644 index 000000000..7bdde9993 --- /dev/null +++ b/hugegraph-mcp/pyproject.toml @@ -0,0 +1,60 @@ +# 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. + +[project] +name = "hugegraph-mcp" +version = "0.1.0" +description = "FastMCP server that exposes HugeGraph schema & Gremlin tools over MCP/STDIO" +readme = "README.md" +license = { text = "Apache-2.0" } +requires-python = ">=3.10" +authors = [ + { name = "Apache HugeGraph Contributors", email = "dev@hugegraph.apache.org" }, +] + +dependencies = [ + "fastmcp>=2.2.0", + "hugegraph-python-client", +] +[project.scripts] +hugegraph-mcp = "hugegraph_mcp.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["hugegraph_mcp"] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.uv.sources] +hugegraph-python-client = { workspace = true } + +[tool.ruff.lint.per-file-ignores] +# server.py must patch os.makedirs before importing pyhugegraph (which creates logs at import time) +"hugegraph_mcp/server.py" = ["E402"] + +[dependency-groups] +dev = [ + "pytest~=8.0.0", +] + +[tool.pytest.ini_options] +markers = [ + "live: requires a real HugeGraph Server", + "integration: requires external integration services", + "real_hugegraph: requires a real HugeGraph Server", + "llm: requires HugeGraph-AI or an external LLM provider", +] diff --git a/hugegraph-python-client/src/pyhugegraph/utils/huge_config.py b/hugegraph-python-client/src/pyhugegraph/utils/huge_config.py index ef31cc8b7..dcd1e85cc 100644 --- a/hugegraph-python-client/src/pyhugegraph/utils/huge_config.py +++ b/hugegraph-python-client/src/pyhugegraph/utils/huge_config.py @@ -70,9 +70,8 @@ def __post_init__(self): "Please upgrade to HugeGraph >= 1.5.0 or use an older version of this client (v1.3.x)." ) - # Enable graphspace support for versions > 1.5.0 # HugeGraph 1.7.0+ moved auth APIs to graphspaces/{graphspace}/auth/... - if (major, minor, patch) > (1, 5, 0): + if (major, minor, patch) >= (1, 7, 0): self.graphspace = "DEFAULT" self.gs_supported = True log.warning("graph space is not set, default value 'DEFAULT' will be used.") diff --git a/hugegraph-python-client/src/pyhugegraph/utils/log.py b/hugegraph-python-client/src/pyhugegraph/utils/log.py index e381d25bf..29be927e8 100644 --- a/hugegraph-python-client/src/pyhugegraph/utils/log.py +++ b/hugegraph-python-client/src/pyhugegraph/utils/log.py @@ -117,7 +117,19 @@ def init_logger( if rank > 0: log_filename = f"{log_filename}.rank{rank}" - os.makedirs(os.path.dirname(log_filename), exist_ok=True) + try: + os.makedirs(os.path.dirname(log_filename), exist_ok=True) + except OSError: + # If we can't create the log directory (e.g., read-only filesystem), + # fall back to console-only logging + if stdout_logging: + # Already have console handler, just skip file logging + pass + else: + # No stdout logging and can't create file - create a null handler + null_handler = logging.NullHandler() + log_instance.addHandler(null_handler) + return log_instance file_handler = RotatingFileHandler( log_filename, maxBytes=max_log_size, diff --git a/pyproject.toml b/pyproject.toml index 43ff9cbcc..a9ef7a6e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ [project.optional-dependencies] llm = ["hugegraph-llm"] +mcp = ["hugegraph-mcp"] ml = ["hugegraph-ml"] python-client = ["hugegraph-python-client"] vermeer = ["vermeer-python-client"] @@ -51,7 +52,7 @@ dev = [ ] nk-llm = ["hugegraph-llm", "hugegraph-python-client", "nuitka"] -all = ["hugegraph-python-client", "hugegraph-llm", "hugegraph-ml", "vermeer-python-client"] +all = ["hugegraph-python-client", "hugegraph-llm", "hugegraph-mcp", "hugegraph-ml", "vermeer-python-client"] [project.urls] homepage = "https://hugegraph.apache.org/" @@ -86,6 +87,7 @@ include = ["*/src", "README.md", "LICENSE", "NOTICE"] [tool.uv.sources] hugegraph-llm = { workspace = true } +hugegraph-mcp = { workspace = true } hugegraph-python-client = { workspace = true } hugegraph-ml = { path = "hugegraph-ml", editable = true } vermeer-python-client = { path = "vermeer-python-client", editable = true } @@ -93,6 +95,7 @@ vermeer-python-client = { path = "vermeer-python-client", editable = true } [tool.uv.workspace] members = [ "hugegraph-llm", + "hugegraph-mcp", "hugegraph-python-client" ] @@ -187,10 +190,11 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = ["T20"] "hugegraph-ml/src/hugegraph_ml/examples/**/*.py" = ["T20"] +"hugegraph-mcp/hugegraph_mcp/server.py" = ["E402"] "hugegraph-python-client/src/pyhugegraph/structure/*.py" = ["N802"] [tool.ruff.lint.isort] -known-first-party = ["hugegraph_llm", "hugegraph_python_client", "hugegraph_ml", "vermeer_python_client"] +known-first-party = ["hugegraph_llm", "hugegraph_mcp", "hugegraph_python_client", "hugegraph_ml", "vermeer_python_client"] [tool.ruff.format] quote-style = "double" From ef6c70c4274458382d82e216c8748a4adcf18a82 Mon Sep 17 00:00:00 2001 From: duyifeng01 Date: Sat, 11 Jul 2026 13:06:23 +0800 Subject: [PATCH 2/4] chore(mcp): add V1 tests, docs, skills, and CI Change-Id: I110718665ecc71910b699baef9d06de104dc17e6 --- .github/workflows/hugegraph-mcp.yml | 145 ++ .gitignore | 3 + .../hugegraph-llm/fixed_flow/design.md | 0 .../hugegraph-llm/fixed_flow/requirements.md | 0 .../hugegraph-llm/fixed_flow/tasks.md | 0 .spec/hugegraph-mcp/graph_mcp/requirements.md | 179 ++ hugegraph-llm/src/tests/api/test_thin_api.py | 175 ++ .../hugegraph_op/test_schema_manager.py | 9 + hugegraph-mcp/README.md | 205 ++ hugegraph-mcp/README.zh-CN.md | 208 +++ .../ingest_graph_data_validation_fix_plan.md | 30 + hugegraph-mcp/docs/mcp-v1-prune-prd.md | 293 +++ .../tests/integration/test_real_write_path.py | 582 ++++++ hugegraph-mcp/tests/test_config.py | 229 +++ hugegraph-mcp/tests/test_envelope.py | 123 ++ hugegraph-mcp/tests/test_error_handling.py | 267 +++ .../tests/test_execute_gremlin_read.py | 212 +++ .../tests/test_execute_gremlin_write.py | 79 + .../tests/test_extract_graph_data.py | 117 ++ hugegraph-mcp/tests/test_generate_gremlin.py | 184 ++ hugegraph-mcp/tests/test_get_live_schema.py | 162 ++ hugegraph-mcp/tests/test_gremlin_policy.py | 163 ++ hugegraph-mcp/tests/test_gremlin_safety.py | 110 ++ hugegraph-mcp/tests/test_guard.py | 77 + .../tests/test_hugegraph_ai_client.py | 262 +++ .../tests/test_import_graph_data_tool.py | 175 ++ hugegraph-mcp/tests/test_ingest_graph_data.py | 776 ++++++++ hugegraph-mcp/tests/test_inspect_graph.py | 349 ++++ hugegraph-mcp/tests/test_manage_graph_data.py | 1652 +++++++++++++++++ hugegraph-mcp/tests/test_manage_schema.py | 651 +++++++ hugegraph-mcp/tests/test_plan_hash.py | 358 ++++ hugegraph-mcp/tests/test_readonly_mode.py | 123 ++ .../tests/test_refresh_vid_embeddings.py | 69 + hugegraph-mcp/tests/test_schema_utils.py | 100 + hugegraph-mcp/tests/test_v1_stable_tools.py | 383 ++++ .../src/tests/api/test_auth.py | 6 +- .../src/tests/api/test_auth_routing.py | 61 + skills/hugegraph-data-importer/SKILL.md | 36 + .../agents/openai.yaml | 21 + skills/hugegraph-operator/SKILL.md | 27 + skills/hugegraph-operator/agents/openai.yaml | 21 + skills/hugegraph-query-analyst/SKILL.md | 25 + .../agents/openai.yaml | 21 + skills/hugegraph-regression-tester/SKILL.md | 32 + .../agents/openai.yaml | 21 + skills/hugegraph-schema-designer/SKILL.md | 25 + .../agents/openai.yaml | 21 + 47 files changed, 8766 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/hugegraph-mcp.yml rename {spec => .spec}/hugegraph-llm/fixed_flow/design.md (100%) rename {spec => .spec}/hugegraph-llm/fixed_flow/requirements.md (100%) rename {spec => .spec}/hugegraph-llm/fixed_flow/tasks.md (100%) create mode 100644 .spec/hugegraph-mcp/graph_mcp/requirements.md create mode 100644 hugegraph-llm/src/tests/api/test_thin_api.py create mode 100644 hugegraph-mcp/README.md create mode 100644 hugegraph-mcp/README.zh-CN.md create mode 100644 hugegraph-mcp/docs/ingest_graph_data_validation_fix_plan.md create mode 100644 hugegraph-mcp/docs/mcp-v1-prune-prd.md create mode 100644 hugegraph-mcp/tests/integration/test_real_write_path.py create mode 100644 hugegraph-mcp/tests/test_config.py create mode 100644 hugegraph-mcp/tests/test_envelope.py create mode 100644 hugegraph-mcp/tests/test_error_handling.py create mode 100644 hugegraph-mcp/tests/test_execute_gremlin_read.py create mode 100644 hugegraph-mcp/tests/test_execute_gremlin_write.py create mode 100644 hugegraph-mcp/tests/test_extract_graph_data.py create mode 100644 hugegraph-mcp/tests/test_generate_gremlin.py create mode 100644 hugegraph-mcp/tests/test_get_live_schema.py create mode 100644 hugegraph-mcp/tests/test_gremlin_policy.py create mode 100644 hugegraph-mcp/tests/test_gremlin_safety.py create mode 100644 hugegraph-mcp/tests/test_guard.py create mode 100644 hugegraph-mcp/tests/test_hugegraph_ai_client.py create mode 100644 hugegraph-mcp/tests/test_import_graph_data_tool.py create mode 100644 hugegraph-mcp/tests/test_ingest_graph_data.py create mode 100644 hugegraph-mcp/tests/test_inspect_graph.py create mode 100644 hugegraph-mcp/tests/test_manage_graph_data.py create mode 100644 hugegraph-mcp/tests/test_manage_schema.py create mode 100644 hugegraph-mcp/tests/test_plan_hash.py create mode 100644 hugegraph-mcp/tests/test_readonly_mode.py create mode 100644 hugegraph-mcp/tests/test_refresh_vid_embeddings.py create mode 100644 hugegraph-mcp/tests/test_schema_utils.py create mode 100644 hugegraph-mcp/tests/test_v1_stable_tools.py create mode 100644 skills/hugegraph-data-importer/SKILL.md create mode 100644 skills/hugegraph-data-importer/agents/openai.yaml create mode 100644 skills/hugegraph-operator/SKILL.md create mode 100644 skills/hugegraph-operator/agents/openai.yaml create mode 100644 skills/hugegraph-query-analyst/SKILL.md create mode 100644 skills/hugegraph-query-analyst/agents/openai.yaml create mode 100644 skills/hugegraph-regression-tester/SKILL.md create mode 100644 skills/hugegraph-regression-tester/agents/openai.yaml create mode 100644 skills/hugegraph-schema-designer/SKILL.md create mode 100644 skills/hugegraph-schema-designer/agents/openai.yaml diff --git a/.github/workflows/hugegraph-mcp.yml b/.github/workflows/hugegraph-mcp.yml new file mode 100644 index 000000000..cbb94c116 --- /dev/null +++ b/.github/workflows/hugegraph-mcp.yml @@ -0,0 +1,145 @@ +# +# 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. +# + +name: HugeGraph-MCP CI + +on: + push: + branches: + - "main" + - "release-*" + paths: + - "hugegraph-mcp/**" + - "hugegraph-python-client/**" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/hugegraph-mcp.yml" + pull_request: + paths: + - "hugegraph-mcp/**" + - "hugegraph-python-client/**" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/hugegraph-mcp.yml" + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cache/uv + key: ${{ runner.os }}-mcp-uv-${{ matrix.python-version }}-${{ hashFiles('**/pyproject.toml', 'uv.lock') }} + restore-keys: | + ${{ runner.os }}-mcp-uv-${{ matrix.python-version }}- + + - name: Install MCP dependencies + run: | + uv sync --extra mcp --extra dev + + - name: Check MCP formatting + working-directory: hugegraph-mcp + run: | + uv run ruff format --check hugegraph_mcp tests + + - name: Lint MCP + working-directory: hugegraph-mcp + run: | + uv run ruff check hugegraph_mcp tests + + - name: Run MCP tests + working-directory: hugegraph-mcp + run: | + uv run pytest -m "not live and not integration and not llm" + + real-hugegraph-write-path: + runs-on: ubuntu-latest + services: + hugegraph: + image: hugegraph/hugegraph:1.7.0 + env: + PASSWORD: admin + options: >- + --health-cmd="curl -f http://localhost:8080/versions || exit 1" + --health-interval=10s + --health-timeout=5s + --health-retries=12 + ports: + - 8080:8080 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cache/uv + key: ${{ runner.os }}-mcp-real-hugegraph-uv-${{ hashFiles('**/pyproject.toml', 'uv.lock') }} + restore-keys: | + ${{ runner.os }}-mcp-real-hugegraph-uv- + + - name: Install MCP dependencies + run: | + uv sync --extra mcp --extra dev + + - name: Run real HugeGraph write-path tests + working-directory: hugegraph-mcp + env: + RUN_MCP_REAL_HUGEGRAPH_TESTS: "1" + HUGEGRAPH_URL: http://127.0.0.1:8080 + HUGEGRAPH_GRAPH_PATH: DEFAULT/hugegraph + HUGEGRAPH_USER: admin + HUGEGRAPH_PASSWORD: admin + HUGEGRAPH_MCP_READONLY: "false" + HUGEGRAPH_MCP_ALLOW_AI: "false" + run: | + uv run pytest tests/integration/test_real_write_path.py -m real_hugegraph diff --git a/.gitignore b/.gitignore index 58bf72ff2..dc56cc317 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ MANIFEST # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec +# Exception: allow .spec/ directory to be tracked +!.spec/ +!.spec/** # Installer logs pip-log.txt diff --git a/spec/hugegraph-llm/fixed_flow/design.md b/.spec/hugegraph-llm/fixed_flow/design.md similarity index 100% rename from spec/hugegraph-llm/fixed_flow/design.md rename to .spec/hugegraph-llm/fixed_flow/design.md diff --git a/spec/hugegraph-llm/fixed_flow/requirements.md b/.spec/hugegraph-llm/fixed_flow/requirements.md similarity index 100% rename from spec/hugegraph-llm/fixed_flow/requirements.md rename to .spec/hugegraph-llm/fixed_flow/requirements.md diff --git a/spec/hugegraph-llm/fixed_flow/tasks.md b/.spec/hugegraph-llm/fixed_flow/tasks.md similarity index 100% rename from spec/hugegraph-llm/fixed_flow/tasks.md rename to .spec/hugegraph-llm/fixed_flow/tasks.md diff --git a/.spec/hugegraph-mcp/graph_mcp/requirements.md b/.spec/hugegraph-mcp/graph_mcp/requirements.md new file mode 100644 index 000000000..66303c715 --- /dev/null +++ b/.spec/hugegraph-mcp/graph_mcp/requirements.md @@ -0,0 +1,179 @@ +# HugeGraph MCP Server 需求文档 + +## 简介 + +HugeGraph MCP Server 是一个基于 Model Context Protocol (MCP) 的服务实现,旨在为大语言模型(LLM)提供与 HugeGraph 图数据库的标准化交互接口。该服务使 Claude Desktop 等 MCP 客户端能够通过自然语言操作 HugeGraph 数据库,执行图数据的 CRUD 操作、Schema 管理、图算法调用等功能。 + +本实现将参考 Neo4j MCP 和 NebulaGraph MCP 的设计,采用 Python 语言开发,集成到现有的 hugegraph-llm 项目中,复用现有的 PyHugeClient 和相关工具链。 + +## 需求列表 + +### 需求 1: 项目基础设施 + +**用户故事:** 作为开发者,我希望能够快速安装和配置 HugeGraph MCP Server,以便在不同环境中使用。 + +#### 验收标准 + +1. WHEN 用户通过 `uvx hugegraph-mcp-server` 命令启动 THEN 系统应成功加载配置并启动 MCP 服务 +2. WHEN 用户提供环境变量(HUGEGRAPH_URL, HUGEGRAPH_GRAPH, HUGEGRAPH_USER, HUGEGRAPH_PASSWORD) THEN 系统应使用这些配置连接到 HugeGraph +3. WHEN 用户提供 .env 文件配置 THEN 系统应优先从 .env 文件读取配置信息 +4. WHEN 用户未提供配置 THEN 系统应从现有的 hugegraph_llm.config.huge_settings 中读取默认配置 +5. WHEN 用户指定 `--transport stdio` 参数 THEN 系统应以 STDIO 模式启动 +6. WHEN 用户指定 `--transport http` 参数 THEN 系统应以 HTTP 模式启动,并支持 --host 和 --port 参数 +7. WHEN 启动失败(如连接不上 HugeGraph) THEN 系统应返回清晰的错误信息并退出 + +### 需求 2: 顶点(Vertex)的 CRUD 操作 + +**用户故事:** 作为 LLM 用户,我希望能够通过自然语言创建、查询、更新和删除图中的顶点,以便管理图数据。 + +#### 验收标准 + +1. WHEN LLM 调用 `get_vertex` 工具并提供顶点 ID THEN 系统应返回该顶点的完整信息(包括 label 和所有 properties) +2. WHEN LLM 调用 `get_vertices` 工具并提供多个顶点 ID THEN 系统应批量返回这些顶点的信息 +3. WHEN LLM 调用 `query_vertices` 工具并提供查询条件(label, property filters) THEN 系统应返回符合条件的顶点列表 +4. WHEN 查询顶点的结果超过默认限制(如 100 个) THEN 系统应自动分页并返回前 N 个结果及提示信息 +5. WHEN LLM 调用 `create_vertex` 工具并提供 label 和 properties THEN 系统应创建新顶点并返回顶点 ID +6. WHEN LLM 调用 `create_vertices` 工具并提供顶点列表 THEN 系统应批量创建顶点并返回成功/失败的详细结果 +7. WHEN 创建顶点时 label 不存在于 schema 中 THEN 系统应返回错误信息,提示 label 不存在 +8. WHEN 创建顶点时 properties 类型与 schema 定义不匹配 THEN 系统应返回验证错误并说明期望的类型 +9. WHEN LLM 调用 `update_vertex` 工具并提供顶点 ID 和新的 properties THEN 系统应更新顶点属性并返回更新后的顶点信息 +10. WHEN 更新顶点时提供的 property key 不存在于 schema THEN 系统应返回错误信息 +11. WHEN LLM 调用 `delete_vertex` 工具并提供顶点 ID THEN 系统应删除该顶点及其所有关联的边,并返回删除确认 +12. WHEN 删除的顶点不存在 THEN 系统应返回 404 错误信息 + +### 需求 3: 边(Edge)的 CRUD 操作 + +**用户故事:** 作为 LLM 用户,我希望能够通过自然语言创建、查询和更新图中的边,以便建立和管理实体之间的关系。 + +#### 验收标准 + +1. WHEN LLM 调用 `get_edge` 工具并提供边 ID THEN 系统应返回该边的完整信息(包括 label, source, target 和 properties) +2. WHEN LLM 调用 `get_edges` 工具并提供多个边 ID THEN 系统应批量返回这些边的信息 +3. WHEN LLM 调用 `query_edges` 工具并提供查询条件(label, source_id, target_id, property filters) THEN 系统应返回符合条件的边列表 +4. WHEN 查询边时仅提供 source_id THEN 系统应返回该顶点的所有出边 +5. WHEN 查询边时仅提供 target_id THEN 系统应返回该顶点的所有入边 +6. WHEN LLM 调用 `create_edge` 工具并提供 label, source_id, target_id 和 properties THEN 系统应创建新边并返回边 ID +7. WHEN LLM 调用 `create_edges` 工具并提供边列表 THEN 系统应批量创建边并返回成功/失败的详细结果 +8. WHEN 创建边时 source 或 target 顶点不存在 THEN 系统应返回错误信息,提示顶点不存在 +9. WHEN 创建边时 label 与 source/target 顶点的 label 组合不符合 schema 定义 THEN 系统应返回错误信息 +10. WHEN LLM 调用 `update_edge` 工具并提供边 ID 和新的 properties THEN 系统应更新边属性并返回更新后的边信息 +11. WHEN 批量创建边时部分成功部分失败 THEN 系统应返回成功的边 ID 列表和失败的错误详情 + +### 需求 4: Schema 管理 + +**用户故事:** 作为 LLM 用户,我希望能够查询和理解图数据库的 Schema 结构,以便正确地创建和查询数据。 + +#### 验收标准 + +1. WHEN LLM 调用 `get_schema` 工具 THEN 系统应返回完整的 schema 信息(包括 vertexlabels, edgelabels, propertykeys) +2. WHEN LLM 通过 MCP Resource 访问 `hugegraph://schema` THEN 系统应返回简化的 schema 信息(仅包含必要字段) +3. WHEN LLM 调用 `get_vertex_labels` 工具 THEN 系统应返回所有顶点 label 及其属性定义 +4. WHEN LLM 调用 `get_edge_labels` 工具 THEN 系统应返回所有边 label 及其属性定义和约束(source_label, target_label) +5. WHEN schema 为空(新图) THEN 系统应返回空的 schema 结构并提示用户需要先定义 schema +6. WHEN LLM 请求 schema 的统计信息 THEN 系统应返回 vertex label 数量、edge label 数量和 property key 数量 + +### 需求 5: 图统计信息 + +**用户故事:** 作为 LLM 用户,我希望能够获取图数据库的统计信息,以便了解数据规模和概况。 + +#### 验收标准 + +1. WHEN LLM 通过 MCP Resource 访问 `hugegraph://statistics` THEN 系统应返回图的统计信息 +2. WHEN 获取统计信息 THEN 系统应返回顶点总数、边总数 +3. WHEN 获取统计信息 THEN 系统应返回最多 10000 个顶点 ID 的样本列表 +4. WHEN 获取统计信息 THEN 系统应返回最多 200 个边 ID 的样本列表 +5. WHEN 返回样本数据时数据量超过限制 THEN 系统应在返回结果中包含说明信息,告知这只是部分数据 + +### 需求 6: 自然语言转 Gremlin 查询支持 + +**用户故事:** 作为 LLM 用户,我希望能够获取 text2gremlin 的 prompt 模板,以便 LLM 能够基于这些信息将自然语言转换为 Gremlin 查询。 + +#### 验收标准 + +1. WHEN LLM 通过 MCP Resource 访问 `hugegraph://text2gremlin-prompt` THEN 系统应返回 text2gremlin 的 prompt 模板(包含 Gremlin 语法说明和示例) +2. WHEN LLM 调用 `get_text2gremlin_prompt` 工具 THEN 系统应返回完整的 text2gremlin prompt 模板 +3. WHEN LLM 通过 MCP Resource 访问 `hugegraph://gremlin-examples` THEN 系统应返回预定义的 Gremlin 查询示例 +4. WHEN 调用 `get_text2gremlin_prompt` 工具时 THEN 系统不应调用任何外部 LLM API,仅返回 prompt 文本 +5. WHEN 返回 text2gremlin prompt THEN 系统应提供通用的 Gremlin 语法说明和查询示例,不包含特定图的 schema 信息 + +### 需求 7: 图算法支持 + +**用户故事:** 作为 LLM 用户,我希望能够快速调用常用的图算法,以便分析图结构和关系。 + +#### 验收标准 + +1. WHEN LLM 调用 `shortest_path` 工具并提供起始顶点 ID 和目标顶点 ID THEN 系统应返回最短路径(包括路径上的顶点和边) +2. WHEN 两个顶点之间不存在路径 THEN 系统应返回空路径并说明原因 +3. WHEN LLM 调用 `k_neighbor` 工具并提供顶点 ID 和深度 k THEN 系统应返回 k 度邻居的所有顶点 +4. WHEN k 值过大(如 >5) THEN 系统应限制最大深度并返回警告信息 +5. WHEN k 度邻居查询结果数量过多 THEN 系统应限制返回数量(如最多 1000 个)并提示 +6. WHEN 调用图算法时提供的顶点 ID 不存在 THEN 系统应返回错误信息 + +### 需求 8: 错误处理和验证 + +**用户故事:** 作为开发者和用户,我希望系统能够提供清晰的错误信息和输入验证,以便快速定位和解决问题。 + +#### 验收标准 + +1. WHEN 任何操作失败 THEN 系统应返回详细的错误信息(包括错误类型、原因、建议) +2. WHEN 连接 HugeGraph 失败 THEN 系统应返回连接错误并提示检查配置 +3. WHEN 输入参数缺失或类型错误 THEN 系统应在执行前验证并返回参数验证错误 +4. WHEN 批量操作部分成功 THEN 系统应返回成功列表和失败列表,每个失败项包含详细错误信息 +5. WHEN 创建或更新操作违反 schema 约束 THEN 系统应返回约束违反错误并说明具体的约束要求 +6. WHEN 顶点或边 ID 格式错误 THEN 系统应返回格式错误信息 +7. WHEN 操作超时 THEN 系统应返回超时错误并建议优化查询或增加超时时间 + +### 需求 9: MCP 协议规范 + +**用户故事:** 作为 MCP 客户端,我希望 HugeGraph MCP Server 完全遵循 MCP 协议规范,以便能够无缝集成。 + +#### 验收标准 + +1. WHEN MCP 客户端发送 `initialize` 请求 THEN 服务器应返回服务器信息和支持的协议版本 +2. WHEN MCP 客户端请求 `tools/list` THEN 服务器应返回所有可用的工具列表及其描述 +3. WHEN MCP 客户端请求 `resources/list` THEN 服务器应返回所有可用的资源列表(schema, statistics, gremlin-examples, text2gremlin-prompt) +4. WHEN MCP 客户端调用 `tools/call` 并提供工具名称和参数 THEN 服务器应执行工具并返回结构化结果 +5. WHEN MCP 客户端请求 `resources/read` 并提供资源 URI THEN 服务器应返回对应的资源内容 +6. WHEN 工具调用失败 THEN 服务器应返回符合 MCP 规范的错误响应 +7. WHEN 以 STDIO 模式运行 THEN 服务器应通过标准输入输出进行 JSON-RPC 通信 +8. WHEN 以 HTTP 模式运行 THEN 服务器应支持 HTTP POST 请求和 Server-Sent Events (SSE) + +### 需求 10: 配置和部署 + +**用户故事:** 作为系统管理员,我希望能够灵活配置和部署 HugeGraph MCP Server,以便适应不同的环境需求。 + +#### 验收标准 + +1. WHEN 用户设置环境变量 HUGEGRAPH_URL THEN 系统应使用该 URL 连接 HugeGraph +2. WHEN 用户设置环境变量 HUGEGRAPH_GRAPH THEN 系统应连接到指定的图实例 +3. WHEN 用户设置环境变量 HUGEGRAPH_USER 和 HUGEGRAPH_PASSWORD THEN 系统应使用这些凭据进行认证 +4. WHEN 用户设置环境变量 HUGEGRAPH_GRAPHSPACE THEN 系统应在指定的 graphspace 中操作 +5. WHEN .env 文件存在 THEN 系统应自动加载 .env 文件中的配置 +6. WHEN 环境变量和 .env 文件都存在 THEN 环境变量应优先级更高 +7. WHEN 未提供任何配置 THEN 系统应尝试从 hugegraph_llm.config.huge_settings 读取默认配置 +8. WHEN 用户指定 --log-level 参数 THEN 系统应设置对应的日志级别(DEBUG, INFO, WARNING, ERROR) + +### 需求 11: 测试和质量保证 + +**用户故事:** 作为开发者,我希望项目包含完善的测试,以便保证代码质量和功能正确性。 + +#### 验收标准 + +1. WHEN 运行单元测试 THEN 所有 MCP 工具的核心逻辑应通过单元测试(使用 mock) +2. WHEN 运行集成测试 THEN 系统应能够连接真实的 HugeGraph 实例并执行端到端测试 +3. WHEN 使用 @modelcontextprotocol/inspector 工具 THEN 应能够成功连接并测试所有工具 +4. WHEN 执行测试时 THEN 测试覆盖率应达到核心功能代码的 80% 以上 +5. WHEN 测试失败 THEN 应输出清晰的失败原因和堆栈信息 + +### 需求 12: 文档和示例 + +**用户故事:** 作为新用户,我希望能够通过清晰的文档快速了解和使用 HugeGraph MCP Server。 + +#### 验收标准 + +1. WHEN 用户查看 README.md THEN 应包含项目简介、安装方法、配置说明和快速开始示例 +2. WHEN 用户查看 README.md THEN 应包含每个 MCP 工具的简要说明和使用示例 +3. WHEN 用户查看 API 文档 THEN 应包含每个工具的详细参数说明、返回值格式和错误码 +4. WHEN 用户查看集成指南 THEN 应包含如何在 Claude Desktop 中配置 HugeGraph MCP Server 的步骤 +5. WHEN 用户查看集成指南 THEN 应包含在其他 MCP 客户端(如 Cursor, VS Code)中的配置示例 +6. WHEN 用户查看示例代码 THEN 应包含常见使用场景的完整示例(创建知识图谱、查询关系等) diff --git a/hugegraph-llm/src/tests/api/test_thin_api.py b/hugegraph-llm/src/tests/api/test_thin_api.py new file mode 100644 index 000000000..db4c79c77 --- /dev/null +++ b/hugegraph-llm/src/tests/api/test_thin_api.py @@ -0,0 +1,175 @@ +# 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. + +import importlib +import warnings +from unittest.mock import Mock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from hugegraph_llm.api.models.rag_response import ThinAPIError, ThinAPIMeta, ThinAPIResponse +from hugegraph_llm.api.thin_api import thin_router +from hugegraph_llm.flows import FlowName + + +def _client(monkeypatch, scheduler): + monkeypatch.setattr( + "hugegraph_llm.api.thin_api.SchedulerSingleton.get_instance", + Mock(return_value=scheduler), + ) + app = FastAPI() + app.include_router(thin_router) + return TestClient(app) + + +def _assert_envelope(response_json: dict, expected_ok: bool): + assert response_json["ok"] is expected_ok + assert "data" in response_json + assert "error" in response_json + assert "warnings" in response_json + assert "next_actions" in response_json + assert "meta" in response_json + assert response_json["meta"]["request_id"].startswith("req-") + assert isinstance(response_json["meta"]["duration_ms"], (int, float)) + if expected_ok: + assert response_json["error"] is None + else: + assert response_json["error"] is not None + assert "type" in response_json["error"] + assert "message" in response_json["error"] + + +def test_graph_extract_api_calls_flow(monkeypatch): + scheduler = Mock() + scheduler.schedule_flow.return_value = '{"vertices": [], "edges": []}' + client = _client(monkeypatch, scheduler) + + response = client.post( + "/graph-extract", + json={ + "text": "Alice knows Bob.", + "schema": "{}", + "example_prompt": "extract graph", + "language": "en", + }, + ) + + assert response.status_code == 200 + json_body = response.json() + _assert_envelope(json_body, expected_ok=True) + assert json_body["data"] == '{"vertices": [], "edges": []}' + scheduler.schedule_flow.assert_called_once_with( + FlowName.GRAPH_EXTRACT, + "{}", + "Alice knows Bob.", + "extract graph", + "property_graph", + "en", + ) + + +def test_graph_import_api_calls_flow(monkeypatch): + scheduler = Mock() + scheduler.schedule_flow.return_value = '{"imported": true}' + client = _client(monkeypatch, scheduler) + + response = client.post("/graph-import", json={"data": "{}", "schema": None}) + + assert response.status_code == 200 + json_body = response.json() + _assert_envelope(json_body, expected_ok=True) + assert json_body["data"] == '{"imported": true}' + scheduler.schedule_flow.assert_called_once_with(FlowName.IMPORT_GRAPH_DATA, "{}", None) + + +def test_thin_api_request_models_do_not_emit_schema_shadow_warning(): + import hugegraph_llm.api.models.rag_requests as rag_requests + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + module = importlib.reload(rag_requests) + + assert not any("Field name \"schema\"" in str(item.message) for item in caught) + + extract = module.GraphExtractRequest(text="Alice knows Bob.", schema="{}") + graph_import = module.GraphImportRequest(data="{}", schema=None) + + assert extract.graph_schema == "{}" + assert graph_import.graph_schema is None + assert extract.model_dump(by_alias=True)["schema"] == "{}" + + +def test_vid_embeddings_refresh_api_calls_flow(monkeypatch): + scheduler = Mock() + scheduler.schedule_flow.return_value = "Removed 0 vectors, added 1 vectors." + client = _client(monkeypatch, scheduler) + + response = client.post("/vid-embeddings/refresh", json={}) + + assert response.status_code == 200 + json_body = response.json() + _assert_envelope(json_body, expected_ok=True) + assert json_body["data"] == "Removed 0 vectors, added 1 vectors." + scheduler.schedule_flow.assert_called_once_with(FlowName.UPDATE_VID_EMBEDDINGS) + + +def test_graph_index_info_api_calls_flow(monkeypatch): + scheduler = Mock() + scheduler.schedule_flow.return_value = '{"vertices": 1}' + client = _client(monkeypatch, scheduler) + + response = client.get("/graph-index-info") + + assert response.status_code == 200 + json_body = response.json() + _assert_envelope(json_body, expected_ok=True) + assert json_body["data"] == '{"vertices": 1}' + scheduler.schedule_flow.assert_called_once_with(FlowName.GET_GRAPH_INDEX_INFO) + + +def test_thin_api_returns_flow_execution_failed(monkeypatch): + scheduler = Mock() + scheduler.schedule_flow.side_effect = RuntimeError("secret path /tmp/token") + client = _client(monkeypatch, scheduler) + + response = client.get("/graph-index-info") + + assert response.status_code == 200 + json_body = response.json() + _assert_envelope(json_body, expected_ok=False) + assert json_body["data"] is None + assert json_body["error"]["type"] == "FLOW_EXECUTION_FAILED" + assert json_body["error"]["message"] == "An internal error occurred during flow execution." + assert "secret" not in json_body["error"]["message"] + assert json_body["error"]["source"] == "hugegraph-llm" + assert "details" in json_body["error"] + + +def test_thin_api_response_defaults_are_not_shared(): + first = ThinAPIResponse(ok=True, meta=ThinAPIMeta(request_id="req-a")) + second = ThinAPIResponse(ok=True, meta=ThinAPIMeta(request_id="req-b")) + first.warnings.append("one") + first.next_actions.append("next") + + assert second.warnings == [] + assert second.next_actions == [] + + first_error = ThinAPIError(type="X", message="x") + second_error = ThinAPIError(type="Y", message="y") + first_error.details["secret"] = "value" + assert second_error.details == {} diff --git a/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py b/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py index 7ccf7310e..f42b3c423 100644 --- a/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py +++ b/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py @@ -230,6 +230,15 @@ def test_run_with_empty_schema(self): # Verify the exception message self.assertIn(f"Cannot get {self.graph_name}'s schema from HugeGraph!", str(cm.exception)) + def test_run_with_none_schema(self): + """Test run method when HugeGraph returns an invalid schema payload.""" + self.mock_schema.getSchema.return_value = None + + with self.assertRaises(ValueError) as cm: + self.schema_manager.run({}) + + self.assertIn(f"Cannot get {self.graph_name}'s schema from HugeGraph!", str(cm.exception)) + def test_run_with_existing_context(self): """Test run method with an existing context.""" # Setup mock to return the sample schema diff --git a/hugegraph-mcp/README.md b/hugegraph-mcp/README.md new file mode 100644 index 000000000..41292651f --- /dev/null +++ b/hugegraph-mcp/README.md @@ -0,0 +1,205 @@ +# HugeGraph MCP + +[中文文档](README.zh-CN.md) + +HugeGraph MCP is a Model Context Protocol server for HugeGraph. V1 is designed as a safe, controlled, thin adapter layer: it exposes a small set of stable tools and centralizes configuration, permission checks, read-only Gremlin validation, the dry-run/confirm write safety chain, and the unified response envelope. + +**Requires HugeGraph Server >= 1.7.0** (MCP defaults to `graphspace=DEFAULT` and relies on graphspace-scoped API routes that are not available in older versions). + +## Developer Notes + +### Design Boundary + +V1 does not turn MCP into a second business kernel. The MCP layer is responsible for: + +- Exposing stable MCP tool interfaces +- Reading runtime configuration +- Enforcing permission and readonly guards +- Validating whether Gremlin is read-only +- Generating and validating `plan_hash` +- Returning a unified response envelope +- Forwarding AI capabilities to HugeGraph-AI, or graph reads/writes to HugeGraph Server + +### Public Tool Surface + +V1 exposes these stable tools to users: + +- `inspect_graph_tool` +- `generate_gremlin_tool` +- `execute_gremlin_read_tool` +- `extract_graph_data_tool` +- `import_graph_data_tool` +- `delete_graph_data_tool` +- `design_schema_tool` +- `apply_schema_tool` + +These tools are still registered in MCP, but they are admin/debug capabilities and are blocked by default when `HUGEGRAPH_MCP_ADMIN_MODE=false`. Write-capable admin tools also require `HUGEGRAPH_MCP_READONLY=false`: + +- `execute_gremlin_write_tool` +- `refresh_vid_embeddings_tool` + +### Unified Response Envelope + +V1 high-level tools return a unified envelope: + +```json +{ + "ok": true, + "data": {}, + "error": null, + "warnings": [], + "next_actions": [], + "meta": { + "request_id": "req-...", + "graph": "hugegraph", + "graphspace": "DEFAULT", + "readonly": true, + "duration_ms": 12.3 + } +} +``` + +When a call fails, `ok=false` and `error` uses this structure: + +```json +{ + "type": "READONLY_VIOLATION", + "message": "DATA_WRITE capability is disabled in read-only mode", + "suggestion": "Disable HUGEGRAPH_MCP_READONLY to allow this operation.", + "retryable": false, + "source": "hugegraph-mcp", + "details": {} +} +``` + +## Tool Reference + +### User-Facing Tool Overview + +| Tool | Description | +|------|-------------| +| `inspect_graph_tool` | Inspect HugeGraph Server status, schema summary, vertex/edge counts, readonly state, and AI availability | +| `generate_gremlin_tool` | Generate Gremlin from natural language; defaults to generation only; `execute=true` still requires read-only validation | +| `execute_gremlin_read_tool` | Execute read-only Gremlin queries; rejects queries whose safety cannot be confirmed | +| `extract_graph_data_tool` | Extract candidate graph data from natural language text and return vertex/edge structures without writing to HugeGraph | +| `import_graph_data_tool` | Structured graph data import entrypoint; real writes must pass `dry_run -> plan_hash -> confirm` | +| `delete_graph_data_tool` | Controlled delete entrypoint; supports only exact vertex or edge deletion, not conditional bulk delete or cascade delete | +| `design_schema_tool` | Provide schema design guidance from proposed schema operations without modifying the database | +| `apply_schema_tool` | V1 supports only schema `validate` and `dry_run`; real `apply` is currently disabled | +| `execute_gremlin_write_tool` | Execute direct Gremlin writes; disabled by default and available only when `HUGEGRAPH_MCP_ADMIN_MODE=true` and `HUGEGRAPH_MCP_READONLY=false` | +| `refresh_vid_embeddings_tool` | Refresh VID embeddings and mutate index state; disabled by default and available only when `HUGEGRAPH_MCP_ADMIN_MODE=true` and `HUGEGRAPH_MCP_READONLY=false` | + +The old `query_graph_tool`, `manage_schema_tool`, and `manage_graph_data_tool` are no longer exposed as user interfaces. New integrations should use the stable tools listed above. + +## Write Safety Chain + +All user-reachable write operations must follow this chain: + +```text +dry_run=true + -> user/agent reviews preview, warnings, matched_count, mutation_summary + -> records plan_hash, nonce, expires_at + -> dry_run=false + confirm=true + original payload + plan_hash + nonce + expires_at + -> MCP revalidates target, permission, schema, payload digest, and expiry + -> executes the write + -> returns write/delete results and failure details +``` + +`plan_hash` is not just a payload hash. It binds at least: + +- Tool name +- Operation mode +- Graph URL +- Graph name +- Graph space +- Permission state such as readonly/admin flags +- Current schema hash +- Normalized payload digest +- Nonce +- Expiry + +The confirm phase must fully revalidate the plan. If the dry-run result expires, the target graph changes, the schema changes, the payload changes, or permissions change, confirm must fail and require a new dry run. + +### Import Semantics + +`import_graph_data_tool(mode="ingest")` is the public MCP V1 structured import path. It uses local schema validation, dry-run/hash/confirm, and direct Gremlin writes through `manage_graph_data()`; it does not call the HugeGraph-AI `/graph-import` HTTP path. The legacy/internal AI-backed function is named `ingest_graph_data_via_ai()`. + +When `import_graph_data_tool(mode="ingest")` executes a create operation, it returns one of three states: + +- `success`: all writes succeeded +- `partial` / `degraded`: some writes succeeded, some failed, or the final state cannot be fully confirmed +- `error`: the write failed + +The response should include written counts, failure details, and compensation suggestions to avoid an untraceable partial write. + +#### Edge Endpoint Contract + +Edge endpoints accept both object and scalar forms: + +```text +object source/target -> forwarded as-is + {"id": "1:Alice"} -> HugeGraph vertex id match + {"name": "Alice"} -> complete primary-key match; arbitrary property + matching is not part of the public graph_data contract + +scalar source/target -> if the live schema says the endpoint label has exactly + one primary key, match by that primary key first; + otherwise fall back to {"id": value} + +outV / inV / vertex id in payload -> always HugeGraph vertex id, with no + primary-key remapping +``` + +The scalar endpoint form is a same-payload import convenience, but under a single-primary-key live schema it is resolved as a primary-key match and may match an already existing vertex in the graph. It is not limited to vertices in the current payload, so edge-only or edge-to-existing-vertex payloads are valid when the dry-run live match resolves each endpoint to exactly one vertex. + +### Delete Semantics + +`delete_graph_data_tool` is a controlled delete tool: + +- The dry-run phase must resolve the concrete objects that would be deleted +- The confirm phase must re-match and verify that the target is unchanged +- The tool must verify after deletion that the target no longer exists +- Vertex deletion is rejected by default when the vertex has associated edges + +Therefore, when deleting a vertex with associated edges, explicitly dry-run and delete the related edges first, then dry-run and delete the vertex. + +## Configuration + +All configuration is read from environment variables. + +| Variable | Default | Description | +|----------|---------|-------------| +| `HUGEGRAPH_URL` | `http://127.0.0.1:8080` | HugeGraph Server URL | +| `HUGEGRAPH_GRAPH_PATH` | `DEFAULT/hugegraph` | Graph path in `GRAPH_SPACE/GRAPH_NAME` format | +| `HUGEGRAPH_GRAPHSPACE` | unset | Override graph space separately | +| `HUGEGRAPH_GRAPH` | unset | Override graph name separately | +| `HUGEGRAPH_USER` | `admin` | HugeGraph username | +| `HUGEGRAPH_PASSWORD` | `""` | HugeGraph password | +| `HUGEGRAPH_MCP_READONLY` | `true` | Whether readonly mode is enabled | +| `HUGEGRAPH_MCP_ALLOW_AI` | `false` | Whether HugeGraph-AI calls are allowed | +| `HUGEGRAPH_MCP_ADMIN_MODE` | `false` | Whether admin/debug tools are enabled | +| `HUGEGRAPH_AI_URL` | `http://127.0.0.1:8001` | HugeGraph-AI URL | +| `HUGEGRAPH_AI_GRAPH_URL` | unset | Graph URL used by HugeGraph-AI; defaults to `HUGEGRAPH_URL` when unset | +| `HUGEGRAPH_MCP_TIMEOUT_SECONDS` | `30` | AI call timeout in seconds | +| `HUGEGRAPH_MCP_MAX_REPEAT_TIMES` | `10` | Recommended maximum for read-cost warnings on `repeat().times(n)` | + +`HUGEGRAPH_MCP_TIMEOUT_SECONDS` only applies to HugeGraph-AI HTTP calls; it does not apply to PyHugeClient Gremlin queries. Read-only Gremlin cost boundaries are reported as non-blocking read cost guard warnings for bare full-graph scans, `repeat()` without a `times()` bound, and `path` / `group` / `profile` without `limit` or `range`. + +Recommended safe defaults: + +- `HUGEGRAPH_MCP_READONLY=true` +- `HUGEGRAPH_MCP_ALLOW_AI=false` +- `HUGEGRAPH_MCP_ADMIN_MODE=false` + +Common combinations: + +| Scenario | Configuration | +|----------|---------------| +| Read-only graph query | `READONLY=true`, `ALLOW_AI=false` | +| AI Gremlin generation / text extraction | `READONLY=true`, `ALLOW_AI=true` | +| Controlled import and delete | `READONLY=false`, set `ALLOW_AI=true` as needed | +| Administration/debugging | `READONLY=false`, `ADMIN_MODE=true` | + +## License + +Apache License 2.0 diff --git a/hugegraph-mcp/README.zh-CN.md b/hugegraph-mcp/README.zh-CN.md new file mode 100644 index 000000000..ef6214e34 --- /dev/null +++ b/hugegraph-mcp/README.zh-CN.md @@ -0,0 +1,208 @@ +# HugeGraph MCP + +[English](README.md) + +HugeGraph MCP 是 HugeGraph 的 Model Context Protocol Server。V1 的定位是安全、可控的薄适配层:对外暴露少量稳定工具,内部统一处理配置、权限、只读 Gremlin 校验、dry-run/confirm 写入安全链和统一响应格式。 + +**要求 HugeGraph Server >= 1.7.0**(MCP 默认使用 `graphspace=DEFAULT` 并依赖 graphspace 路由 API,旧版本不支持)。 + +## 开发者说明 + +### 设计边界 + +V1 不把 MCP 做成另一套业务内核。MCP 层负责: + +- 暴露稳定 MCP 工具接口 +- 读取运行时配置 +- 执行权限和 readonly guard +- 校验 Gremlin 是否只读 +- 生成并校验 `plan_hash` +- 统一响应 envelope +- 将 AI 能力转发给 HugeGraph-AI,或将图读写转发给 HugeGraph Server + + + +### 对外工具面 + +V1 对用户暴露稳定工具: + +- `inspect_graph_tool` +- `generate_gremlin_tool` +- `execute_gremlin_read_tool` +- `extract_graph_data_tool` +- `import_graph_data_tool` +- `delete_graph_data_tool` +- `design_schema_tool` +- `apply_schema_tool` + +以下工具仍注册在 MCP 中,但属于管理/调试能力,默认受 `HUGEGRAPH_MCP_ADMIN_MODE=false` 阻断。具备写入能力的管理工具还要求 `HUGEGRAPH_MCP_READONLY=false`: + +- `execute_gremlin_write_tool` +- `refresh_vid_embeddings_tool` + + +### 统一响应格式 + +V1 高层工具返回统一 envelope: + +```json +{ + "ok": true, + "data": {}, + "error": null, + "warnings": [], + "next_actions": [], + "meta": { + "request_id": "req-...", + "graph": "hugegraph", + "graphspace": "DEFAULT", + "readonly": true, + "duration_ms": 12.3 + } +} +``` + +失败时 `ok=false`,`error` 使用统一结构: + +```json +{ + "type": "READONLY_VIOLATION", + "message": "DATA_WRITE capability is disabled in read-only mode", + "suggestion": "Disable HUGEGRAPH_MCP_READONLY to allow this operation.", + "retryable": false, + "source": "hugegraph-mcp", + "details": {} +} +``` + + +## 工具参考 + +### 用户可用工具总览 + +| 工具 | 说明 | +|------|------| +| `inspect_graph_tool` | 查看 HugeGraph Server 状态、schema 摘要、点边计数、readonly 状态和 AI 可用性 | +| `generate_gremlin_tool` | 根据自然语言生成 Gremlin;默认只生成,不执行;`execute=true` 时也必须通过只读校验 | +| `execute_gremlin_read_tool` | 执行只读 Gremlin 查询;无法确认安全时拒绝执行 | +| `extract_graph_data_tool` | 从自然语言文本抽取候选图数据,返回点和边结构,不写入 HugeGraph | +| `import_graph_data_tool` | 结构化图数据导入入口;真实写入必须经过 `dry_run -> plan_hash -> confirm` | +| `delete_graph_data_tool` | 受控删除入口;只支持精确删除点或边,不支持条件批量删除和级联删除 | +| `design_schema_tool` | 根据 schema 操作草案给出设计建议,不修改数据库 | +| `apply_schema_tool` | V1 只支持 schema `validate` 和 `dry_run`;真实 `apply` 当前禁用 | +| `execute_gremlin_write_tool` | 直接执行 Gremlin 写语句;默认禁用,仅 `HUGEGRAPH_MCP_ADMIN_MODE=true` 且 `HUGEGRAPH_MCP_READONLY=false` 时可用 | +| `refresh_vid_embeddings_tool` | 刷新 VID embeddings,会改变索引状态;默认禁用,仅 `HUGEGRAPH_MCP_ADMIN_MODE=true` 且 `HUGEGRAPH_MCP_READONLY=false` 时可用 | + + + + +## 写入安全链 + +所有用户可触达的写操作都必须经过: + +```text +dry_run=true + -> 用户/agent 审查 preview、warnings、matched_count、mutation_summary + -> 记录 plan_hash、nonce、expires_at + -> dry_run=false + confirm=true + 原始 payload + plan_hash + nonce + expires_at + -> MCP 重新校验目标、权限、schema、payload digest 和过期时间 + -> 执行写入 + -> 返回写入/删除结果和失败明细 +``` + +`plan_hash` 不是简单的 payload 哈希。它至少绑定: + +- 工具名 +- 操作 mode +- graph url +- graph name +- graph space +- readonly/admin 等权限状态 +- 当前 schema hash +- normalized payload digest +- nonce +- expiry + +confirm 阶段必须全量重验。dry-run 结果过期、目标图变化、schema 变化、payload 变化或权限变化时,confirm 必须失败并要求重新 dry-run。 + +### 导入语义 + +`import_graph_data_tool(mode="ingest")` 是 MCP V1 对外的结构化导入路径。它使用本地 schema 校验、dry-run/hash/confirm 和 `manage_graph_data()` 的 direct Gremlin 写入;不会调用 HugeGraph-AI `/graph-import` HTTP 路径。legacy/internal 的 AI-backed 函数命名为 `ingest_graph_data_via_ai()`。 + +`import_graph_data_tool(mode="ingest")` 执行创建时返回三类状态: + +- `success`:全部写入成功 +- `partial` / `degraded`:部分写入成功,部分失败或结果不可完全确认 +- `error`:写入失败 + +响应需要包含已写数量、失败明细和可补偿建议,避免出现“半写半崩但不可追踪”。 + +#### 边端点契约 + +边端点同时支持 object 和 scalar 写法: + +```text +object source/target -> 原样透传 + {"id": "1:Alice"} -> 按 HugeGraph vertex id 匹配 + {"name": "Alice"} -> 按完整主键匹配;任意属性匹配不属于 public graph_data 契约 + +scalar source/target -> 如果 live schema 中该端点 label 恰好是单主键, + 优先按该主键匹配;否则回退为 {"id": value} + +outV / inV / payload 中显式 vertex id -> 始终表示 HugeGraph vertex id, + 不做主键改写 +``` + +scalar 端点是 same-payload import 的便捷写法,但在单主键 live schema 下会按主键解析,可能匹配图中已存在的顶点;它不只在当前 payload 内查找。因此 edge-only 或连接 payload 外已有顶点的输入是合法的,前提是 dry-run 的 live match 能把每个端点唯一解析到一个顶点。 + +### 删除语义 + +`delete_graph_data_tool` 的删除是受控删除: + +- dry-run 阶段必须查出将被删除的具体对象 +- confirm 阶段必须重新匹配并校验目标一致 +- 删除后必须反查确认目标不存在 +- 顶点有关联边时默认拒绝删除 + +因此,删除一个有关联边的点时,应先 dry-run 并删除相关边,再 dry-run 并删除该点。 + +## 配置说明 + +所有配置通过环境变量读取。 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `HUGEGRAPH_URL` | `http://127.0.0.1:8080` | HugeGraph Server 地址 | +| `HUGEGRAPH_GRAPH_PATH` | `DEFAULT/hugegraph` | 图路径,格式为 `GRAPH_SPACE/GRAPH_NAME` | +| `HUGEGRAPH_GRAPHSPACE` | 未设置 | 单独覆盖 graph space | +| `HUGEGRAPH_GRAPH` | 未设置 | 单独覆盖 graph name | +| `HUGEGRAPH_USER` | `admin` | HugeGraph 用户名 | +| `HUGEGRAPH_PASSWORD` | `""` | HugeGraph 密码 | +| `HUGEGRAPH_MCP_READONLY` | `true` | 是否启用只读模式 | +| `HUGEGRAPH_MCP_ALLOW_AI` | `false` | 是否允许调用 HugeGraph-AI | +| `HUGEGRAPH_MCP_ADMIN_MODE` | `false` | 是否启用管理/调试工具 | +| `HUGEGRAPH_AI_URL` | `http://127.0.0.1:8001` | HugeGraph-AI 地址 | +| `HUGEGRAPH_AI_GRAPH_URL` | 未设置 | AI 侧使用的图地址,未设置时使用 `HUGEGRAPH_URL` | +| `HUGEGRAPH_MCP_TIMEOUT_SECONDS` | `30` | AI 调用超时时间 | +| `HUGEGRAPH_MCP_MAX_REPEAT_TIMES` | `10` | `repeat().times(n)` 只读成本 warning 的建议最大值 | + +`HUGEGRAPH_MCP_TIMEOUT_SECONDS` 仅作用于 HugeGraph-AI HTTP 调用,不作用于 PyHugeClient 的 Gremlin 查询。只读 Gremlin 的成本边界由 read cost guard 以非阻塞 warning 形式提示,包括裸全图扫描、`repeat()` 无 `times()` 上限、以及 `path` / `group` / `profile` 无 `limit` 或 `range`。 + +推荐默认安全姿态: + +- `HUGEGRAPH_MCP_READONLY=true` +- `HUGEGRAPH_MCP_ALLOW_AI=false` +- `HUGEGRAPH_MCP_ADMIN_MODE=false` + +常见组合: + +| 场景 | 配置 | +|------|------| +| 只读图查询 | `READONLY=true`,`ALLOW_AI=false` | +| AI 生成只读 Gremlin / 文本抽取 | `READONLY=true`,`ALLOW_AI=true` | +| 允许受控导入和删除 | `READONLY=false`,按需设置 `ALLOW_AI=true` | +| 管理/调试 | `READONLY=false`,`ADMIN_MODE=true` | + +## License + +Apache License 2.0 diff --git a/hugegraph-mcp/docs/ingest_graph_data_validation_fix_plan.md b/hugegraph-mcp/docs/ingest_graph_data_validation_fix_plan.md new file mode 100644 index 000000000..4e553045b --- /dev/null +++ b/hugegraph-mcp/docs/ingest_graph_data_validation_fix_plan.md @@ -0,0 +1,30 @@ +# ingest_graph_data Validation Fix Completion Record + +## Completed Fixes + +- Primary key enforcement now uses the live HugeGraph schema as the source of truth. Vertices missing required primary key properties, or carrying empty primary key values, are rejected with `SCHEMA_MISMATCH`. +- Edge endpoint resolution now validates `source` / `target` and `outV` / `inV` payload shapes against vertices present in the same payload. Missing primary key fields and unresolved endpoints are rejected before dry-run returns a `plan_hash`. +- Duplicate vertex identity is now a hard validation error. Reused schema primary key tuples or explicit vertex IDs are rejected with `SCHEMA_MISMATCH` instead of being reported as warnings. + +## Out of Scope + +- Cross-payload duplicate detection against data already stored in HugeGraph. +- Changing the public MCP tool interface or envelope shape. +- Import execution behavior beyond preserving the existing `dry_run -> plan_hash -> confirm` safety chain. +- Schema creation, schema migration, or automatic primary key inference when the live schema is unavailable. + +## Verification + +Run targeted ingest validation tests: + +```powershell +cd hugegraph-mcp +uv run pytest tests/test_ingest_graph_data.py -v +``` + +Run the full `hugegraph-mcp` regression suite: + +```powershell +cd hugegraph-mcp +uv run pytest +``` diff --git a/hugegraph-mcp/docs/mcp-v1-prune-prd.md b/hugegraph-mcp/docs/mcp-v1-prune-prd.md new file mode 100644 index 000000000..af4ff0551 --- /dev/null +++ b/hugegraph-mcp/docs/mcp-v1-prune-prd.md @@ -0,0 +1,293 @@ +# HugeGraph MCP V1 Code Prune PRD + +## 1. 背景 + +当前 MCP-V1 分支已经把 V2/未来能力从用户公开入口隐藏或默认禁用,但仓库内仍保留了部分 V2/未来能力的实现代码、测试和配置项。 + +这会带来两个问题: + +- V1 PR 规模过大,评审者需要同时阅读 V1 稳定能力和后续能力实现。 +- V1 边界不够清晰,隐藏代码、测试和配置容易让人误以为 V2 能力已经属于 V1 交付范围。 + +因此,V1 分支应只保留 V1 相关代码。V2 及以后能力从 V1 分支删除,后续按独立 PR 重新提交。 + +## 2. 目标 + +- 将 MCP-V1 分支裁剪为只包含 V1 必需代码、文档和测试。 +- 删除 V2/未来能力的源码、测试、配置和文档描述。 +- 降低 V1 PR 的 diff 规模和评审复杂度。 +- 保证 V1 公开工具契约、readonly 默认安全、plan_hash 安全链和 disabled feature 行为不退化。 + +## 3. 非目标 + +- 不在本次 V1 PR 中实现或保留 GraphRAG 问答能力。 +- 不在本次 V1 PR 中实现或保留 SQL/SQLite 数据源导入能力。 +- 不在本次 V1 PR 中实现或保留 table import 映射能力。 +- 不在本次 V1 PR 中实现或保留 graph data update 能力。 +- 不在本次 V1 PR 中实现或保留 schema apply 执行能力。 +- 不改变 V1 已确认的公开稳定工具行为。 + +## 4. V1 保留范围 + +### 4.1 保留的 MCP 工具 + +V1 保留 8 个稳定用户工具: + +- `inspect_graph_tool` +- `generate_gremlin_tool` +- `execute_gremlin_read_tool` +- `extract_graph_data_tool` +- `design_schema_tool` +- `apply_schema_tool` +- `import_graph_data_tool` +- `delete_graph_data_tool` + +V1 继续保留 2 个已注册但默认阻断的 admin/debug 工具,除非后续决定进一步缩小公开面: + +- `execute_gremlin_write_tool` +- `refresh_vid_embeddings_tool` + +保留原因:这两个工具已经属于当前 V1 注册契约的一部分,并且默认由 `HUGEGRAPH_MCP_ADMIN_MODE=false` 返回 `FEATURE_DISABLED`。它们不是 V2 功能,但必须在 PR 描述中标为 admin/debug gated,不作为普通用户主流程宣传。 + +### 4.2 保留的内部能力 + +- MCP 配置读取:HugeGraph URL、graph、graphspace、user、password、readonly、allow_ai、AI URL、timeout。 +- 统一 envelope:`ok/data/error/warnings/next_actions/meta`。 +- readonly/admin/confirm 权限守卫。 +- Gremlin read safety policy。 +- `plan_hash` 生成和校验。 +- live schema 获取和 schema summary。 +- V1 schema design / validate / dry_run。 +- V1 graph_data extract / ingest / delete。 +- V1 受控删除只支持精确 `delete_vertex` / `delete_edge`。 + +## 5. V1 删除范围 + +### 5.1 GraphRAG 问答能力 + +删除: + +- `hugegraph_mcp/tools/query_graph.py` +- `tests/test_query_graph.py` + +同步删除或收敛: + +- `HUGEGRAPH_MCP_MAX_CONTEXT_ITEMS` +- `HUGEGRAPH_MCP_ENABLE_GRAPHRAG_EXPERIMENTAL` +- README / 中文 README 中 GraphRAG 预留配置描述 +- `tests/test_config.py` 中 GraphRAG 配置断言 + +后续归属:V2 GraphRAG PR。 + +### 5.2 SQL/SQLite 数据源能力 + +删除: + +- `hugegraph_mcp/tools/sql_modes.py` +- `hugegraph_mcp/tools/sql_table.py` +- `tests/test_sql_table.py` + +同步删除或收敛: + +- `HUGEGRAPH_MCP_SQL_ENABLED` +- `HUGEGRAPH_MCP_SQLITE_ALLOWLIST` +- `HUGEGRAPH_MCP_SQL_MAX_PREVIEW_ROWS` +- `HUGEGRAPH_MCP_SQL_MAX_IMPORT_ROWS` +- `HUGEGRAPH_MCP_SQL_TIMEOUT_SECONDS` +- README / 中文 README 中 SQL 预留配置描述 +- `ErrorType.UNSUPPORTED_SQL_SOURCE` +- `ErrorType.UNSAFE_SQL` + +后续归属:V2 SQL import PR。 + +### 5.3 Table import 映射能力 + +删除: + +- `hugegraph_mcp/tools/import_table.py` +- `tests/test_import_table.py` + +保留: + +- `import_graph_data_tool(mode="table")` 在 V1 中继续返回 `FEATURE_DISABLED`。 +- 相关 disabled 行为测试应保留或迁移到 `tests/test_import_graph_data_tool.py`。 + +后续归属:V2 table import PR。 + +### 5.4 Graph data update 能力 + +删除或收敛: + +- `manage_graph_data(mode="update")` +- `update_vertex` +- `update_edge` +- `_update_vertex_query` +- `_update_edge_query` +- update 相关 validation、dry-run、execute 分支 +- `tests/test_manage_graph_data.py` 中 update 相关用例 + +保留: + +- `manage_graph_data(mode="import")` +- `manage_graph_data(mode="delete")` +- `create_vertex` +- `create_edge` +- `delete_vertex` +- `delete_edge` +- dry-run / plan_hash / confirm / readonly 保护 + +后续归属:V2 graph data update PR。 + +### 5.5 Schema apply 执行能力 + +删除或收敛: + +- `manage_schema(mode="apply")` 的执行分支 +- `schema_tools.execute_schema_operations` +- schema apply 的 confirm / plan_hash / execute 测试 +- `tests/test_execute_schema_operations.py` +- `tests/test_manage_schema.py` 中 apply 执行相关用例 + +保留: + +- `apply_schema_tool(mode="apply")` 直接返回 `FEATURE_DISABLED` +- `manage_schema(mode="design")` +- `manage_schema(mode="validate")` +- `manage_schema(mode="dry_run")` +- create-only schema validation 和 dry-run plan hash + +后续归属:V2 schema apply PR。 + +## 6. 文档范围 + +V1 README 只描述: + +- V1 稳定工具列表。 +- admin/debug gated 工具默认禁用。 +- readonly 默认安全。 +- AI 调用开关。 +- graph_data ingest/delete 的 dry-run -> plan_hash -> confirm 链。 +- schema validate/dry_run,明确 apply 禁用。 +- table import / SQL / GraphRAG / update / schema apply 不属于 V1。 + +V1 README 不描述: + +- GraphRAG 配置项。 +- SQL/SQLite 配置项。 +- table import 映射流程。 +- graph data update 流程。 +- schema apply 执行流程。 + +## 7. 测试范围 + +V1 必须保留或补齐以下测试: + +- MCP tool 注册集合。 +- 旧工具不暴露: + - `query_graph_tool` + - `manage_schema_tool` + - `manage_graph_data_tool` +- readonly 默认值和 readonly 守卫。 +- admin/debug 工具默认 `FEATURE_DISABLED`。 +- `apply_schema_tool(mode="apply")` 返回 `FEATURE_DISABLED`。 +- `import_graph_data_tool(mode="table")` 返回 `FEATURE_DISABLED`。 +- `generate_gremlin_tool` 只执行只读 Gremlin。 +- `execute_gremlin_read_tool` 拒绝写 Gremlin。 +- `extract_graph_data_tool` 不写入。 +- `import_graph_data_tool(mode="ingest")` dry-run / confirm / plan_hash。 +- `delete_graph_data_tool` 精确删除、安全预览、confirm 校验。 +- envelope 结构。 + +删除以下 V2/未来测试: + +- `tests/test_query_graph.py` +- `tests/test_sql_table.py` +- `tests/test_import_table.py` +- `tests/test_execute_schema_operations.py` +- update-only 的 `tests/test_manage_graph_data.py` 用例 +- schema apply execute-only 的 `tests/test_manage_schema.py` 用例 +- GraphRAG/SQL config-only 的 `tests/test_config.py` 用例 + +## 8. 验收标准 + +- V1 分支中不存在 GraphRAG 问答源码和测试。 +- V1 分支中不存在 SQL/SQLite 源码和测试。 +- V1 分支中不存在 table import 映射源码和测试。 +- V1 graph data 代码不再支持 `update_vertex` / `update_edge`。 +- V1 schema 代码不再执行 schema apply。 +- `import_graph_data_tool(mode="table")` 仍返回 `FEATURE_DISABLED`。 +- `apply_schema_tool(mode="apply")` 仍返回 `FEATURE_DISABLED`。 +- MCP tool list 与 V1 契约一致。 +- README/中文 README 没有把 V2 能力作为 V1 配置或用户流程描述。 +- `hugegraph-mcp` 全量测试通过。 + +## 9. 风险 + +- 删除 update 分支可能影响 delete/import 共享的 validation 和 Gremlin 生成代码,需要逐项收敛而不是整文件删除。 +- 删除 schema apply 时要保留 schema validate/dry_run 的 plan hash 逻辑。 +- 删除 SQL/table import 后,`import_graph_data_tool(mode="table")` 仍需要保留 disabled stub,避免 API 行为改变为 unknown mode。 +- 删除 config 字段后,需要同步更新 README 和 config 测试。 + +## 10. 回滚策略 + +- 已创建备份分支:`codex/backup-mcp-v1-20260528-7d93158`。 +- 如删除范围过大或影响 V1 测试,可从备份分支恢复对应文件。 +- V2 功能后续从备份分支或当前历史中 cherry-pick 到独立 V2 PR。 + +## 11. 执行计划 + +### 阶段 1:冻结当前状态 + +- 确认备份分支存在并指向当前 MCP-V1 基线。 +- 保存本 PRD。 +- 确认当前 V1 测试基线。 + +### 阶段 2:删除独立 V2 模块 + +- 删除 `query_graph.py` 和 `tests/test_query_graph.py`。 +- 删除 `sql_modes.py`、`sql_table.py` 和 `tests/test_sql_table.py`。 +- 删除 `import_table.py` 和 `tests/test_import_table.py`。 +- 运行 import 检查,确认没有残留引用。 + +### 阶段 3:收敛配置和文档 + +- 从 `MCPConfig` 删除 GraphRAG/SQL/table-only 配置字段。 +- 从 README/README.zh-CN 删除 GraphRAG/SQL 预留配置。 +- 更新 `tests/test_config.py`,只保留 V1 配置项。 + +### 阶段 4:收敛 graph data update + +- 从 validation allowlist 删除 `update_vertex` / `update_edge`。 +- 删除 update Gremlin 生成函数和 `_write_query` update 分支。 +- 将 `manage_graph_data` 支持模式收敛为 `import` / `delete`。 +- 删除 update-only 测试,保留 import/delete/dry-run/confirm/plan_hash 测试。 + +### 阶段 5:收敛 schema apply 执行 + +- 删除 `manage_schema(mode="apply")` 执行分支。 +- 删除 `schema_tools.execute_schema_operations` 或降级为非 V1 私有不可达代码后再删除。 +- 删除 schema apply execute 相关测试。 +- 保留 `apply_schema_tool(mode="apply")` 的 `FEATURE_DISABLED` 测试。 + +### 阶段 6:修复引用和测试 + +- 运行全仓 `git grep`,确认 V2 关键词只出现在 PRD 或 disabled 行为测试中。 +- 更新 V1 tool contract 测试。 +- 更新 README 断言或文档相关测试。 + +### 阶段 7:验证 + +- 运行 V1 关键测试: + - `uv run pytest tests/test_v1_stable_tools.py tests/test_readonly_mode.py tests/test_import_graph_data_tool.py` +- 运行 MCP 全量测试: + - `uv run pytest` +- 运行静态检查,如当前项目已有命令: + - `uv run ruff check .` + - `uv run ruff format --check .` + +### 阶段 8:PR 准备 + +- PR 标题聚焦 V1:`feat(mcp): add V1 stable tool surface` +- PR 描述列出删除的 V2 能力及后续 PR 计划。 +- PR 中不包含 V2 用户功能描述。 +- V2 后续 PR 从备份分支或历史中恢复对应模块。 diff --git a/hugegraph-mcp/tests/integration/test_real_write_path.py b/hugegraph-mcp/tests/integration/test_real_write_path.py new file mode 100644 index 000000000..a8ef15605 --- /dev/null +++ b/hugegraph-mcp/tests/integration/test_real_write_path.py @@ -0,0 +1,582 @@ +# 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. + +"""Layer B integration tests for the real HugeGraph write path.""" + +from __future__ import annotations + +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 + + +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" + ) + + 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_USER", _env("HUGEGRAPH_USER", "admin")) + monkeypatch.setenv("HUGEGRAPH_PASSWORD", _env("HUGEGRAPH_PASSWORD", "admin")) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + monkeypatch.setenv("HUGEGRAPH_MCP_ALLOW_AI", "false") + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "false") + + client = build_hugegraph_client(MCPConfig.from_env(), client_cls=PyHugeClient) + try: + client.schema().getSchema() + except Exception as exc: # pragma: no cover - depends on external service + pytest.fail(f"HugeGraph Server is not available: {exc}") + return client + + +def test_id_based_ingest_writes_the_intended_edge(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')", + ) + + graph_data = { + "vertices": [ + { + "id": "alice", + "label": names.vertex_label, + "properties": {names.name_key: "Alice"}, + }, + { + "id": "bob", + "label": names.vertex_label, + "properties": {names.name_key: "Bob"}, + }, + ], + "edges": [ + { + "label": names.edge_label, + "source_label": names.vertex_label, + "target_label": names.vertex_label, + "source": {"id": "alice"}, + "target": {"id": "bob"}, + } + ], + } + + result = _import_graph_data(graph_data) + + assert result["ok"] is True + assert ( + _count( + hugegraph_client, + f"g.V().hasId('alice').out({_g(names.edge_label)}).hasId('bob')", + ) + == 1 + ) + assert ( + _count( + hugegraph_client, + f"g.V().hasId('decoy').out({_g(names.edge_label)})", + ) + == 0 + ) + + +def test_create_edge_rejects_missing_endpoint(hugegraph_client): + names = _schema_names("missing") + _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')", + ) + change_plan = { + "operations": [ + { + "op": "create_edge", + "label": names.edge_label, + "source_label": names.vertex_label, + "source_match": {"id": "alice"}, + "target_label": names.vertex_label, + "target_match": {"id": "bob"}, + } + ] + } + + result = manage_graph_data_module.dry_run_graph_change_plan( + change_plan, + manage_graph_data_module._fetch_live_schema(), + ) + + assert result["valid"] is False + assert any( + "target endpoint matched_count must be 1" in error["reason"] + for error in result["errors"] + ) + assert ( + _count( + hugegraph_client, + f"g.V().hasId('alice').out({_g(names.edge_label)}).hasId('bob')", + ) + == 0 + ) + + +def test_create_edge_rejects_non_unique_property_match(hugegraph_client): + names = _schema_names("nonunique") + _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')", + ) + _exec( + hugegraph_client, + f"g.addV({_g(names.vertex_label)}).property(T.id,'alice_2')" + f".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')", + ) + 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": {"id": "bob"}, + } + ] + } + + result = manage_graph_data_module.dry_run_graph_change_plan( + change_plan, + manage_graph_data_module._fetch_live_schema(), + ) + + assert result["valid"] is False + assert any( + "source endpoint matched_count must be 1" in error["reason"] + for error in result["errors"] + ) + assert ( + _count( + hugegraph_client, + f"g.V().has({_g(names.name_key)},'Alice').out({_g(names.edge_label)})", + ) + == 0 + ) + + +def test_create_vertex_existing_id_rejected_in_dry_run_without_writes( + hugegraph_client, +): + names = _schema_names("id_conflict") + _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')", + ) + graph_data = { + "vertices": [ + { + "id": "alice", + "label": names.vertex_label, + "properties": {names.name_key: "Alice"}, + }, + { + "id": "conflict", + "label": names.vertex_label, + "properties": {names.name_key: "Duplicate"}, + }, + ], + "edges": [], + } + + dry_run = server.import_graph_data_tool(mode="ingest", graph_data=graph_data) + + 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"] + ) + assert ( + _count( + hugegraph_client, + f"g.V().hasLabel({_g(names.vertex_label)}).hasId('alice')", + ) + == 0 + ) + assert ( + _count( + hugegraph_client, + f"g.V().hasLabel({_g(names.vertex_label)}).hasId('conflict')", + ) + == 1 + ) + + +def test_create_vertex_existing_primary_key_rejected_in_dry_run_without_writes( + hugegraph_client, +): + names = _schema_names("pk_conflict") + _ensure_primary_key_schema(hugegraph_client, names) + _exec( + hugegraph_client, + f"g.addV({_g(names.vertex_label)}).property({_g(names.name_key)},'Conflict')", + ) + graph_data = { + "vertices": [ + { + "label": names.vertex_label, + "properties": {names.name_key: "Alice"}, + }, + { + "label": names.vertex_label, + "properties": {names.name_key: "Conflict"}, + }, + ], + "edges": [], + } + + dry_run = server.import_graph_data_tool(mode="ingest", graph_data=graph_data) + + assert dry_run["ok"] is False + assert dry_run["error"]["type"] == "INVALID_GRAPH_DATA" + assert any( + "create_vertex primary_key identity already exists" in error["reason"] + for error in dry_run["error"]["details"]["errors"] + ) + assert ( + _count( + hugegraph_client, + f"g.V().hasLabel({_g(names.vertex_label)})" + f".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')", + ) + == 1 + ) + + +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) + + 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 + + edge_delete_plan = { + "operations": [ + { + "op": "delete_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"}, + } + ] + } + result = _delete_graph_data(edge_delete_plan) + + assert result["ok"] is True + 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 + + vertex_delete_plan = { + "operations": [ + { + "op": "delete_vertex", + "label": names.vertex_label, + "match": {names.name_key: "Alice"}, + }, + { + "op": "delete_vertex", + "label": names.vertex_label, + "match": {names.name_key: "Bob"}, + }, + ] + } + 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 + + +def test_partial_write_returns_error_envelope_and_real_graph_state_matches( + hugegraph_client, +): + names = _schema_names("partial") + _ensure_custom_id_schema(hugegraph_client, names, unique_name=True) + graph_data = { + "vertices": [ + { + "label": names.vertex_label, + "id": "alice", + "properties": {names.name_key: "Duplicate"}, + }, + { + "label": names.vertex_label, + "id": "conflict", + "properties": {names.name_key: "Duplicate"}, + }, + ], + "edges": [], + } + dry_run = server.import_graph_data_tool(mode="ingest", graph_data=graph_data) + assert dry_run["ok"] is True + + plan_context = dry_run["data"]["plan_context"] + + result = server.import_graph_data_tool( + mode="ingest", + graph_data=graph_data, + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=plan_context["nonce"], + expires_at=plan_context["expires_at"], + ) + + assert result["ok"] is False + assert result["error"]["details"]["status"] == "partial" + assert ( + _count( + hugegraph_client, + f"g.V().hasLabel({_g(names.vertex_label)}).hasId('alice')", + ) + == 1 + ) + assert ( + _count( + hugegraph_client, + f"g.V().hasLabel({_g(names.vertex_label)})" + f".hasId('conflict').has({_g(names.name_key)},'Duplicate')", + ) + == 0 + ) + + +def test_public_ingest_readonly_gate_prevents_real_write(hugegraph_client, monkeypatch): + names = _schema_names("readonly") + _ensure_custom_id_schema(hugegraph_client, names) + graph_data = { + "vertices": [ + { + "id": "readonly_alice", + "label": names.vertex_label, + "properties": {names.name_key: "Alice"}, + } + ], + "edges": [], + } + dry_run = server.import_graph_data_tool(mode="ingest", graph_data=graph_data) + assert dry_run["ok"] is True + plan_context = dry_run["data"]["plan_context"] + + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + result = server.import_graph_data_tool( + mode="ingest", + graph_data=graph_data, + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=plan_context["nonce"], + expires_at=plan_context["expires_at"], + ) + + assert result["ok"] is False + assert result["error"]["type"] == "READONLY_VIOLATION" + assert ( + _count( + hugegraph_client, + f"g.V().hasLabel({_g(names.vertex_label)}).hasId('readonly_alice')", + ) + == 0 + ) + + +def test_admin_write_tool_gate_prevents_real_write(hugegraph_client, monkeypatch): + names = _schema_names("admin") + _ensure_custom_id_schema(hugegraph_client, names) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "false") + + 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')" + ) + ) + + assert result["ok"] is False + assert result["error"]["type"] == "FEATURE_DISABLED" + assert ( + _count( + hugegraph_client, + f"g.V().hasLabel({_g(names.vertex_label)}).hasId('admin_blocked')", + ) + == 0 + ) + + +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 + plan_context = dry_run["data"]["plan_context"] + return server.import_graph_data_tool( + mode="ingest", + graph_data=graph_data, + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=plan_context["nonce"], + expires_at=plan_context["expires_at"], + ) + + +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 + plan_context = dry_run["data"]["plan_context"] + return server.delete_graph_data_tool( + change_plan=change_plan, + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=plan_context["nonce"], + expires_at=plan_context["expires_at"], + ) + + +class _Names: + def __init__(self, prefix: str) -> None: + suffix = uuid4().hex[:8] + self.name_key = f"{prefix}_name_{suffix}" + self.vertex_label = f"{prefix}_v_{suffix}" + self.edge_label = f"{prefix}_e_{suffix}" + self.name_index = f"{prefix}_name_idx_{suffix}" + + +def _schema_names(prefix: str) -> _Names: + return _Names(prefix) + + +def _ensure_custom_id_schema( + client, + names: _Names, + *, + unique_name: bool = False, +) -> None: + schema = client.schema() + schema.propertyKey(names.name_key).asText().ifNotExist().create() + schema.vertexLabel(names.vertex_label).properties( + names.name_key + ).useCustomizeStringId().nullableKeys(names.name_key).ifNotExist().create() + schema.edgeLabel(names.edge_label).sourceLabel(names.vertex_label).targetLabel( + names.vertex_label + ).ifNotExist().create() + index = ( + schema.indexLabel(names.name_index).onV(names.vertex_label).by(names.name_key) + ) + if unique_name: + index.unique().ifNotExist().create() + else: + index.secondary().ifNotExist().create() + + +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( + names.name_key + ).ifNotExist().create() + schema.edgeLabel(names.edge_label).sourceLabel(names.vertex_label).targetLabel( + names.vertex_label + ).ifNotExist().create() + + +def _exec(client, query: str): + return client.gremlin().exec(query) + + +def _count(client, query: str) -> int: + return int(_extract_count(_exec(client, f"{query}.count()")) or 0) + + +def _extract_count(data): + if isinstance(data, dict) and "data" in data: + return _extract_count(data["data"]) + if isinstance(data, list): + return _extract_count(data[0]) if data else 0 + return data + + +def _env(name: str, default: str | None = None) -> str | None: + import os + + return os.environ.get(name, default) diff --git a/hugegraph-mcp/tests/test_config.py b/hugegraph-mcp/tests/test_config.py new file mode 100644 index 000000000..5378e8f2a --- /dev/null +++ b/hugegraph-mcp/tests/test_config.py @@ -0,0 +1,229 @@ +# 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. + +import logging + +from hugegraph_mcp.config import MCPConfig, config + + +CONFIG_ENV_VARS = ( + "HUGEGRAPH_URL", + "HUGEGRAPH_GRAPH_PATH", + "HUGEGRAPH_GRAPH", + "HUGEGRAPH_GRAPHSPACE", + "HUGEGRAPH_USER", + "HUGEGRAPH_PASSWORD", + "HUGEGRAPH_MCP_READONLY", + "HUGEGRAPH_AI_URL", + "HUGEGRAPH_AI_GRAPH_URL", + "HUGEGRAPH_MCP_ALLOW_AI", + "HUGEGRAPH_MCP_ADMIN_MODE", + "HUGEGRAPH_MCP_TIMEOUT_SECONDS", +) + + +def clear_config_env(monkeypatch): + for name in CONFIG_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +def test_basic_config_parsing(monkeypatch): + clear_config_env(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_URL", "http://graph.example:18080") + monkeypatch.setenv("HUGEGRAPH_GRAPHSPACE", "space_a") + monkeypatch.setenv("HUGEGRAPH_GRAPH", "graph_a") + monkeypatch.setenv("HUGEGRAPH_USER", "alice") + monkeypatch.setenv("HUGEGRAPH_PASSWORD", "secret") + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + monkeypatch.setenv("HUGEGRAPH_AI_URL", "http://ai.example:18001") + monkeypatch.setenv("HUGEGRAPH_AI_GRAPH_URL", "http://graph-internal:8080") + monkeypatch.setenv("HUGEGRAPH_MCP_ALLOW_AI", "yes") + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "true") + monkeypatch.setenv("HUGEGRAPH_MCP_TIMEOUT_SECONDS", "45") + + cfg = MCPConfig.from_env() + + assert cfg.url == "http://graph.example:18080" + assert cfg.graphspace == "space_a" + assert cfg.graph == "graph_a" + assert cfg.user == "alice" + assert cfg.password == "secret" + assert cfg.is_readonly() is True + assert cfg.ai_url == "http://ai.example:18001" + assert cfg.ai_graph_url == "http://graph-internal:8080" + assert cfg.allow_ai is True + assert cfg.admin_mode is True + assert cfg.timeout_seconds == 45 + + +def test_graph_path_parsing(monkeypatch): + clear_config_env(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_GRAPH_PATH", "myspace/mygraph") + + cfg = MCPConfig.from_env() + + assert cfg.graphspace == "myspace" + assert cfg.graph == "mygraph" + + +def test_split_graph_variables_take_priority(monkeypatch): + clear_config_env(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_GRAPH_PATH", "path_space/path_graph") + monkeypatch.setenv("HUGEGRAPH_GRAPHSPACE", "split_space") + monkeypatch.setenv("HUGEGRAPH_GRAPH", "split_graph") + + cfg = MCPConfig.from_env() + + assert cfg.graphspace == "split_space" + assert cfg.graph == "split_graph" + assert cfg.warnings == ( + "HUGEGRAPH_GRAPHSPACE/HUGEGRAPH_GRAPH override HUGEGRAPH_GRAPH_PATH", + ) + + +def test_warnings_are_logged(monkeypatch, caplog): + clear_config_env(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_GRAPH_PATH", "path_space/path_graph") + monkeypatch.setenv("HUGEGRAPH_GRAPHSPACE", "split_space") + + with caplog.at_level(logging.WARNING, logger="hugegraph_mcp.config"): + cfg = MCPConfig.from_env() + + assert cfg.warnings == ( + "HUGEGRAPH_GRAPHSPACE/HUGEGRAPH_GRAPH override HUGEGRAPH_GRAPH_PATH", + ) + assert ( + "HUGEGRAPH_GRAPHSPACE/HUGEGRAPH_GRAPH override HUGEGRAPH_GRAPH_PATH" + in caplog.messages + ) + + +def test_duplicate_config_warnings_are_not_logged_for_unchanged_env( + monkeypatch, caplog +): + clear_config_env(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_GRAPH_PATH", "cache_space/cache_graph") + monkeypatch.setenv("HUGEGRAPH_GRAPHSPACE", "cache_override") + + with caplog.at_level(logging.WARNING, logger="hugegraph_mcp.config"): + first = MCPConfig.from_env() + second = MCPConfig.from_env() + + assert first is second + assert ( + caplog.messages.count( + "HUGEGRAPH_GRAPHSPACE/HUGEGRAPH_GRAPH override HUGEGRAPH_GRAPH_PATH" + ) + == 1 + ) + + +def test_invalid_integer_config_falls_back_to_default(monkeypatch, caplog): + clear_config_env(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_TIMEOUT_SECONDS", "not-a-number") + + with caplog.at_level(logging.WARNING, logger="hugegraph_mcp.config"): + cfg = MCPConfig.from_env() + + assert cfg.timeout_seconds == 30 + assert ( + "Invalid integer config value 'not-a-number'; using default 30" + in caplog.messages + ) + + +def test_non_positive_integer_config_falls_back_to_default(monkeypatch, caplog): + for value in ("0", "-1"): + clear_config_env(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_TIMEOUT_SECONDS", value) + caplog.clear() + + with caplog.at_level(logging.WARNING, logger="hugegraph_mcp.config"): + cfg = MCPConfig.from_env() + + assert cfg.timeout_seconds == 30 + assert ( + f"Invalid integer config value '{value}'; using default 30" + in caplog.messages + ) + + +def test_readonly_and_allow_ai_are_controlled_independently(monkeypatch): + clear_config_env(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + monkeypatch.setenv("HUGEGRAPH_MCP_ALLOW_AI", "true") + + cfg = MCPConfig.from_env() + + assert cfg.is_readonly() is True + assert cfg.allow_ai is True + + +def test_admin_mode_reads_current_env(monkeypatch): + clear_config_env(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "false") + assert MCPConfig.from_env().admin_mode is False + + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "true") + assert MCPConfig.from_env().admin_mode is True + + +def test_readonly_parsing(monkeypatch): + true_values = ("true", "1", "yes", "TRUE") + false_values = ("false", "0", "no", "") + + for value in true_values: + clear_config_env(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", value) + assert MCPConfig.from_env().is_readonly() is True + + for value in false_values: + clear_config_env(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", value) + assert MCPConfig.from_env().is_readonly() is False + + +def test_default_password_is_empty_but_explicit_xxx_is_accepted(monkeypatch): + clear_config_env(monkeypatch) + assert MCPConfig.from_env().password == "" + + monkeypatch.setenv("HUGEGRAPH_PASSWORD", "xxx") + assert MCPConfig.from_env().password == "xxx" + + +def test_default_values(monkeypatch): + clear_config_env(monkeypatch) + + cfg = MCPConfig.from_env() + + assert cfg.url == "http://127.0.0.1:8080" + assert cfg.graphspace == "DEFAULT" + assert cfg.graph == "hugegraph" + assert cfg.user == "admin" + assert cfg.password == "" + assert cfg.is_readonly() is True + assert cfg.ai_url == "http://127.0.0.1:8001" + assert cfg.ai_graph_url is None + assert cfg.allow_ai is False + assert cfg.admin_mode is False + assert cfg.timeout_seconds == 30 + assert cfg.warnings == () + + +def test_config_proxy_reads_current_env(monkeypatch): + clear_config_env(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_GRAPH", "first_graph") + assert config.graph == "first_graph" + + monkeypatch.setenv("HUGEGRAPH_GRAPH", "second_graph") + assert config.graph == "second_graph" diff --git a/hugegraph-mcp/tests/test_envelope.py b/hugegraph-mcp/tests/test_envelope.py new file mode 100644 index 000000000..d0871d735 --- /dev/null +++ b/hugegraph-mcp/tests/test_envelope.py @@ -0,0 +1,123 @@ +# 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. + +from hugegraph_mcp.config import config +from hugegraph_mcp.envelope import ( + ErrorType, + envelope_err, + envelope_ok, + generate_request_id, +) + + +def test_envelope_ok_basic(): + result = envelope_ok({"items": [1, 2]}, duration_ms=12.5) + + assert result["ok"] is True + assert result["data"] == {"items": [1, 2]} + assert result["error"] is None + assert result["warnings"] == [] + assert result["meta"]["request_id"].startswith("req-") + assert len(result["meta"]["request_id"]) == len("req-") + 12 + + +def test_envelope_ok_with_warnings(): + result = envelope_ok( + {"created": 1}, + warnings=["schema cache is stale"], + meta={"operation": "schema_create"}, + ) + + assert result["ok"] is True + assert result["warnings"] == ["schema cache is stale"] + assert result["meta"]["operation"] == "schema_create" + + +def test_envelope_err_basic(): + result = envelope_err( + ErrorType.CONNECTION_FAILED, + "Cannot connect to HugeGraph server", + suggestion="Check if HugeGraph Server is running", + retryable=True, + details={"url": "http://127.0.0.1:8080"}, + ) + + assert result["ok"] is False + assert result["data"] is None + assert result["error"]["type"] == "CONNECTION_FAILED" + assert result["error"]["message"] == "Cannot connect to HugeGraph server" + assert result["error"]["suggestion"] == "Check if HugeGraph Server is running" + assert result["error"]["retryable"] is True + assert result["error"]["source"] == "hugegraph-mcp" + assert result["error"]["details"] == {"url": "http://127.0.0.1:8080"} + assert result["warnings"] == [] + + +def test_envelope_err_defaults(): + """suggestion defaults to None, retryable to False, source to hugegraph-mcp, details to {}.""" + result = envelope_err(ErrorType.TIMEOUT, "Request timed out") + + assert result["error"]["suggestion"] is None + assert result["error"]["retryable"] is False + assert result["error"]["source"] == "hugegraph-mcp" + assert result["error"]["details"] == {} + + +def test_envelope_err_all_error_types(): + assert len(ErrorType) == 21 + assert ErrorType.VALIDATION_ERROR.value == "VALIDATION_ERROR" + assert ErrorType.PLAN_EXPIRED.value == "PLAN_EXPIRED" + assert ErrorType.NOT_FOUND.value == "NOT_FOUND" + assert ErrorType.FEATURE_DISABLED.value == "FEATURE_DISABLED" + assert ErrorType.QUERY_SYNTAX_ERROR.value == "QUERY_SYNTAX_ERROR" + assert ErrorType.SERVER_ERROR.value == "SERVER_ERROR" + + for error_type in ErrorType: + result = envelope_err(error_type, f"{error_type.value} failed") + assert result["ok"] is False + assert result["error"]["type"] == error_type.value + assert result["error"]["message"] == f"{error_type.value} failed" + + +def test_request_id_unique(): + request_ids = {generate_request_id() for _ in range(100)} + + assert len(request_ids) == 100 + assert all(request_id.startswith("req-") for request_id in request_ids) + assert all(len(request_id) == len("req-") + 12 for request_id in request_ids) + + +def test_meta_includes_config_fields(): + result = envelope_ok("ok") + + assert result["meta"]["graph"] == config.graph + assert result["meta"]["graphspace"] == config.graphspace + assert result["meta"]["readonly"] == config.readonly + + +def test_meta_reads_current_env(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_GRAPH", "runtime_graph") + monkeypatch.setenv("HUGEGRAPH_GRAPHSPACE", "runtime_space") + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + + result = envelope_ok("ok") + + assert result["meta"]["graph"] == "runtime_graph" + assert result["meta"]["graphspace"] == "runtime_space" + assert result["meta"]["readonly"] is True + + +def test_duration_ms_passthrough(): + result = envelope_ok("ok", duration_ms=123) + + assert result["meta"]["duration_ms"] == 123 diff --git a/hugegraph-mcp/tests/test_error_handling.py b/hugegraph-mcp/tests/test_error_handling.py new file mode 100644 index 000000000..34b4a7ce6 --- /dev/null +++ b/hugegraph-mcp/tests/test_error_handling.py @@ -0,0 +1,267 @@ +# 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. + +"""Test improved error handling for Gremlin operations.""" + +from unittest.mock import Mock, patch + +import requests +from hugegraph_mcp.gremlin_tools import execute_gremlin_read, execute_gremlin_write + + +def test_connection_error_handling(): + """Test handling of connection errors.""" + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client_instance = Mock() + mock_client_instance.exec.side_effect = requests.exceptions.ConnectionError( + "Connection refused" + ) + mock_client.return_value = mock_client_instance + + result = execute_gremlin_read("g.V().count()") + + assert result["ok"] is False + assert result["error"]["type"] == "CONNECTION_FAILED" + assert "Cannot connect to HugeGraph server" in result["error"]["message"] + assert "Check if HugeGraph server is running" in result["error"]["suggestion"] + assert result["error"]["details"]["error_type"] == "connection_error" + assert result["error"]["retryable"] is True + + +def test_read_client_initialization_connection_error_is_enveloped(): + """Connection failures while constructing the client should not escape.""" + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client.side_effect = requests.exceptions.ConnectionError( + "Connection refused during init" + ) + + result = execute_gremlin_read("g.V().count()") + + assert result["ok"] is False + assert result["error"]["type"] == "CONNECTION_FAILED" + assert "Cannot connect to HugeGraph server" in result["error"]["message"] + assert result["error"]["details"]["error_type"] == "connection_error" + assert result["error"]["retryable"] is True + + +def test_write_client_initialization_connection_error_is_enveloped(monkeypatch): + """Write client construction failures should use the same envelope path.""" + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "true") + with patch("hugegraph_mcp.gremlin_tools._get_write_client") as mock_client: + mock_client.side_effect = requests.exceptions.ConnectionError( + "Connection refused during init" + ) + + result = execute_gremlin_write("g.addV('test')") + + assert result["ok"] is False + assert result["error"]["type"] == "CONNECTION_FAILED" + assert "Cannot connect to HugeGraph server" in result["error"]["message"] + assert result["error"]["details"]["error_type"] == "connection_error" + assert result["error"]["retryable"] is True + + +def test_http_500_error_handling(monkeypatch): + """Test handling of HTTP 500 server errors.""" + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "true") + with patch("hugegraph_mcp.gremlin_tools._get_write_client") as mock_client: + mock_client_instance = Mock() + + # Create a mock response with status code 500 + mock_response = Mock() + mock_response.status_code = 500 + error = requests.exceptions.HTTPError( + "Internal Server Error", response=mock_response + ) + mock_client_instance.exec.side_effect = error + mock_client.return_value = mock_client_instance + + result = execute_gremlin_write("g.addV('test')") + + assert result["ok"] is False + assert result["error"]["type"] == "SERVER_ERROR" + assert "HugeGraph server internal error" in result["error"]["message"] + assert result["error"]["details"]["error_type"] == "server_error" + assert result["error"]["details"]["status_code"] == 500 + assert "Check the Gremlin query syntax" in result["error"]["suggestion"] + assert result["error"]["retryable"] is True + + +def test_http_503_error_is_retryable(): + """Temporary 5xx HTTP failures should be retryable.""" + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client_instance = Mock() + + mock_response = Mock() + mock_response.status_code = 503 + error = requests.exceptions.HTTPError( + "Service Unavailable", response=mock_response + ) + mock_client_instance.exec.side_effect = error + mock_client.return_value = mock_client_instance + + result = execute_gremlin_read("g.V().limit(10)") + + assert result["ok"] is False + assert result["error"]["type"] == "SERVER_ERROR" + assert result["error"]["details"]["error_type"] == "http_error" + assert result["error"]["details"]["status_code"] == 503 + assert result["error"]["retryable"] is True + + +def test_http_404_error_handling(): + """Test handling of HTTP 404 graph or endpoint errors.""" + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client_instance = Mock() + + mock_response = Mock() + mock_response.status_code = 404 + error = requests.exceptions.HTTPError("Not Found", response=mock_response) + mock_client_instance.exec.side_effect = error + mock_client.return_value = mock_client_instance + + result = execute_gremlin_read("g.V().limit(10)") + + assert result["ok"] is False + assert result["error"]["type"] == "NOT_FOUND" + assert "Graph or endpoint not found" in result["error"]["message"] + assert result["error"]["details"]["error_type"] == "not_found_error" + assert result["error"]["details"]["status_code"] == 404 + assert result["error"]["retryable"] is False + + +def test_timeout_error_handling(): + """Test handling of request timeouts.""" + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client_instance = Mock() + mock_client_instance.exec.side_effect = requests.exceptions.Timeout( + "Read timed out" + ) + mock_client.return_value = mock_client_instance + + result = execute_gremlin_read("g.V().count()") + + assert result["ok"] is False + assert result["error"]["type"] == "TIMEOUT" + assert "timed out" in result["error"]["message"] + assert result["error"]["details"]["error_type"] == "timeout_error" + assert result["error"]["retryable"] is True + + +def test_authentication_error_handling(): + """Test handling of authentication errors (401).""" + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client_instance = Mock() + + # Create a mock response with status code 401 + mock_response = Mock() + mock_response.status_code = 401 + error = requests.exceptions.HTTPError("Unauthorized", response=mock_response) + mock_client_instance.exec.side_effect = error + mock_client.return_value = mock_client_instance + + result = execute_gremlin_read("g.V().limit(10)") + + assert result["ok"] is False + assert result["error"]["type"] == "AUTHENTICATION_FAILED" + assert "Authentication failed" in result["error"]["message"] + assert "Check HUGEGRAPH_USER" in result["error"]["suggestion"] + assert result["error"]["details"]["error_type"] == "authentication_error" + + +def test_readonly_mode_error(): + """Test readonly mode error handling.""" + with patch.dict("os.environ", {"HUGEGRAPH_MCP_READONLY": "true"}): + result = execute_gremlin_write("g.addV('test')") + + assert result["ok"] is False + assert result["error"]["type"] == "READONLY_VIOLATION" + assert result["meta"]["readonly"] is True + + +def test_validation_error_for_read_operations(): + """Test validation error when trying to use write keywords in read operations.""" + result = execute_gremlin_read("g.addV('test')") + + assert result["ok"] is False + assert result["error"]["type"] == "UNSAFE_GREMLIN" + assert "write" in result["error"]["message"].lower() + + +def test_syntax_error_handling(): + """Test handling of Gremlin syntax errors.""" + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client_instance = Mock() + mock_client_instance.exec.side_effect = ValueError("Invalid Gremlin syntax") + mock_client.return_value = mock_client_instance + + result = execute_gremlin_read("g.V().count()") + + assert result["ok"] is False + assert result["error"]["type"] == "QUERY_SYNTAX_ERROR" + assert "syntax error" in result["error"]["message"] + assert result["error"]["details"]["error_type"] == "query_syntax_error" + assert result["error"]["retryable"] is False + + +def test_unknown_error_handling(): + """Test handling of unexpected errors.""" + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client_instance = Mock() + mock_client_instance.exec.side_effect = RuntimeError("Unexpected error") + mock_client.return_value = mock_client_instance + + result = execute_gremlin_read("g.V().count()") + + assert result["ok"] is False + assert result["error"]["type"] == "SERVER_ERROR" + assert "Unexpected error" in result["error"]["message"] + assert result["error"]["details"]["error_type"] == "unknown_error" + + +def test_no_index_exception_is_classified_as_no_index(): + """HugeGraph NoIndexException should not be reported as a connection failure.""" + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client_instance = Mock() + mock_client_instance.exec.side_effect = RuntimeError( + "Gremlin can't get results: Server Exception: " + "org.apache.hugegraph.exception.NoIndexException" + ) + mock_client.return_value = mock_client_instance + + result = execute_gremlin_read("g.V().has('occupation','engineer')") + + assert result["ok"] is False + assert result["error"]["type"] == "NO_INDEX" + assert result["error"]["details"]["error_type"] == "no_index_error" + assert "Create an index" in result["error"]["suggestion"] + assert result["error"]["retryable"] is False + + +def test_successful_execution_preserves_format(): + """Test that successful execution maintains the expected format.""" + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client_instance = Mock() + mock_client_instance.exec.return_value = [{"id": "1", "label": "person"}] + mock_client.return_value = mock_client_instance + + result = execute_gremlin_read("g.V().limit(1)") + + assert result["ok"] is True + assert result["error"] is None + assert result["data"]["is_read"] is True + assert result["data"]["total"] == 1 + assert result["data"]["data"] == [{"id": "1", "label": "person"}] + assert isinstance(result["meta"]["duration_ms"], (int, float)) diff --git a/hugegraph-mcp/tests/test_execute_gremlin_read.py b/hugegraph-mcp/tests/test_execute_gremlin_read.py new file mode 100644 index 000000000..176f44e39 --- /dev/null +++ b/hugegraph-mcp/tests/test_execute_gremlin_read.py @@ -0,0 +1,212 @@ +# 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. + + +class FakeGremlinClient: + def __init__(self): + self.last_query = None + + def exec(self, query: str): # minimal interface for tests + self.last_query = query + # return a fake list result + return ["alice", "bob"] + + +class FakeHugeGraphShapeClient: + def __init__(self, data): + self.data = data + self.last_query = None + + def exec(self, query: str): + self.last_query = query + return self.data + + +class FakePyHugeClient: + init_kwargs: list[dict] = [] + + def __init__(self, url: str, graph: str, user: str, pwd: str, graphspace=None): + self.init_kwargs.append( + { + "url": url, + "graph": graph, + "user": user, + "pwd": pwd, + "graphspace": graphspace, + } + ) + + def gremlin(self): + return FakeGremlinClient() + + +def test_execute_gremlin_read_basic(monkeypatch): + """Basic happy path: query is executed with read client and returns data/total/duration/is_read.""" + + from hugegraph_mcp import gremlin_tools # to be implemented + + client = FakeGremlinClient() + monkeypatch.setattr( + gremlin_tools, "_get_read_client", lambda: client, raising=False + ) + + result = gremlin_tools.execute_gremlin_read("g.V().limit(2)") + + assert client.last_query == "g.V().limit(2)" + assert result["ok"] is True + assert result["error"] is None + assert result["data"]["is_read"] is True + assert result["data"]["total"] == 2 + assert isinstance(result["data"]["duration_ms"], (int, float)) + assert result["meta"]["duration_ms"] == result["data"]["duration_ms"] + + +def test_execute_gremlin_read_counts_hugegraph_data_shape(monkeypatch): + from hugegraph_mcp import gremlin_tools + + client = FakeHugeGraphShapeClient({"data": [{"id": "1:Alice"}], "meta": {}}) + monkeypatch.setattr( + gremlin_tools, "_get_read_client", lambda: client, raising=False + ) + + result = gremlin_tools.execute_gremlin_read("g.V().limit(1).elementMap()") + + assert result["ok"] is True + assert result["data"]["total"] == 1 + assert result["data"]["data"] == {"data": [{"id": "1:Alice"}], "meta": {}} + + +def test_execute_gremlin_read_counts_empty_hugegraph_data_shape(monkeypatch): + from hugegraph_mcp import gremlin_tools + + client = FakeHugeGraphShapeClient({"data": [], "meta": {}}) + monkeypatch.setattr( + gremlin_tools, "_get_read_client", lambda: client, raising=False + ) + + result = gremlin_tools.execute_gremlin_read("g.V().has('name','missing')") + + assert result["ok"] is True + assert result["data"]["total"] == 0 + + +def test_execute_gremlin_read_includes_unbounded_cost_warning(monkeypatch): + from hugegraph_mcp import gremlin_tools + + client = FakeGremlinClient() + monkeypatch.setattr( + gremlin_tools, "_get_read_client", lambda: client, raising=False + ) + + result = gremlin_tools.execute_gremlin_read("g.V()") + + assert result["ok"] is True + assert result["warnings"] + + +def test_execute_gremlin_read_has_no_cost_warning_for_limited_query(monkeypatch): + from hugegraph_mcp import gremlin_tools + + client = FakeGremlinClient() + monkeypatch.setattr( + gremlin_tools, "_get_read_client", lambda: client, raising=False + ) + + result = gremlin_tools.execute_gremlin_read("g.V().limit(2)") + + assert result["ok"] is True + assert result["warnings"] == [] + + +def test_execute_gremlin_read_counts_single_string_as_one(monkeypatch): + from hugegraph_mcp import gremlin_tools + + client = FakeHugeGraphShapeClient({"data": "Alice", "meta": {}}) + monkeypatch.setattr( + gremlin_tools, "_get_read_client", lambda: client, raising=False + ) + + result = gremlin_tools.execute_gremlin_read("g.V().values('name').limit(1)") + + assert result["ok"] is True + assert result["data"]["total"] == 1 + + +def test_execute_gremlin_read_counts_single_map_as_one(monkeypatch): + from hugegraph_mcp import gremlin_tools + + client = FakeHugeGraphShapeClient({"data": {"id": "1", "label": "person"}}) + monkeypatch.setattr( + gremlin_tools, "_get_read_client", lambda: client, raising=False + ) + + result = gremlin_tools.execute_gremlin_read("g.V().limit(1).elementMap()") + + assert result["ok"] is True + assert result["data"]["total"] == 1 + + +def test_execute_gremlin_read_rejects_obvious_writes(monkeypatch): + """Queries with clear write keywords must be rejected even through read tool.""" + + from hugegraph_mcp import gremlin_tools + + client = FakeGremlinClient() + monkeypatch.setattr( + gremlin_tools, "_get_read_client", lambda: client, raising=False + ) + + result = gremlin_tools.execute_gremlin_read("g.addV('person')") + + assert result["ok"] is False + assert result["error"]["type"] == "UNSAFE_GREMLIN" + assert result["meta"] + + +def test_execute_gremlin_read_respects_readonly_env(monkeypatch): + """When HUGEGRAPH_MCP_READONLY is true, execute_gremlin_read should still work (it's read).""" + + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + + from hugegraph_mcp import gremlin_tools + + client = FakeGremlinClient() + monkeypatch.setattr( + gremlin_tools, "_get_read_client", lambda: client, raising=False + ) + + result = gremlin_tools.execute_gremlin_read("g.V().count()") + + assert result["ok"] is True + assert result["data"]["is_read"] is True + assert "total" in result["data"] + + +def test_get_read_client_uses_current_env(monkeypatch): + from hugegraph_mcp import gremlin_tools + + FakePyHugeClient.init_kwargs = [] + monkeypatch.setattr(gremlin_tools, "PyHugeClient", FakePyHugeClient) + + monkeypatch.setenv("HUGEGRAPH_GRAPH", "first_graph") + monkeypatch.setenv("HUGEGRAPH_GRAPHSPACE", "first_space") + gremlin_tools._get_read_client() + + monkeypatch.setenv("HUGEGRAPH_GRAPH", "second_graph") + monkeypatch.setenv("HUGEGRAPH_GRAPHSPACE", "second_space") + gremlin_tools._get_read_client() + + assert FakePyHugeClient.init_kwargs[0]["graph"] == "first_graph" + assert FakePyHugeClient.init_kwargs[0]["graphspace"] == "first_space" + assert FakePyHugeClient.init_kwargs[1]["graph"] == "second_graph" + assert FakePyHugeClient.init_kwargs[1]["graphspace"] == "second_space" diff --git a/hugegraph-mcp/tests/test_execute_gremlin_write.py b/hugegraph-mcp/tests/test_execute_gremlin_write.py new file mode 100644 index 000000000..1a9967b3d --- /dev/null +++ b/hugegraph-mcp/tests/test_execute_gremlin_write.py @@ -0,0 +1,79 @@ +# 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. + + +class FakeGremlinClient: + def __init__(self, results): + self.results = results + self.last_query = None + + def exec(self, query: str): + self.last_query = query + return self.results + + +def test_execute_gremlin_write_basic(monkeypatch): + """Basic write path: uses write client, returns affected & is_write.""" + + # Set readonly environment to false for this test + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + + from hugegraph_mcp import gremlin_tools + from hugegraph_mcp.guard import Capability + + fake_client = FakeGremlinClient(results=[{"id": 1}, {"id": 2}]) + guarded = [] + + def fake_guard_write(capability=Capability.DATA_WRITE, **_kwargs): + guarded.append(capability) + return None + + monkeypatch.setattr(gremlin_tools, "guard_write", fake_guard_write) + monkeypatch.setattr( + gremlin_tools, "_get_write_client", lambda: fake_client, raising=False + ) + + res = gremlin_tools.execute_gremlin_write( + "g.addV('person').property('name','Alice')" + ) + + assert fake_client.last_query is not None + assert fake_client.last_query.startswith("g.addV") + assert res["ok"] is True + assert res["error"] is None + assert res["data"]["is_write"] is True + assert res["data"]["affected"] == 2 + assert isinstance(res["data"]["duration_ms"], (int, float)) + assert res["meta"]["duration_ms"] == res["data"]["duration_ms"] + assert guarded == [Capability.DATA_WRITE] + + +def test_execute_gremlin_write_blocked_in_readonly(monkeypatch): + """When HUGEGRAPH_MCP_READONLY is true, write tool must be blocked.""" + + # Set readonly environment to true for this test + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + + from hugegraph_mcp import gremlin_tools + + fake_client = FakeGremlinClient(results=[]) + monkeypatch.setattr( + gremlin_tools, "_get_write_client", lambda: fake_client, raising=False + ) + + result = gremlin_tools.execute_gremlin_write("g.addV('person')") + + assert result["ok"] is False + assert result["error"]["type"] == "READONLY_VIOLATION" + assert result["meta"]["readonly"] is True + assert fake_client.last_query is None diff --git a/hugegraph-mcp/tests/test_extract_graph_data.py b/hugegraph-mcp/tests/test_extract_graph_data.py new file mode 100644 index 000000000..2858223fd --- /dev/null +++ b/hugegraph-mcp/tests/test_extract_graph_data.py @@ -0,0 +1,117 @@ +# 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. + +import json +from unittest.mock import Mock + +from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok +from hugegraph_mcp.tools import extract_graph_data as extract_graph_data_module + + +def test_extract_graph_data_basic(monkeypatch): + graph_data = { + "vertices": [{"label": "person", "properties": {"name": "Alice"}}], + "edges": [ + { + "label": "knows", + "source_label": "person", + "target_label": "person", + "source": {"name": "Alice"}, + "target": {"name": "Bob"}, + } + ], + } + post = Mock(return_value=envelope_ok({"ok": True, "data": json.dumps(graph_data)})) + monkeypatch.setattr(extract_graph_data_module, "post", post) + + result = extract_graph_data_module.extract_graph_data( + "Alice knows Bob.", + schema={"vertexlabels": ["person"]}, + example_prompt="extract people", + ) + + assert result["ok"] is True + gd = result["data"]["graph_data"] + assert gd["vertices"] == graph_data["vertices"] + assert gd["edges"] == graph_data["edges"] + assert "schema_ref" in gd + assert gd["schema_ref"]["graph"] is not None + assert gd["warnings"] == [] + assert result["data"]["schema_warnings"] == [] + assert "raw_summary" in result["data"] + post.assert_called_once_with( + "/graph-extract", + json={ + "text": "Alice knows Bob.", + "schema": json.dumps({"vertexlabels": ["person"]}, sort_keys=True), + "example_prompt": "extract people", + "language": "zh", + }, + ) + + +def test_extract_graph_data_uses_graph_schema_by_default(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_GRAPH_PATH", "DEFAULT/hugegraph") + graph_data = {"vertices": [], "edges": []} + post = Mock(return_value=envelope_ok({"ok": True, "data": json.dumps(graph_data)})) + monkeypatch.setattr(extract_graph_data_module, "post", post) + + result = extract_graph_data_module.extract_graph_data("Alice knows Bob.") + + assert result["ok"] is True + post.assert_called_once_with( + "/graph-extract", + json={ + "text": "Alice knows Bob.", + "schema": "hugegraph", + "example_prompt": extract_graph_data_module.DEFAULT_GRAPH_EXTRACT_PROMPT_ZH, + "language": "zh", + }, + ) + + +def test_extract_graph_data_preserves_explicit_string_schema_and_prompt(monkeypatch): + graph_data = {"vertices": [], "edges": []} + post = Mock(return_value=envelope_ok({"ok": True, "data": json.dumps(graph_data)})) + monkeypatch.setattr(extract_graph_data_module, "post", post) + + result = extract_graph_data_module.extract_graph_data( + "Alice knows Bob.", + schema="custom_graph", + example_prompt="custom prompt", + ) + + assert result["ok"] is True + post.assert_called_once_with( + "/graph-extract", + json={ + "text": "Alice knows Bob.", + "schema": "custom_graph", + "example_prompt": "custom prompt", + "language": "zh", + }, + ) + + +def test_extract_graph_data_ai_unavailable(monkeypatch): + ai_error = envelope_err( + ErrorType.HUGEGRAPH_AI_UNAVAILABLE, + "HugeGraph-AI is unavailable", + retryable=True, + ) + post = Mock(return_value=ai_error) + monkeypatch.setattr(extract_graph_data_module, "post", post) + + result = extract_graph_data_module.extract_graph_data("Alice knows Bob.") + + assert result == ai_error diff --git a/hugegraph-mcp/tests/test_generate_gremlin.py b/hugegraph-mcp/tests/test_generate_gremlin.py new file mode 100644 index 000000000..b20cfd73d --- /dev/null +++ b/hugegraph-mcp/tests/test_generate_gremlin.py @@ -0,0 +1,184 @@ +# 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. + +from unittest.mock import Mock + +from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok +from hugegraph_mcp.tools import generate_gremlin as generate_gremlin_module + + +def _ai_ok(gremlin: str, **extra) -> dict: + data = { + "gremlin": gremlin, + "template_gremlin": gremlin, + "raw_gremlin": gremlin, + "requires_index": False, + "assumptions": None, + } + data.update(extra) + return envelope_ok(data) + + +def test_generate_gremlin_default_no_execute(monkeypatch): + post = Mock(return_value=_ai_ok("g.V().count()")) + execute_read = Mock() + monkeypatch.setattr(generate_gremlin_module, "post", post) + monkeypatch.setattr(generate_gremlin_module, "execute_gremlin_read", execute_read) + + result = generate_gremlin_module.generate_gremlin("count vertices") + + assert result["ok"] is True + assert result["data"]["gremlin"] == "g.V().count()" + assert result["data"]["template_gremlin"] == "g.V().count()" + assert result["data"]["raw_gremlin"] == "g.V().count()" + assert result["data"]["is_readonly"] is True + assert result["data"]["risk_level"] == "low" + assert result["data"]["requires_index"] is False + assert result["data"]["assumptions"] is None + assert result["data"]["executed"] is False + assert result["data"]["execution_result"] is None + post.assert_called_once_with("/text2gremlin", json={"query": "count vertices"}) + execute_read.assert_not_called() + + +def test_generate_gremlin_passes_output_types(monkeypatch): + post = Mock(return_value=_ai_ok("g.V().count()")) + monkeypatch.setattr(generate_gremlin_module, "post", post) + + result = generate_gremlin_module.generate_gremlin( + "count vertices", + output_types=["vertex"], + ) + + assert result["ok"] is True + post.assert_called_once_with( + "/text2gremlin", + json={"query": "count vertices", "output_types": ["vertex"]}, + ) + + +def test_generate_gremlin_rejects_missing_gremlin(monkeypatch): + post = Mock( + return_value=envelope_ok( + {"requires_index": False, "assumptions": ["no query generated"]} + ) + ) + execute_read = Mock() + monkeypatch.setattr(generate_gremlin_module, "post", post) + monkeypatch.setattr(generate_gremlin_module, "execute_gremlin_read", execute_read) + + result = generate_gremlin_module.generate_gremlin("count vertices", execute=True) + + assert result["ok"] is False + assert result["error"]["type"] == "FLOW_EXECUTION_FAILED" + assert result["error"]["message"] == "HugeGraph-AI did not return Gremlin." + execute_read.assert_not_called() + + +def test_generate_gremlin_safe_execute(monkeypatch): + post = Mock(return_value=_ai_ok("g.V().limit(2)")) + execution_data = { + "data": [{"id": 1}], + "total": 1, + "duration_ms": 1, + "is_read": True, + } + execution_result = envelope_ok(execution_data, duration_ms=1) + execute_read = Mock(return_value=execution_result) + monkeypatch.setattr(generate_gremlin_module, "post", post) + monkeypatch.setattr(generate_gremlin_module, "execute_gremlin_read", execute_read) + + result = generate_gremlin_module.generate_gremlin("show two vertices", execute=True) + + assert result["ok"] is True + assert result["data"]["is_readonly"] is True + assert result["data"]["risk_level"] == "low" + assert result["data"]["requires_index"] is False + assert result["data"]["assumptions"] is None + assert result["data"]["executed"] is True + assert result["data"]["execution_result"] == execution_data + assert result["data"]["execution_meta"] == execution_result["meta"] + execute_read.assert_called_once_with("g.V().limit(2)") + + +def test_generate_gremlin_unwraps_execution_envelope(monkeypatch): + post = Mock(return_value=_ai_ok("g.V().limit(1)")) + execution_result = envelope_ok( + {"data": [{"id": 1}], "total": 1, "duration_ms": 1, "is_read": True}, + duration_ms=1, + ) + execute_read = Mock(return_value=execution_result) + monkeypatch.setattr(generate_gremlin_module, "post", post) + monkeypatch.setattr(generate_gremlin_module, "execute_gremlin_read", execute_read) + + result = generate_gremlin_module.generate_gremlin("show one vertex", execute=True) + + assert result["ok"] is True + assert result["data"]["executed"] is True + assert "ok" not in result["data"]["execution_result"] + + +def test_generate_gremlin_propagates_execute_failure(monkeypatch): + post = Mock(return_value=_ai_ok("g.V().has('name','Alice')")) + execution_error = envelope_err( + ErrorType.NO_INDEX, + "Query requires an index", + suggestion="Create an index", + ) + execute_read = Mock(return_value=execution_error) + monkeypatch.setattr(generate_gremlin_module, "post", post) + monkeypatch.setattr(generate_gremlin_module, "execute_gremlin_read", execute_read) + + result = generate_gremlin_module.generate_gremlin("find Alice", execute=True) + + assert result["ok"] is False + assert result["error"]["type"] == "NO_INDEX" + assert result["error"]["details"]["gremlin"] == "g.V().has('name','Alice')" + assert result["error"]["details"]["execution_error"]["message"] == ( + "Query requires an index" + ) + + +def test_generate_gremlin_unsafe_no_execute(monkeypatch): + post = Mock(return_value=_ai_ok("g.addV('person')")) + execute_read = Mock() + monkeypatch.setattr(generate_gremlin_module, "post", post) + monkeypatch.setattr(generate_gremlin_module, "execute_gremlin_read", execute_read) + + result = generate_gremlin_module.generate_gremlin("add a person", execute=True) + + assert result["ok"] is False + assert result["error"]["type"] == "UNSAFE_GREMLIN" + assert ( + result["error"]["message"] + == "Generated Gremlin is not safe to execute automatically" + ) + assert result["error"]["details"]["classification"] == "unsafe" + execute_read.assert_not_called() + + +def test_generate_gremlin_ai_unavailable(monkeypatch): + ai_error = envelope_err( + ErrorType.HUGEGRAPH_AI_UNAVAILABLE, + "HugeGraph-AI is unavailable", + retryable=True, + ) + post = Mock(return_value=ai_error) + execute_read = Mock() + monkeypatch.setattr(generate_gremlin_module, "post", post) + monkeypatch.setattr(generate_gremlin_module, "execute_gremlin_read", execute_read) + + result = generate_gremlin_module.generate_gremlin("count vertices", execute=True) + + assert result == ai_error + execute_read.assert_not_called() diff --git a/hugegraph-mcp/tests/test_get_live_schema.py b/hugegraph-mcp/tests/test_get_live_schema.py new file mode 100644 index 000000000..e1e56a2b2 --- /dev/null +++ b/hugegraph-mcp/tests/test_get_live_schema.py @@ -0,0 +1,162 @@ +# 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. + + +class FakeSchemaManager: + def __init__(self, schema): + self._schema = schema + + def getSchema(self, _format: str = "json"): + return self._schema + + +class FakePyHugeClient: + last_init_kwargs: dict | None = None + schema_data: dict | None = None + + def __init__( + self, url: str, graph: str, user: str, pwd: str, graphspace=None, timeout=None + ): + FakePyHugeClient.last_init_kwargs = { + "url": url, + "graph": graph, + "user": user, + "pwd": pwd, + "graphspace": graphspace, + "timeout": timeout, + } + self._schema = FakePyHugeClient.schema_data + + def schema(self): + return FakeSchemaManager(self._schema) + + +def _make_full_schema(): + return { + "vertexlabels": [ + {"id": 1, "name": "person", "properties": ["name", "age"]}, + ], + "edgelabels": [ + { + "name": "knows", + "source_label": "person", + "target_label": "person", + "properties": ["since"], + } + ], + "propertykeys": [ + {"name": "name", "data_type": "TEXT"}, + {"name": "age", "data_type": "INT"}, + {"name": "since", "data_type": "INT"}, + ], + } + + +def test_get_live_schema_basic(monkeypatch): + from hugegraph_mcp import schema_tools + + FakePyHugeClient.schema_data = _make_full_schema() + monkeypatch.setattr(schema_tools, "PyHugeClient", FakePyHugeClient) + + result = schema_tools.get_live_schema() + + assert "schema" in result + assert result["schema"] == FakePyHugeClient.schema_data + assert "simple_schema" in result + simple = result["simple_schema"] + assert "vertexlabels" in simple + assert "edgelabels" in simple + assert simple["vertexlabels"][0]["name"] == "person" + assert simple["edgelabels"][0]["name"] == "knows" + + +def test_get_live_schema_with_graphspace(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_GRAPH_PATH", "mcp_space/hugegraph") + + import hugegraph_mcp.schema_tools + + FakePyHugeClient.schema_data = _make_full_schema() + monkeypatch.setattr(hugegraph_mcp.schema_tools, "PyHugeClient", FakePyHugeClient) + + result = hugegraph_mcp.schema_tools.get_live_schema() + + # Type checker: ensure last_init_kwargs is not None before accessing + assert FakePyHugeClient.last_init_kwargs is not None + assert FakePyHugeClient.last_init_kwargs["graphspace"] == "mcp_space" + assert result.get("graphspace") == "mcp_space" + + +def test_get_live_schema_uses_current_env_without_reload(monkeypatch): + from hugegraph_mcp import schema_tools + + FakePyHugeClient.schema_data = _make_full_schema() + monkeypatch.setattr(schema_tools, "PyHugeClient", FakePyHugeClient) + + monkeypatch.setenv("HUGEGRAPH_GRAPH", "first_graph") + monkeypatch.setenv("HUGEGRAPH_GRAPHSPACE", "first_space") + schema_tools.get_live_schema() + first_kwargs = dict(FakePyHugeClient.last_init_kwargs) + + monkeypatch.setenv("HUGEGRAPH_GRAPH", "second_graph") + monkeypatch.setenv("HUGEGRAPH_GRAPHSPACE", "second_space") + schema_tools.get_live_schema() + second_kwargs = dict(FakePyHugeClient.last_init_kwargs) + + assert first_kwargs["graph"] == "first_graph" + assert first_kwargs["graphspace"] == "first_space" + assert second_kwargs["graph"] == "second_graph" + assert second_kwargs["graphspace"] == "second_space" + + +def test_get_live_schema_respects_readonly_flag(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + + from hugegraph_mcp import schema_tools + + FakePyHugeClient.schema_data = _make_full_schema() + monkeypatch.setattr(schema_tools, "PyHugeClient", FakePyHugeClient) + + result = schema_tools.get_live_schema() + + assert result.get("readonly") is True + + +def test_current_live_schema_respects_explicit_empty_schema(monkeypatch): + from hugegraph_mcp import schema_tools + from hugegraph_mcp.tools.live_schema import current_live_schema + + def fail_fetch(): + raise AssertionError( + "current_live_schema should not fetch when schema is provided" + ) + + empty_schema = {} + monkeypatch.setattr(schema_tools, "get_live_schema", fail_fetch) + + assert current_live_schema(empty_schema) is empty_schema + + +def test_fetch_live_schema_or_none_logs_fetch_failures(monkeypatch, caplog): + from hugegraph_mcp import schema_tools + from hugegraph_mcp.tools.live_schema import fetch_live_schema_or_none + + def fail_fetch(): + raise RuntimeError("schema fetch failed") + + monkeypatch.setattr(schema_tools, "get_live_schema", fail_fetch) + + with caplog.at_level("WARNING", logger="hugegraph_mcp.live_schema"): + result = fetch_live_schema_or_none() + + assert result is None + assert "Failed to fetch live schema: schema fetch failed" in caplog.text diff --git a/hugegraph-mcp/tests/test_gremlin_policy.py b/hugegraph-mcp/tests/test_gremlin_policy.py new file mode 100644 index 000000000..be1d4f3ee --- /dev/null +++ b/hugegraph-mcp/tests/test_gremlin_policy.py @@ -0,0 +1,163 @@ +# 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. + +"""Tests for GremlinPolicy (Milestone 3).""" + +from hugegraph_mcp.gremlin_policy import ( + GremlinDecision, + GremlinPolicy, + check_gremlin_read, + gremlin_cost_warnings, +) + + +def test_safe_query_returns_allowed(): + decision = check_gremlin_read("g.V().limit(10)") + + assert decision.allowed is True + assert decision.classification == "safe" + assert decision.error_type is None + assert decision.suggestion is None + + +def test_unsafe_query_returns_blocked(): + decision = check_gremlin_read("g.addV('person')") + + assert decision.allowed is False + assert decision.classification == "unsafe" + assert decision.error_type == "UNSAFE_GREMLIN" + assert "write" in decision.reason.lower() + + +def test_uncertain_query_returns_blocked(): + decision = check_gremlin_read("g.V().unknownStep()") + + assert decision.allowed is False + assert decision.classification == "uncertain" + assert decision.error_type == "UNSAFE_GREMLIN" + assert ( + "ambiguous" in decision.reason.lower() or "unknown" in decision.reason.lower() + ) + + +def test_decision_is_frozen_dataclass(): + decision = check_gremlin_read("g.V().count()") + + assert isinstance(decision, GremlinDecision) + try: + decision.allowed = False + assert False, "Should be frozen" + except AttributeError: + pass + + +def test_policy_class_instance(): + policy = GremlinPolicy() + + safe = policy.check_read("g.V().count()") + assert safe.allowed is True + + unsafe = policy.check_read("g.V().drop()") + assert unsafe.allowed is False + + uncertain = policy.check_read("g.V().map { it }") + assert uncertain.allowed is False + + +def test_newly_denied_steps_as_unsafe(): + for step in ["sideEffect", "io", "call", "program"]: + decision = check_gremlin_read(f"g.V().{step}('x')") + assert decision.allowed is False, f"Expected blocked: {step}" + assert decision.classification == "unsafe" + + +def test_accumulator_steps_as_uncertain(): + for step in ["sack", "store", "aggregate", "cap"]: + decision = check_gremlin_read(f"g.V().{step}('x')") + assert decision.allowed is False, f"Expected blocked: {step}" + assert decision.classification == "uncertain" + + +def test_bare_identifier_wrapped_in_parentheses_is_uncertain(): + decision = check_gremlin_read("g.V().has('age', (secret_token))") + + assert decision.allowed is False + assert decision.classification == "uncertain" + + +def test_bare_identifier_nested_in_function_call_is_uncertain(): + decision = check_gremlin_read("g.V().has('age', coalesce(secret_token))") + + assert decision.allowed is False + assert decision.classification == "uncertain" + + +def test_allowed_literal_tokens_remain_safe(): + for query in [ + "g.V().has('active', true)", + "g.V().has('active', false)", + "g.V().has('deleted_at', null)", + ]: + decision = check_gremlin_read(query) + assert decision.allowed is True, f"Expected allowed: {query}" + + +def test_edge_endpoint_where_traversal_is_safe(): + decision = check_gremlin_read( + "g.V().hasLabel('person').has('name','Alice')" + ".outE('knows').where(inV().hasLabel('person').has('name','Bob')).count()" + ) + + assert decision.allowed is True + assert decision.classification == "safe" + + +def test_gremlin_cost_warnings_bounded_limit_query_is_empty(): + assert gremlin_cost_warnings("g.V().limit(10)") == [] + + +def test_gremlin_cost_warnings_count_query_is_empty(): + assert gremlin_cost_warnings("g.V().count()") == [] + + +def test_gremlin_cost_warnings_unbounded_query_mentions_limit(): + warnings = gremlin_cost_warnings("g.V()") + + assert warnings + assert any("limit" in warning for warning in warnings) + + +def test_gremlin_cost_warnings_repeat_without_times(): + warnings = gremlin_cost_warnings("g.V().repeat(out())") + + assert any("repeat" in warning and "times" in warning for warning in warnings) + + +def test_gremlin_cost_warnings_repeat_times_exceeds_threshold(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_MAX_REPEAT_TIMES", "10") + + warnings = gremlin_cost_warnings("g.V().repeat(out()).times(100).limit(10)") + + assert any("depth" in warning or "maximum" in warning for warning in warnings) + + +def test_gremlin_cost_warnings_group_with_limit_is_not_heavy_warning(): + warnings = gremlin_cost_warnings('g.V().group().by("name").limit(5)') + + assert not any("Heavy step" in warning for warning in warnings) + + +def test_gremlin_cost_warnings_group_without_limit_is_heavy_warning(): + warnings = gremlin_cost_warnings("g.V().group()") + + assert any("Heavy step" in warning for warning in warnings) diff --git a/hugegraph-mcp/tests/test_gremlin_safety.py b/hugegraph-mcp/tests/test_gremlin_safety.py new file mode 100644 index 000000000..f1cbf9f26 --- /dev/null +++ b/hugegraph-mcp/tests/test_gremlin_safety.py @@ -0,0 +1,110 @@ +# 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. + +from hugegraph_mcp.gremlin_safety import classify_gremlin_read_safety + + +def test_safe_read_queries(): + queries = [ + "g.V().count()", + "g.E().limit(10)", + "g.V().hasLabel('person').values('name')", + "g.V().both().id()", + "g.V().out().path()", + "g.V().has('name', 'alice').valueMap()", + "g.E().range(0, 5).elementMap()", + "g.V().hasLabel('person').has('name','Alice').outE('knows').where(inV().hasLabel('person').has('name','Bob')).count()", + ] + + for query in queries: + assert classify_gremlin_read_safety(query) == "safe" + + +def test_unsafe_write_queries(): + queries = [ + "g.addV('person')", + "g.addE('knows')", + "g.V().drop()", + "g.E().dropE()", + "g.V().property('name','x')", + "g.V().has('name','x').drop()", + "g.V().remove()", + "g.E().clear()", + "g.V().hasLabel('person').drop().iterate()", + ] + + for query in queries: + assert classify_gremlin_read_safety(query) == "unsafe" + + +def test_uncertain_queries(): + queries = [ + "g.V().unknownStep()", + "query + g.V().count()", + "g.V().hasLabel(label)", + "def q = g.V().count(); q", + "graph.traversal().V().count()", + "g.V().map{ it.get() }", + ] + + for query in queries: + assert classify_gremlin_read_safety(query) == "uncertain" + + +def test_case_insensitive(): + assert classify_gremlin_read_safety("G.V().COUNT()") == "safe" + assert classify_gremlin_read_safety("G.ADDV('person')") == "unsafe" + assert classify_gremlin_read_safety("g.V().DROP()") == "unsafe" + + +def test_existing_keyword_tests_still_pass(): + queries = [ + "g.addV('person')", + "g.addE('knows')", + "g.V().dropV()", + "g.E().dropE()", + "g.V().property('name', 'alice')", + ] + + for query in queries: + assert classify_gremlin_read_safety(query) == "unsafe" + + +def test_newly_denied_high_risk_steps(): + """M1: sideEffect, io, call, program must be classified as unsafe.""" + queries = [ + "g.V().sideEffect('x')", + "g.V().io('file')", + "g.V().call('func')", + "g.V().program('script')", + ] + + for query in queries: + assert classify_gremlin_read_safety(query) == "unsafe", ( + f"Expected unsafe: {query}" + ) + + +def test_side_effect_accumulators_are_uncertain(): + """sack, store, aggregate, cap are not in read whitelist → classified as uncertain.""" + queries = [ + "g.V().sack(sum).by('age')", + "g.V().store('x')", + "g.V().aggregate('x')", + "g.V().cap('x')", + ] + + for query in queries: + assert classify_gremlin_read_safety(query) == "uncertain", ( + f"Expected uncertain: {query}" + ) diff --git a/hugegraph-mcp/tests/test_guard.py b/hugegraph-mcp/tests/test_guard.py new file mode 100644 index 000000000..dd0eb5a41 --- /dev/null +++ b/hugegraph-mcp/tests/test_guard.py @@ -0,0 +1,77 @@ +# 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. + +import pytest + +from hugegraph_mcp.guard import Capability, guard, require_capability + + +WRITE_CAPABILITIES = ( + Capability.DATA_WRITE, + Capability.SCHEMA_WRITE, + Capability.INDEX_WRITE, + Capability.DEBUG_WRITE, +) + + +def test_guard_allows_read_in_readonly(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + + assert guard(Capability.READ) is None + assert guard(Capability.GENERATE) is None + + +def test_guard_blocks_write_in_readonly(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + + for capability in WRITE_CAPABILITIES: + result = guard(capability) + + assert result is not None + assert result["ok"] is False + assert result["error"]["type"] == "READONLY_VIOLATION" + assert result["meta"]["capability"] == capability.value + + +def test_guard_allows_all_when_not_readonly(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + + for capability in Capability: + assert guard(capability) is None + + +def test_guard_returns_envelope(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + + result = guard(Capability.DATA_WRITE) + + assert result is not None + assert set(result) == { + "ok", + "data", + "error", + "warnings", + "next_actions", + "meta", + } + assert result["ok"] is False + assert result["data"] is None + assert result["error"]["type"] == "READONLY_VIOLATION" + assert result["meta"]["readonly"] is True + + +def test_require_capability_raises(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + + with pytest.raises(PermissionError): + require_capability(Capability.SCHEMA_WRITE) diff --git a/hugegraph-mcp/tests/test_hugegraph_ai_client.py b/hugegraph-mcp/tests/test_hugegraph_ai_client.py new file mode 100644 index 000000000..e99bf943e --- /dev/null +++ b/hugegraph-mcp/tests/test_hugegraph_ai_client.py @@ -0,0 +1,262 @@ +# 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. + +from unittest.mock import Mock + +import requests + +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.hugegraph_ai_client import get, health_check, post, request + + +class FakeResponse: + def __init__(self, data=None, status_code: int = 200): + self._data = data + self.status_code = status_code + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.exceptions.HTTPError( + f"HTTP {self.status_code}", response=self + ) + + def json(self): + if isinstance(self._data, Exception): + raise self._data + return self._data + + +def _cfg(**overrides): + values = { + "ai_url": "http://ai.example:8001", + "allow_ai": True, + "timeout_seconds": 7, + } + values.update(overrides) + return MCPConfig(**values) + + +def test_request_success(monkeypatch): + http_request = Mock(return_value=FakeResponse({"status": "ok"})) + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", http_request + ) + + result = request("GET", "/health", cfg=_cfg()) + + assert result["ok"] is True + assert result["data"] == {"status": "ok"} + http_request.assert_called_once_with( + "GET", + "http://ai.example:8001/health", + params=None, + headers=None, + timeout=7, + ) + + +def test_request_passes_configured_auth_when_password_is_set(monkeypatch): + http_request = Mock(return_value=FakeResponse({"status": "ok"})) + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", http_request + ) + + result = request("GET", "/health", cfg=_cfg(user="alice", password="secret")) + + assert result["ok"] is True + http_request.assert_called_once_with( + "GET", + "http://ai.example:8001/health", + params=None, + headers=None, + timeout=7, + auth=("alice", "secret"), + ) + + +def test_request_connection_error(monkeypatch): + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", + Mock(side_effect=requests.exceptions.ConnectionError("connection refused")), + ) + + result = request("GET", "/health", cfg=_cfg()) + + assert result["ok"] is False + assert result["error"]["type"] == "HUGEGRAPH_AI_UNAVAILABLE" + + +def test_request_timeout(monkeypatch): + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", + Mock(side_effect=requests.exceptions.Timeout("timed out")), + ) + + result = request("GET", "/health", cfg=_cfg()) + + assert result["ok"] is False + assert result["error"]["type"] == "HUGEGRAPH_AI_UNAVAILABLE" + + +def test_request_http_500(monkeypatch): + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", + Mock(return_value=FakeResponse({"error": "boom"}, status_code=500)), + ) + + result = request("GET", "/health", cfg=_cfg()) + + assert result["ok"] is False + assert result["error"]["type"] == "HUGEGRAPH_AI_UNAVAILABLE" + assert result["error"]["details"]["status_code"] == 500 + + +def test_request_http_401_is_authorization_failed(monkeypatch): + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", + Mock(return_value=FakeResponse({"error": "denied"}, status_code=401)), + ) + + result = request("GET", "/health", cfg=_cfg()) + + assert result["ok"] is False + assert result["error"]["type"] == "AUTHORIZATION_FAILED" + assert result["error"]["retryable"] is False + assert result["error"]["details"]["status_code"] == 401 + + +def test_request_http_404_is_not_authorization_failed(monkeypatch): + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", + Mock(return_value=FakeResponse({"error": "missing"}, status_code=404)), + ) + + result = request("GET", "/missing", cfg=_cfg()) + + assert result["ok"] is False + assert result["error"]["type"] == "HUGEGRAPH_AI_UNAVAILABLE" + assert result["error"]["retryable"] is False + assert result["error"]["details"]["status_code"] == 404 + + +def test_request_http_429_is_retryable_ai_error(monkeypatch): + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", + Mock(return_value=FakeResponse({"error": "rate limited"}, status_code=429)), + ) + + result = request("GET", "/health", cfg=_cfg()) + + assert result["ok"] is False + assert result["error"]["type"] == "HUGEGRAPH_AI_UNAVAILABLE" + assert result["error"]["retryable"] is True + assert result["error"]["details"]["status_code"] == 429 + + +def test_request_allow_ai_disabled(monkeypatch): + http_request = Mock() + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", http_request + ) + + result = request("GET", "/health", cfg=_cfg(allow_ai=False)) + + assert result["ok"] is False + assert result["error"]["type"] == "HUGEGRAPH_AI_UNAVAILABLE" + assert result["error"]["message"] == "AI calls are disabled" + http_request.assert_not_called() + + +def test_post_convenience(monkeypatch): + http_request = Mock(return_value=FakeResponse({"gremlin": "g.V().count()"})) + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", http_request + ) + + result = post("/generate-gremlin", cfg=_cfg(), json={"question": "count vertices"}) + + assert result["ok"] is True + http_request.assert_called_once_with( + "POST", + "http://ai.example:8001/generate-gremlin", + params=None, + headers=None, + timeout=7, + json={"question": "count vertices"}, + ) + + +def test_get_convenience(monkeypatch): + http_request = Mock(return_value=FakeResponse({"ready": True})) + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", http_request + ) + + result = get("/graph-index-info", cfg=_cfg()) + + assert result["ok"] is True + http_request.assert_called_once_with( + "GET", + "http://ai.example:8001/graph-index-info", + params=None, + headers=None, + timeout=7, + ) + + +def test_health_check(monkeypatch): + http_request = Mock(return_value=FakeResponse({"ok": True, "data": "ready"})) + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", http_request + ) + + result = health_check(cfg=_cfg()) + + assert result["ok"] is True + assert result["data"]["status"] == "available" + assert result["data"]["health_endpoint"] == "/graph-index-info" + http_request.assert_called_once_with( + "GET", + "http://ai.example:8001/graph-index-info", + params=None, + headers=None, + timeout=7, + ) + + +def test_health_check_falls_back_to_openapi(monkeypatch): + http_request = Mock( + side_effect=[ + FakeResponse({"detail": "missing"}, status_code=404), + FakeResponse({"openapi": "3.1.0"}), + ] + ) + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", http_request + ) + + result = health_check(cfg=_cfg()) + + assert result["ok"] is True + assert result["data"]["status"] == "available" + assert result["data"]["health_endpoint"] == "/openapi.json" + assert result["data"]["openapi"] == "3.1.0" + assert len(result["warnings"]) == 1 + assert http_request.call_args_list[0].args[:2] == ( + "GET", + "http://ai.example:8001/graph-index-info", + ) + assert http_request.call_args_list[1].args[:2] == ( + "GET", + "http://ai.example:8001/openapi.json", + ) diff --git a/hugegraph-mcp/tests/test_import_graph_data_tool.py b/hugegraph-mcp/tests/test_import_graph_data_tool.py new file mode 100644 index 000000000..f06fea6e4 --- /dev/null +++ b/hugegraph-mcp/tests/test_import_graph_data_tool.py @@ -0,0 +1,175 @@ +# 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. + +from hugegraph_mcp.envelope import envelope_ok +from hugegraph_mcp import server + + +def test_import_graph_data_tool_extract_routes_to_extract(monkeypatch): + calls = [] + + def fake_extract_graph_data(text, schema=None, example_prompt=None): + calls.append((text, schema, example_prompt)) + return envelope_ok({"graph_data": {"vertices": [], "edges": []}}) + + monkeypatch.setattr(server, "extract_graph_data", fake_extract_graph_data) + + result = server.import_graph_data_tool( + mode="extract", + text="Alice knows Bob.", + graph_schema={"vertexlabels": ["person"]}, + example_prompt="extract people", + ) + + assert result["ok"] is True + assert "duration_ms" in result["meta"] + assert calls == [ + ("Alice knows Bob.", {"vertexlabels": ["person"]}, "extract people") + ] + + +def test_import_graph_data_tool_ingest_routes_to_mcp_import(monkeypatch): + calls = [] + graph_data = {"vertices": [], "edges": []} + + def fake_manage_graph_data( + mode, + graph_data, + dry_run=True, + confirm=False, + plan_hash=None, + nonce=None, + expires_at=None, + plan_tool_name="manage_graph_data", + ): + calls.append( + ( + mode, + graph_data, + dry_run, + confirm, + plan_hash, + nonce, + expires_at, + plan_tool_name, + ) + ) + return envelope_ok({"plan_hash": "0123456789abcdef"}) + + monkeypatch.setattr(server, "manage_graph_data", fake_manage_graph_data) + + result = server.import_graph_data_tool( + mode="ingest", + graph_data=graph_data, + dry_run=False, + confirm=True, + plan_hash="0123456789abcdef", + nonce="test_nonce", + expires_at=9999999999.0, + ) + + assert result["ok"] is True + assert "duration_ms" in result["meta"] + assert calls == [ + ( + "import", + graph_data, + False, + True, + "0123456789abcdef", + "test_nonce", + 9999999999.0, + "import_graph_data_tool", + ) + ] + + +def test_import_graph_data_tool_wraps_extract_exception(monkeypatch): + def fake_extract_graph_data(text, schema=None, example_prompt=None): + raise RuntimeError("extract failed") + + monkeypatch.setattr(server, "extract_graph_data", fake_extract_graph_data) + + result = server.import_graph_data_tool(mode="extract", text="Alice knows Bob.") + + assert result["ok"] is False + assert result["error"]["type"] == "FLOW_EXECUTION_FAILED" + assert result["error"]["source"] == "import_graph_data_tool" + assert "extract failed" in result["error"]["message"] + + +def test_import_graph_data_tool_aligns_ingest_error_source(monkeypatch): + def fake_manage_graph_data(**_kwargs): + from hugegraph_mcp.envelope import ErrorType, envelope_err + + return envelope_err(ErrorType.INVALID_GRAPH_DATA, "bad graph data") + + monkeypatch.setattr(server, "manage_graph_data", fake_manage_graph_data) + + result = server.import_graph_data_tool( + mode="ingest", + graph_data={"vertices": [], "edges": []}, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "INVALID_GRAPH_DATA" + assert result["error"]["source"] == "import_graph_data_tool" + + +def test_import_graph_data_tool_validates_extract_text(): + result = server.import_graph_data_tool(mode="extract") + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert result["error"]["source"] == "import_graph_data_tool" + assert "duration_ms" in result["meta"] + assert "text is required" in result["error"]["message"] + + +def test_import_graph_data_tool_validates_ingest_graph_data(): + result = server.import_graph_data_tool(mode="ingest") + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert result["error"]["source"] == "import_graph_data_tool" + assert "duration_ms" in result["meta"] + assert "graph_data is required" in result["error"]["message"] + + +def test_import_graph_data_tool_rejects_non_object_graph_data(): + result = server.import_graph_data_tool(mode="ingest", graph_data=[]) + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert result["error"]["source"] == "import_graph_data_tool" + assert result["error"]["details"] == {"received_type": "list"} + assert "graph_data must be an object" in result["error"]["message"] + + +def test_import_graph_data_tool_rejects_unknown_mode(): + result = server.import_graph_data_tool(mode="unknown") + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert result["error"]["source"] == "import_graph_data_tool" + assert "duration_ms" in result["meta"] + assert result["error"]["details"] == {"mode": "unknown"} + + +def test_import_graph_data_tool_table_returns_feature_disabled(): + result = server.import_graph_data_tool(mode="table", table_data={"rows": []}) + + assert result["ok"] is False + assert result["error"]["type"] == "FEATURE_DISABLED" + assert result["error"]["source"] == "import_graph_data_tool" + assert "duration_ms" in result["meta"] diff --git a/hugegraph-mcp/tests/test_ingest_graph_data.py b/hugegraph-mcp/tests/test_ingest_graph_data.py new file mode 100644 index 000000000..eeaf180c1 --- /dev/null +++ b/hugegraph-mcp/tests/test_ingest_graph_data.py @@ -0,0 +1,776 @@ +# 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. + +import json +import re +from unittest.mock import Mock + +from hugegraph_mcp.envelope import envelope_ok +from hugegraph_mcp.tools import ingest_graph_data as ingest_graph_data_module + + +def _graph_data(): + return { + "vertices": [ + {"label": "person", "properties": {"name": "Alice"}}, + {"label": "person", "properties": {"name": "Bob"}}, + ], + "edges": [ + { + "label": "knows", + "source_label": "person", + "target_label": "person", + "source": {"name": "Alice"}, + "target": {"name": "Bob"}, + } + ], + } + + +def _live_schema(): + return { + "schema": { + "vertexlabels": [ + { + "id": 1, + "name": "person", + "properties": [{"name": "name"}, {"name": "age"}], + "primary_keys": ["name"], + }, + ], + "edgelabels": [ + {"name": "knows", "source_label": "person", "target_label": "person"}, + ], + "propertykeys": [ + {"name": "name", "data_type": "TEXT"}, + {"name": "age", "data_type": "INT"}, + ], + }, + } + + +def _mock_schema(monkeypatch): + monkeypatch.setattr( + ingest_graph_data_module, "_fetch_live_schema", lambda: _live_schema() + ) + + +def test_ingest_graph_data_accepts_string_property_schema(monkeypatch): + schema = _live_schema() + schema["schema"]["vertexlabels"][0]["properties"] = ["name", "age"] + schema["schema"]["edgelabels"][0]["properties"] = ["since"] + monkeypatch.setattr(ingest_graph_data_module, "_fetch_live_schema", lambda: schema) + + result = ingest_graph_data_module.ingest_graph_data( + { + "vertices": [ + {"label": "person", "properties": {"name": "Alice", "age": 30}}, + {"label": "person", "properties": {"name": "Bob", "age": 31}}, + ], + "edges": [ + { + "label": "knows", + "source_label": "person", + "target_label": "person", + "source": {"name": "Alice"}, + "target": {"name": "Bob"}, + "properties": {"since": 2020}, + } + ], + } + ) + + assert result["ok"] is True + + +def test_ingest_graph_data_dry_run(monkeypatch): + _mock_schema(monkeypatch) + + result = ingest_graph_data_module.ingest_graph_data(_graph_data()) + + assert result["ok"] is True + assert re.fullmatch(r"[0-9a-f]{32}", result["data"]["plan_hash"]) + assert result["data"]["mutation_summary"] == {"vertices": 2, "edges": 1} + assert any("index" in w for w in result["data"]["warnings"]) + assert "duplicate vertex labels detected" not in result["data"]["warnings"] + + +def test_ingest_graph_data_dry_run_same_input_same_hash(monkeypatch): + _mock_schema(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" + ) + + assert first["data"]["plan_hash"] == second["data"]["plan_hash"] + + +def test_ingest_graph_data_plan_hash_includes_schema(monkeypatch): + graph_data = _graph_data() + schema = _live_schema() + schema_with_age_text = _live_schema() + 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 + ) + + assert first != second + + +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"]["vertexlabels"][0]["properties"] = [ + {"name": "age"}, + {"name": "name"}, + ] + + first = ingest_graph_data_module.calculate_plan_hash(graph_data, schema) + second = ingest_graph_data_module.calculate_plan_hash(graph_data, reordered_schema) + + assert first == second + + +def test_ingest_plan_hash_schema_primary_key_change_different_hash(): + graph_data = _graph_data() + schema = _live_schema() + changed_schema = _live_schema() + changed_schema["schema"]["vertexlabels"][0]["primary_keys"] = ["age"] + + first = ingest_graph_data_module.calculate_plan_hash(graph_data, schema) + second = ingest_graph_data_module.calculate_plan_hash(graph_data, changed_schema) + + assert first != second + + +def test_ingest_plan_hash_schema_metadata_ignored_same_hash(): + graph_data = _graph_data() + schema = _live_schema() + schema_with_metadata = _live_schema() + schema_with_metadata["schema"]["propertykeys"][0]["id"] = 1 + schema_with_metadata["schema"]["propertykeys"][0]["user_data"] = {"x": "y"} + schema_with_metadata["schema"]["vertexlabels"][0]["id"] = 99 + 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 + ) + + assert first == second + + +def test_ingest_plan_hash_graph_data_order_same_hash(): + graph_data = _graph_data() + reordered_graph_data = { + "edges": [ + { + "target": {"name": "Bob"}, + "source": {"name": "Alice"}, + "target_label": "person", + "source_label": "person", + "label": "knows", + } + ], + "vertices": [ + {"properties": {"name": "Bob"}, "label": "person"}, + {"properties": {"name": "Alice"}, "label": "person"}, + ], + } + + first = ingest_graph_data_module.calculate_plan_hash(graph_data, _live_schema()) + second = ingest_graph_data_module.calculate_plan_hash( + reordered_graph_data, + _live_schema(), + ) + + assert first == second + + +def test_ingest_graph_data_validate_invalid(monkeypatch): + _mock_schema(monkeypatch) + + result = ingest_graph_data_module.ingest_graph_data({"vertices": [{}], "edges": []}) + + assert result["ok"] is False + assert result["error"]["type"] == "SCHEMA_MISMATCH" + assert "missing required field: label" in result["error"]["details"]["errors"][0] + + +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": []} + ) + + assert result["ok"] is False + assert result["error"]["type"] == "CONNECTION_FAILED" + assert "Cannot read live schema" in result["error"]["message"] + + +def test_ingest_graph_data_schema_mismatch(monkeypatch): + _mock_schema(monkeypatch) + + # Edge source_label='ghost' does not exist in schema + bad_data = { + "vertices": [{"label": "person", "properties": {"name": "Alice"}}], + "edges": [ + { + "label": "knows", + "source_label": "ghost", + "target_label": "person", + } + ], + } + + result = ingest_graph_data_module.ingest_graph_data(bad_data) + + 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"] + ) + + +def test_validate_graph_payload_rejects_labels_when_live_schema_is_empty(): + empty_schema = { + "schema": { + "propertykeys": [], + "vertexlabels": [], + "edgelabels": [], + "indexlabels": [], + } + } + + result = ingest_graph_data_module.validate_graph_payload( + { + "vertices": [{"label": "person", "properties": {"name": "Alice"}}], + "edges": [ + { + "label": "knows", + "source_label": "person", + "target_label": "person", + "source": {"name": "Alice"}, + "target": {"name": "Bob"}, + } + ], + }, + live_schema=empty_schema, + ) + + assert result["valid"] is False + assert "vertex 0 label 'person' does not exist in schema" in result["errors"] + assert "edge 0 label 'knows' does not exist in schema" in result["errors"] + assert "edge 0 source_label 'person' does not exist in schema" in result["errors"] + assert "edge 0 target_label 'person' does not exist in schema" in result["errors"] + + +def test_ingest_graph_data_rejects_property_type_mismatch(monkeypatch): + _mock_schema(monkeypatch) + + bad_data = { + "vertices": [{"label": "person", "properties": {"name": "Alice", "age": "30"}}], + "edges": [], + } + + result = ingest_graph_data_module.ingest_graph_data(bad_data) + + 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"] + ) + + +def test_ingest_graph_data_rejects_missing_schema_primary_key(monkeypatch): + _mock_schema(monkeypatch) + + result = ingest_graph_data_module.ingest_graph_data( + {"vertices": [{"label": "person", "properties": {"age": 30}}], "edges": []} + ) + + 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"] + ) + + +def test_ingest_graph_data_allows_edge_target_outside_payload(monkeypatch): + _mock_schema(monkeypatch) + + result = ingest_graph_data_module.ingest_graph_data( + { + "vertices": [{"label": "person", "properties": {"name": "Alice"}}], + "edges": [ + { + "label": "knows", + "source_label": "person", + "target_label": "person", + "source": {"name": "Alice"}, + "target": {"name": "Bob"}, + } + ], + } + ) + + assert result["ok"] is True + assert result["data"]["mutation_summary"] == {"vertices": 1, "edges": 1} + + +def test_validate_graph_payload_allows_explicit_id_endpoint_without_primary_key(): + result = ingest_graph_data_module.validate_graph_payload( + { + "vertices": [{"label": "person", "properties": {"name": "Alice"}}], + "edges": [ + { + "label": "knows", + "source_label": "person", + "target_label": "person", + "source": {"name": "Alice"}, + "target": {"id": "1:Bob"}, + } + ], + }, + live_schema=_live_schema(), + ) + + assert result["valid"] is True + + +def test_validate_graph_payload_rejects_mixed_source_endpoint_forms(): + result = ingest_graph_data_module.validate_graph_payload( + { + "vertices": [{"label": "person", "properties": {"name": "Alice"}}], + "edges": [ + { + "label": "knows", + "source_label": "person", + "source": {"name": "Alice"}, + "outV": "1:Alice", + "outVLabel": "person", + "target_label": "person", + "target": {"name": "Bob"}, + } + ], + }, + live_schema=_live_schema(), + ) + + assert result["valid"] is False + assert any("mixes source and outV endpoint forms" in e for e in result["errors"]) + + +def test_validate_graph_payload_rejects_mixed_target_endpoint_forms(): + result = ingest_graph_data_module.validate_graph_payload( + { + "vertices": [{"label": "person", "properties": {"name": "Alice"}}], + "edges": [ + { + "label": "knows", + "source_label": "person", + "source": {"name": "Alice"}, + "target_label": "person", + "target": {"name": "Bob"}, + "inV": "1:Bob", + "inVLabel": "person", + } + ], + }, + live_schema=_live_schema(), + ) + + assert result["valid"] is False + assert any("mixes target and inV endpoint forms" in e for e in result["errors"]) + + +def test_ingest_graph_data_rejects_edge_endpoint_missing_primary_key(monkeypatch): + _mock_schema(monkeypatch) + + result = ingest_graph_data_module.ingest_graph_data( + { + "vertices": [ + {"label": "person", "properties": {"name": "Alice"}}, + {"label": "person", "properties": {"name": "Bob"}}, + ], + "edges": [ + { + "label": "knows", + "source_label": "person", + "target_label": "person", + "source": {"name": "Alice"}, + "target": {"age": 31}, + } + ], + } + ) + + assert result["ok"] is False + assert result["error"]["type"] == "SCHEMA_MISMATCH" + assert any( + "edge 0 target endpoint missing primary key for label 'person': name" in e + for e in result["error"]["details"]["errors"] + ) + + +def test_validate_graph_payload_rejects_ambiguous_scalar_endpoint(): + result = ingest_graph_data_module.validate_graph_payload( + { + "vertices": [ + { + "id": "Alice", + "label": "person", + "properties": {"name": "Other Alice"}, + }, + {"label": "person", "properties": {"name": "Alice"}}, + {"label": "person", "properties": {"name": "Bob"}}, + ], + "edges": [ + { + "label": "knows", + "source_label": "person", + "source": "Alice", + "target_label": "person", + "target": "Bob", + } + ], + }, + live_schema=_live_schema(), + ) + + assert result["valid"] is False + assert any( + "source scalar endpoint is ambiguous" in error for error in result["errors"] + ) + + +def test_ingest_graph_data_valid_payload_with_primary_key_endpoints(monkeypatch): + _mock_schema(monkeypatch) + + result = ingest_graph_data_module.ingest_graph_data(_graph_data()) + + assert result["ok"] is True + assert result["data"]["mutation_summary"] == {"vertices": 2, "edges": 1} + + +def test_ingest_graph_data_resolves_outv_inv_endpoint_shape(monkeypatch): + schema = _live_schema() + schema["schema"]["vertexlabels"][0].pop("primary_keys") + schema["schema"]["vertexlabels"][0]["primaryKeys"] = ["name"] + monkeypatch.setattr(ingest_graph_data_module, "_fetch_live_schema", lambda: schema) + + result = ingest_graph_data_module.ingest_graph_data( + { + "vertices": [ + {"label": "person", "properties": {"name": "Alice"}}, + {"label": "person", "properties": {"name": "Bob"}}, + ], + "edges": [ + { + "label": "knows", + "outV": "1:Alice", + "outVLabel": "person", + "inV": "1:Bob", + "inVLabel": "person", + } + ], + } + ) + + assert result["ok"] is True + + +def test_ingest_graph_data_rejects_duplicate_vertex_identity(monkeypatch): + _mock_schema(monkeypatch) + + result = ingest_graph_data_module.ingest_graph_data( + { + "vertices": [ + {"label": "person", "properties": {"name": "Alice"}}, + {"label": "person", "properties": {"name": "Alice"}}, + ], + "edges": [], + } + ) + + assert result["ok"] is False + assert result["error"]["type"] == "SCHEMA_MISMATCH" + + +def test_ingest_graph_data_rejects_edge_label_mismatch(monkeypatch): + _mock_schema(monkeypatch) + + bad_data = { + "vertices": [{"label": "person", "properties": {"name": "Alice"}}], + "edges": [ + { + "label": "likes", + "source_label": "person", + "target_label": "person", + "source": {"name": "Alice"}, + "target": {"name": "Bob"}, + }, + { + "label": "knows", + "source_label": "person", + "target_label": "ghost", + "source": {"name": "Alice"}, + "target": {"name": "Bob"}, + }, + ], + } + + result = ingest_graph_data_module.ingest_graph_data(bad_data) + + assert result["ok"] is False + assert result["error"]["type"] == "SCHEMA_MISMATCH" + 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 + ) + + +def test_ingest_graph_data_warns_for_labels_without_schema_index(monkeypatch): + schema = _live_schema() + schema["schema"]["indexlabels"] = [ + {"name": "personByName", "base_type": "VERTEX", "base_label": "person"}, + ] + monkeypatch.setattr(ingest_graph_data_module, "_fetch_live_schema", lambda: schema) + + 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"] + ) + + +def test_ingest_graph_data_missing_confirm(monkeypatch): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + + result = ingest_graph_data_module.ingest_graph_data( + _graph_data(), + dry_run=False, + confirm=False, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "CONFIRM_REQUIRED" + + +def test_ingest_graph_data_plan_hash_mismatch(monkeypatch): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + graph_data = _graph_data() + dry_run = ingest_graph_data_module.ingest_graph_data(graph_data) + plan_ctx = dry_run["data"]["plan_context"] + + result = ingest_graph_data_module.ingest_graph_data( + graph_data, + dry_run=False, + confirm=True, + plan_hash="0000000000000000", + nonce=plan_ctx["nonce"], + expires_at=plan_ctx["expires_at"], + ) + + assert result["ok"] is False + assert result["error"]["type"] == "PLAN_HASH_MISMATCH" + + +def test_ingest_graph_data_plan_hash_expired(monkeypatch): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + graph_data = _graph_data() + dry_run = ingest_graph_data_module.ingest_graph_data(graph_data) + plan_ctx = 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=plan_ctx["nonce"], + expires_at=0, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "PLAN_EXPIRED" + + +def test_ingest_graph_data_readonly(monkeypatch): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + + result = ingest_graph_data_module.ingest_graph_data( + _graph_data(), + dry_run=False, + confirm=True, + plan_hash="0000000000000000", + ) + + assert result["ok"] is False + assert result["error"]["type"] == "READONLY_VIOLATION" + + +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) + graph_data = _graph_data() + dry_run = ingest_graph_data_module.ingest_graph_data(graph_data) + + # M5: pass nonce and expires_at from dry_run plan_context + plan_ctx = 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=plan_ctx["nonce"], + 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"] == {} + + +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) + graph_data = _graph_data() + dry_run = ingest_graph_data_module.ingest_graph_data(graph_data) + plan_ctx = 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=plan_ctx["nonce"], + expires_at=plan_ctx["expires_at"], + ) + + 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 any("write outcome is unknown" in w for w in result["warnings"]) + + +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) + graph_data = _graph_data() + dry_run = ingest_graph_data_module.ingest_graph_data(graph_data) + plan_ctx = 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=plan_ctx["nonce"], + expires_at=plan_ctx["expires_at"], + ) + + 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"]) + + +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) + graph_data = _graph_data() + dry_run = ingest_graph_data_module.ingest_graph_data(graph_data) + plan_ctx = 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=plan_ctx["nonce"], + expires_at=plan_ctx["expires_at"], + ) + + assert result["ok"] is False + assert result["error"]["details"]["status"] == "error" + assert result["error"]["details"]["written"] == {"vertices": 0, "edges": 0} + + +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) + graph_data = _graph_data() + dry_run = ingest_graph_data_module.ingest_graph_data(graph_data) + plan_ctx = 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=plan_ctx["nonce"], + expires_at=plan_ctx["expires_at"], + ) + + assert result["ok"] is False + assert result["error"]["details"]["status"] == "degraded" + assert result["error"]["details"]["failed_items"] == [{"index": 0}] diff --git a/hugegraph-mcp/tests/test_inspect_graph.py b/hugegraph-mcp/tests/test_inspect_graph.py new file mode 100644 index 000000000..30cc664e3 --- /dev/null +++ b/hugegraph-mcp/tests/test_inspect_graph.py @@ -0,0 +1,349 @@ +# 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. + +from unittest.mock import Mock + + +def _schema_result(readonly: bool = False): + raw_schema = { + "vertexlabels": [ + {"id": 1, "name": "person", "properties": ["name"]}, + ], + "edgelabels": [ + { + "name": "knows", + "source_label": "person", + "target_label": "person", + "properties": ["since"], + } + ], + "indexlabels": [ + {"name": "personByName"}, + {"name": "knowsBySince"}, + ], + } + return { + "schema": raw_schema, + "simple_schema": { + "vertexlabels": [{"id": 1, "name": "person", "properties": ["name"]}], + "edgelabels": [ + { + "name": "knows", + "source_label": "person", + "target_label": "person", + "properties": ["since"], + } + ], + }, + "readonly": readonly, + } + + +def _patch_ai_available(monkeypatch, inspect_graph_module): + monkeypatch.setattr( + inspect_graph_module, + "health_check", + Mock( + return_value={ + "ok": True, + "data": {"status": "available", "vid_embedding": "ready"}, + "warnings": [], + } + ), + ) + + +def test_inspect_graph_basic(monkeypatch): + from hugegraph_mcp.tools import inspect_graph as inspect_graph_module + + monkeypatch.setattr( + inspect_graph_module, "get_live_schema", lambda: _schema_result() + ) + execute_read = Mock( + side_effect=[ + {"data": [3], "total": 1, "duration_ms": 1, "is_read": True}, + {"data": [2], "total": 1, "duration_ms": 1, "is_read": True}, + ] + ) + monkeypatch.setattr(inspect_graph_module, "execute_gremlin_read", execute_read) + _patch_ai_available(monkeypatch, inspect_graph_module) + + result = inspect_graph_module.inspect_graph() + + assert result["ok"] is True + assert result["error"] is None + assert result["data"]["hugegraph_server_status"] == "available" + assert result["data"]["hugegraph_ai_status"] == "available" + assert result["data"]["schema_summary"]["vertexlabels"][0]["name"] == "person" + assert result["data"]["vertex_count"] == 3 + assert result["data"]["edge_count"] == 2 + assert result["data"]["index_status"] == {"total": 2} + execute_read.assert_any_call("g.V().count()") + execute_read.assert_any_call("g.E().count()") + + +def test_inspect_graph_ai_status_uses_unified_health_check_config(monkeypatch): + from hugegraph_mcp.tools import inspect_graph as inspect_graph_module + + monkeypatch.setenv("HUGEGRAPH_USER", "alice") + monkeypatch.setenv("HUGEGRAPH_PASSWORD", "secret") + monkeypatch.setenv("HUGEGRAPH_AI_URL", "http://ai.example:18001") + monkeypatch.setenv("HUGEGRAPH_MCP_TIMEOUT_SECONDS", "9") + monkeypatch.setattr( + inspect_graph_module, "get_live_schema", lambda: _schema_result() + ) + monkeypatch.setattr( + inspect_graph_module, + "execute_gremlin_read", + Mock(return_value={"data": [1], "total": 1, "duration_ms": 1, "is_read": True}), + ) + captured = [] + + def fake_health_check(*, cfg): + captured.append(cfg) + return {"ok": True, "data": {"status": "available"}, "warnings": []} + + monkeypatch.setattr(inspect_graph_module, "health_check", fake_health_check) + + result = inspect_graph_module.inspect_graph() + + assert result["data"]["hugegraph_ai_status"] == "available" + assert captured[0].user == "alice" + assert captured[0].password == "secret" + assert captured[0].ai_url == "http://ai.example:18001" + assert captured[0].timeout_seconds == 9 + + +def test_inspect_graph_nested_count_result(monkeypatch): + from hugegraph_mcp.tools import inspect_graph as inspect_graph_module + + monkeypatch.setattr( + inspect_graph_module, "get_live_schema", lambda: _schema_result() + ) + execute_read = Mock( + side_effect=[ + { + "data": {"data": [8], "meta": {}}, + "total": 2, + "duration_ms": 1, + "is_read": True, + }, + { + "data": {"data": [5], "meta": {}}, + "total": 2, + "duration_ms": 1, + "is_read": True, + }, + ] + ) + monkeypatch.setattr(inspect_graph_module, "execute_gremlin_read", execute_read) + _patch_ai_available(monkeypatch, inspect_graph_module) + + result = inspect_graph_module.inspect_graph() + + assert result["ok"] is True + assert result["data"]["vertex_count"] == 8 + assert result["data"]["edge_count"] == 5 + assert "Failed to fetch vertex count" not in result["warnings"] + assert "Failed to fetch edge count" not in result["warnings"] + + +def test_inspect_graph_with_raw_schema(monkeypatch): + from hugegraph_mcp.tools import inspect_graph as inspect_graph_module + + schema = _schema_result() + monkeypatch.setattr(inspect_graph_module, "get_live_schema", lambda: schema) + monkeypatch.setattr( + inspect_graph_module, + "execute_gremlin_read", + Mock(return_value={"data": [0], "total": 1, "duration_ms": 1, "is_read": True}), + ) + _patch_ai_available(monkeypatch, inspect_graph_module) + + result = inspect_graph_module.inspect_graph(include_raw_schema=True) + + assert result["ok"] is True + assert result["error"] is None + assert result["data"]["raw_schema"] == schema["schema"] + assert result["data"]["simple_schema"] == schema["simple_schema"] + + +def test_inspect_graph_server_unavailable(monkeypatch): + from hugegraph_mcp.tools import inspect_graph as inspect_graph_module + + monkeypatch.setattr( + inspect_graph_module, + "get_live_schema", + Mock(side_effect=ConnectionError("cannot connect")), + ) + execute_read = Mock() + monkeypatch.setattr(inspect_graph_module, "execute_gremlin_read", execute_read) + _patch_ai_available(monkeypatch, inspect_graph_module) + + result = inspect_graph_module.inspect_graph() + + assert result["ok"] is True + assert result["data"]["hugegraph_server_status"] == "unavailable" + assert result["data"]["schema_summary"] is None + assert result["data"]["vertex_count"] is None + assert result["data"]["edge_count"] is None + assert any("HugeGraph Server is unavailable" in w for w in result["warnings"]) + execute_read.assert_not_called() + + +def test_inspect_graph_ai_unavailable(monkeypatch): + from hugegraph_mcp.tools import inspect_graph as inspect_graph_module + + monkeypatch.setattr( + inspect_graph_module, "get_live_schema", lambda: _schema_result() + ) + monkeypatch.setattr( + inspect_graph_module, + "execute_gremlin_read", + Mock(return_value={"data": [1], "total": 1, "duration_ms": 1, "is_read": True}), + ) + monkeypatch.setattr( + inspect_graph_module, + "health_check", + Mock( + return_value={ + "ok": False, + "error": {"message": "HugeGraph-AI is unavailable: ai down"}, + "warnings": [], + } + ), + ) + + result = inspect_graph_module.inspect_graph() + + assert result["ok"] is True + assert result["data"]["hugegraph_server_status"] == "available" + assert result["data"]["hugegraph_ai_status"] == "unavailable" + assert result["data"]["vid_embedding_status"] == "unknown" + assert any("HugeGraph-AI is unavailable" in w for w in result["warnings"]) + + +def test_inspect_graph_ai_available_when_openapi_fallback_works(monkeypatch): + from hugegraph_mcp.tools import inspect_graph as inspect_graph_module + + monkeypatch.setattr( + inspect_graph_module, "get_live_schema", lambda: _schema_result() + ) + monkeypatch.setattr( + inspect_graph_module, + "execute_gremlin_read", + Mock(return_value={"data": [1], "total": 1, "duration_ms": 1, "is_read": True}), + ) + + monkeypatch.setattr( + inspect_graph_module, + "health_check", + Mock( + return_value={ + "ok": True, + "data": { + "status": "available", + "health_endpoint": "/openapi.json", + "openapi": "3.1.0", + }, + "warnings": [ + "/graph-index-info: HugeGraph-AI graph index info is unavailable" + ], + } + ), + ) + + result = inspect_graph_module.inspect_graph() + + assert result["ok"] is True + assert result["data"]["hugegraph_ai_status"] == "available" + assert result["data"]["vid_embedding_status"] == "unknown" + assert any("graph index info is unavailable" in w for w in result["warnings"]) + + +def test_inspect_graph_requires_explicit_vid_index_status(monkeypatch): + from hugegraph_mcp.tools import inspect_graph as inspect_graph_module + + monkeypatch.setattr( + inspect_graph_module, "get_live_schema", lambda: _schema_result() + ) + monkeypatch.setattr( + inspect_graph_module, + "execute_gremlin_read", + Mock(return_value={"data": [1], "total": 1, "duration_ms": 1, "is_read": True}), + ) + monkeypatch.setattr( + inspect_graph_module, + "health_check", + Mock( + return_value={ + "ok": True, + "data": { + "status": "available", + "health_endpoint": "/graph-index-info", + }, + "warnings": [], + } + ), + ) + + result = inspect_graph_module.inspect_graph() + + assert result["ok"] is True + assert result["data"]["hugegraph_ai_status"] == "available" + assert result["data"]["vid_embedding_status"] == "unknown" + + +def test_inspect_graph_includes_next_actions(monkeypatch): + from hugegraph_mcp.tools import inspect_graph as inspect_graph_module + + monkeypatch.setattr( + inspect_graph_module, "get_live_schema", lambda: _schema_result() + ) + monkeypatch.setattr( + inspect_graph_module, + "execute_gremlin_read", + Mock(return_value={"data": [1], "total": 1, "duration_ms": 1, "is_read": True}), + ) + _patch_ai_available(monkeypatch, inspect_graph_module) + + result = inspect_graph_module.inspect_graph() + + assert result["next_actions"] + assert any( + "inspect_graph_tool with include_raw_schema=true" in action + for action in result["next_actions"] + ) + assert any( + "execute_gremlin_read_tool" in action for action in result["next_actions"] + ) + assert not any("query_graph_tool" in action for action in result["next_actions"]) + + +def test_inspect_graph_readonly_flag(monkeypatch): + from hugegraph_mcp.tools import inspect_graph as inspect_graph_module + + monkeypatch.setattr( + inspect_graph_module, "get_live_schema", lambda: _schema_result(readonly=True) + ) + monkeypatch.setattr( + inspect_graph_module, + "execute_gremlin_read", + Mock(return_value={"data": [1], "total": 1, "duration_ms": 1, "is_read": True}), + ) + _patch_ai_available(monkeypatch, inspect_graph_module) + + result = inspect_graph_module.inspect_graph() + + assert result["data"]["readonly"] is True + assert result["meta"]["readonly"] is True diff --git a/hugegraph-mcp/tests/test_manage_graph_data.py b/hugegraph-mcp/tests/test_manage_graph_data.py new file mode 100644 index 000000000..125aa3625 --- /dev/null +++ b/hugegraph-mcp/tests/test_manage_graph_data.py @@ -0,0 +1,1652 @@ +# 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. + +from copy import deepcopy +import re + +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, + _g, +) + + +def _live_schema(): + return { + "schema": { + "propertykeys": [ + {"name": "name", "data_type": "TEXT"}, + {"name": "age", "data_type": "INT"}, + {"name": "since", "data_type": "INT"}, + ], + "vertexlabels": [ + { + "name": "person", + "properties": ["name", "age"], + "primary_keys": ["name"], + }, + ], + "edgelabels": [ + { + "name": "knows", + "source_label": "person", + "target_label": "person", + "properties": ["since"], + }, + ], + }, + "simple_schema": {"vertices": ["person"], "edges": ["knows"]}, + } + + +def _delete_edge_plan(): + return { + "operations": [ + { + "op": "delete_edge", + "label": "knows", + "source_label": "person", + "source_match": {"name": "Alice"}, + "target_label": "person", + "target_match": {"name": "Bob"}, + } + ] + } + + +def _delete_vertex_plan(): + return { + "operations": [ + { + "op": "delete_vertex", + "label": "person", + "match": {"name": "Alice"}, + } + ] + } + + +def _mock_schema(monkeypatch): + monkeypatch.setattr( + manage_graph_data_module, + "_fetch_live_schema", + lambda: _live_schema(), + ) + + +def _nested_count_result(count): + return { + "data": {"data": [count], "meta": {}}, + "total": 1, + "duration_ms": 1, + "is_read": True, + } + + +def test_validate_graph_change_plan_rejects_unknown_op(): + result = manage_graph_data_module.validate_graph_change_plan( + {"operations": [{"op": "merge_vertex", "label": "person"}]}, + _live_schema(), + ) + + assert result["valid"] is False + assert "unsupported op" in result["errors"][0]["reason"] + + +def test_validate_mode_operations_handles_unknown_mode(): + result = manage_graph_data_module._validate_mode_operations( + "upsert", + {"operations": []}, + ) + + assert result["valid"] is False + assert result["errors"][0]["reason"] == "unknown mode: upsert" + + +def test_validate_mode_operations_rejects_non_object_operation(): + result = manage_graph_data_module._validate_mode_operations( + "import", + {"operations": ["not-an-operation"]}, + ) + + assert result["valid"] is False + assert result["errors"][0]["reason"] == "operation must be an object" + + +def test_gremlin_literal_uses_single_quotes_to_avoid_gstring_interpolation(): + assert _g("${System.exit(0)}") == "'${System.exit(0)}'" + assert _g("Alice's path\\name") == "'Alice\\'s path\\\\name'" + assert _g("line1\nline2\r\n\tend") == "'line1\\nline2\\r\\n\\tend'" + assert _g("bad\u0001char") == "'bad\\u0001char'" + + +def test_validate_delete_vertex_requires_primary_key_match(): + result = manage_graph_data_module.validate_graph_change_plan( + { + "operations": [ + { + "op": "delete_vertex", + "label": "person", + "match": {"age": 31}, + } + ] + }, + _live_schema(), + ) + + assert result["valid"] is False + assert "must contain primary key" in result["errors"][0]["reason"] + + +def test_validate_edge_rejects_unknown_endpoint_label(): + result = manage_graph_data_module.validate_graph_change_plan( + { + "operations": [ + { + "op": "delete_edge", + "label": "knows", + "source_label": "ghost", + "source_match": {"name": "Alice"}, + "target_label": "person", + "target_match": {"name": "Bob"}, + } + ] + }, + _live_schema(), + ) + + assert result["valid"] is False + 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]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + _delete_edge_plan(), + _live_schema(), + ) + + assert result["valid"] is False + assert "delete_edge matched_count must be 1" in result["errors"][0]["reason"] + + +def test_dry_run_delete_edge_returns_preview_and_hash(monkeypatch): + queries = [] + counts = iter([1, 1, 1]) + + def fake_read(query): + queries.append(query) + return { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + } + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, "execute_gremlin_read", fake_read + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + _delete_edge_plan(), + _live_schema(), + ) + + assert result["valid"] is True + assert re.fullmatch(r"[0-9a-f]{32}", result["plan_hash"]) + assert result["preview"][0]["source_matched_count"] == 1 + assert result["preview"][0]["target_matched_count"] == 1 + assert result["preview"][0]["matched_count"] == 1 + assert queries == [ + "g.V().hasLabel('person').has('name','Alice').count()", + "g.V().hasLabel('person').has('name','Bob').count()", + "g.V().hasLabel('person').has('name','Alice').outE('knows').where(inV().hasLabel('person').has('name','Bob')).count()", + ] + + +def test_dry_run_delete_edge_accepts_nested_count_result(monkeypatch): + counts = iter([1, 1, 1]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: _nested_count_result(next(counts)), + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + _delete_edge_plan(), + _live_schema(), + ) + + assert result["valid"] is True + assert result["preview"][0]["source_matched_count"] == 1 + assert result["preview"][0]["target_matched_count"] == 1 + assert result["preview"][0]["matched_count"] == 1 + + +def test_dry_run_delete_edge_rejects_missing_source(monkeypatch): + counts = iter([0, 1]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + _delete_edge_plan(), + _live_schema(), + ) + + assert result["valid"] is False + assert ( + "delete_edge source endpoint matched_count must be 1" + in result["errors"][0]["reason"] + ) + assert "matched_count" not in result["preview"][0] + + +def test_dry_run_delete_edge_rejects_missing_target(monkeypatch): + counts = iter([1, 0]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + _delete_edge_plan(), + _live_schema(), + ) + + assert result["valid"] is False + assert ( + "delete_edge target endpoint matched_count must be 1" + in result["errors"][0]["reason"] + ) + assert "matched_count" not in result["preview"][0] + + +def test_dry_run_delete_edge_rejects_zero_edge_match(monkeypatch): + counts = iter([1, 1, 0]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + _delete_edge_plan(), + _live_schema(), + ) + + assert result["valid"] is False + assert "delete_edge matched_count must be 1, got 0" in result["errors"][0]["reason"] + + +def test_dry_run_delete_vertex_rejects_edges_when_not_cascade(monkeypatch): + counts = iter([1, 3]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + { + "operations": [ + { + "op": "delete_vertex", + "label": "person", + "match": {"name": "Alice"}, + "cascade": False, + } + ] + }, + _live_schema(), + ) + + assert result["valid"] is False + assert "cascade=false" in result["errors"][0]["reason"] + assert result["preview"][0]["associated_edge_count"] == 3 + + +def test_dry_run_delete_vertex_cascade_preview_unwraps_nested_values(monkeypatch): + def fake_read(query): + if query.endswith(".count()"): + return {"data": {"data": [1], "meta": {}}, "duration_ms": 1} + return { + "data": { + "data": [{"id": "edge-1", "label": "knows"}], + "meta": {"ignored": True}, + }, + "duration_ms": 1, + } + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, "execute_gremlin_read", fake_read + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + { + "operations": [ + { + "op": "delete_vertex", + "label": "person", + "match": {"name": "Alice"}, + "cascade": True, + } + ] + }, + _live_schema(), + ) + + assert result["valid"] is False + assert result["preview"][0]["associated_edges"] == [ + {"id": "edge-1", "label": "knows"} + ] + assert result["errors"][0]["error_type"] == "CASCADE_NOT_ENABLED" + + +def test_manage_delete_vertex_returns_blocked_by_relationships(monkeypatch): + _mock_schema(monkeypatch) + counts = iter([1, 2]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + + result = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_vertex_plan(), + ) + + assert result["ok"] is False + assert result["error"]["type"] == "BLOCKED_BY_RELATIONSHIPS" + + +def test_dry_run_delete_vertex_allows_no_edges_when_not_cascade(monkeypatch): + counts = iter([1, 0]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + _delete_vertex_plan(), + _live_schema(), + ) + + assert result["valid"] is True + assert re.fullmatch(r"[0-9a-f]{32}", result["plan_hash"]) + assert result["preview"][0]["matched_count"] == 1 + assert result["preview"][0]["associated_edge_count"] == 0 + + +def test_dry_run_delete_vertex_accepts_nested_count_result(monkeypatch): + counts = iter([1, 0]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: _nested_count_result(next(counts)), + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + _delete_vertex_plan(), + _live_schema(), + ) + + assert result["valid"] is True + assert result["preview"][0]["matched_count"] == 1 + assert result["preview"][0]["associated_edge_count"] == 0 + + +def test_dry_run_delete_vertex_rejects_non_single_match(monkeypatch): + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: {"data": [0], "total": 1, "duration_ms": 1, "is_read": True}, + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + _delete_vertex_plan(), + _live_schema(), + ) + + assert result["valid"] is False + assert "delete_vertex matched_count must be 1" in result["errors"][0]["reason"] + + +def test_manage_graph_data_execute_delete_vertex_verifies_removed(monkeypatch): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + reads = [] + writes = [] + counts = iter([1, 0, 1, 0, 1, 0, 0]) + + def fake_read(query): + reads.append(query) + return { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + } + + def fake_write(query, **_kwargs): + writes.append(query) + return {"success": True, "affected": 1, "duration_ms": 1, "is_write": True} + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, "execute_gremlin_read", fake_read + ) + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, "execute_gremlin_write", fake_write + ) + dry_run = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_vertex_plan(), + ) + + result = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_vertex_plan(), + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=dry_run["data"]["plan_context"]["nonce"], + expires_at=dry_run["data"]["plan_context"]["expires_at"], + ) + + assert result["ok"] is True + assert writes == ["g.V().hasLabel('person').has('name','Alice').drop()"] + assert reads[-1] == "g.V().hasLabel('person').has('name','Alice').count()" + + +def test_manage_graph_data_execute_delete_edge_verifies_removed(monkeypatch): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + reads = [] + writes = [] + counts = iter([1, 1, 1, 1, 1, 1, 1, 1, 1, 0]) + + def fake_read(query): + reads.append(query) + return { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + } + + def fake_write(query, **_kwargs): + writes.append(query) + return {"success": True, "affected": 1, "duration_ms": 1, "is_write": True} + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, "execute_gremlin_read", fake_read + ) + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, "execute_gremlin_write", fake_write + ) + dry_run = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_edge_plan(), + ) + + result = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_edge_plan(), + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=dry_run["data"]["plan_context"]["nonce"], + expires_at=dry_run["data"]["plan_context"]["expires_at"], + ) + + edge_match_query = ( + "g.V().hasLabel('person').has('name','Alice').outE('knows')" + ".where(inV().hasLabel('person').has('name','Bob'))" + ) + assert result["ok"] is True + assert writes == [f"{edge_match_query}.drop()"] + assert reads[-1] == f"{edge_match_query}.count()" + + +def test_manage_graph_data_execute_delete_edge_verify_failure(monkeypatch): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + counts = iter([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query, **_kwargs: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_write", + lambda _query, **_kwargs: { + "success": True, + "affected": 1, + "duration_ms": 1, + "is_write": True, + }, + ) + dry_run = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_edge_plan(), + ) + + result = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_edge_plan(), + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=dry_run["data"]["plan_context"]["nonce"], + expires_at=dry_run["data"]["plan_context"]["expires_at"], + ) + + assert result["ok"] is False + assert result["error"]["type"] == "DELETE_VERIFY_FAILED" + assert result["error"]["details"]["failed_items"][0]["type"] == ( + "DELETE_VERIFY_FAILED" + ) + + +def test_graph_data_to_change_plan_maps_create_operations(): + result = manage_graph_data_module.graph_data_to_change_plan( + { + "vertices": [{"label": "person", "properties": {"name": "Alice"}}], + "edges": [ + { + "label": "knows", + "source_label": "person", + "source": {"name": "Alice"}, + "target_label": "person", + "target": {"name": "Bob"}, + "properties": {"since": 2024}, + } + ], + } + ) + + assert [op["op"] for op in result["operations"]] == ["create_vertex", "create_edge"] + assert result["operations"][1]["source_match"] == {"name": "Alice"} + + +def test_graph_data_to_change_plan_preserves_outv_inv_id_contract(): + result = manage_graph_data_module.graph_data_to_change_plan( + { + "vertices": [ + {"id": "1:Alice", "label": "person", "properties": {"name": "Alice"}}, + {"id": "1:Bob", "label": "person", "properties": {"name": "Bob"}}, + ], + "edges": [ + { + "label": "knows", + "outV": "1:Alice", + "outVLabel": "person", + "inV": "1:Bob", + "inVLabel": "person", + "properties": {"since": 2024}, + } + ], + }, + live_schema=_live_schema(), + ) + + assert result["operations"][0]["id"] == "1:Alice" + assert result["operations"][1]["id"] == "1:Bob" + edge_op = result["operations"][2] + assert edge_op["source_match"] == {"id": "1:Alice"} + assert edge_op["target_match"] == {"id": "1:Bob"} + + +def test_graph_data_to_change_plan_object_id_endpoint_is_id(): + result = manage_graph_data_module.graph_data_to_change_plan( + { + "vertices": [ + {"id": "1:Alice", "label": "person", "properties": {"name": "Alice"}}, + {"id": "1:Bob", "label": "person", "properties": {"name": "Bob"}}, + ], + "edges": [ + { + "label": "knows", + "source_label": "person", + "source": {"id": "1:Alice"}, + "target_label": "person", + "target": {"id": "1:Bob"}, + } + ], + }, + live_schema=_live_schema(), + ) + + edge_op = result["operations"][2] + assert edge_op["source_match"] == {"id": "1:Alice"} + assert edge_op["target_match"] == {"id": "1:Bob"} + + +def test_graph_data_to_change_plan_object_primary_key_endpoint(): + result = manage_graph_data_module.graph_data_to_change_plan( + { + "vertices": [ + {"label": "person", "properties": {"name": "Alice"}}, + {"label": "person", "properties": {"name": "Bob"}}, + ], + "edges": [ + { + "label": "knows", + "source_label": "person", + "source": {"name": "Alice"}, + "target_label": "person", + "target": {"name": "Bob"}, + } + ], + }, + live_schema=_live_schema(), + ) + + edge_op = result["operations"][2] + assert edge_op["source_match"] == {"name": "Alice"} + assert edge_op["target_match"] == {"name": "Bob"} + + +def test_graph_data_to_change_plan_maps_scalar_endpoints_to_single_primary_key(): + result = manage_graph_data_module.graph_data_to_change_plan( + { + "vertices": [ + {"label": "person", "properties": {"name": "Alice"}}, + {"label": "person", "properties": {"name": "Bob"}}, + ], + "edges": [ + { + "label": "knows", + "source_label": "person", + "source": "Alice", + "target_label": "person", + "target": "Bob", + } + ], + }, + live_schema=_live_schema(), + ) + + edge_op = result["operations"][2] + assert edge_op["source_match"] == {"name": "Alice"} + assert edge_op["target_match"] == {"name": "Bob"} + + +def test_graph_data_to_change_plan_maps_edge_only_scalar_to_single_primary_key(): + result = manage_graph_data_module.graph_data_to_change_plan( + { + "vertices": [], + "edges": [ + { + "label": "knows", + "source_label": "person", + "source": "Alice", + "target_label": "person", + "target": "Bob", + } + ], + }, + live_schema=_live_schema(), + ) + + edge_op = result["operations"][0] + assert edge_op["source_match"] == {"name": "Alice"} + assert edge_op["target_match"] == {"name": "Bob"} + + +def test_manage_graph_data_import_uses_primary_key_for_scalar_endpoints(monkeypatch): + _mock_schema(monkeypatch) + reads = [] + + def fake_read(query): + reads.append(query) + return {"data": [0], "total": 1, "duration_ms": 1, "is_read": True} + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + fake_read, + ) + + result = manage_graph_data_module.manage_graph_data( + mode="import", + graph_data={ + "vertices": [ + {"label": "person", "properties": {"name": "Alice"}}, + {"label": "person", "properties": {"name": "Bob"}}, + ], + "edges": [ + { + "label": "knows", + "source_label": "person", + "source": "Alice", + "target_label": "person", + "target": "Bob", + } + ], + }, + ) + + assert result["ok"] is True + assert not any("hasId('Alice')" in query for query in reads) + assert not any("hasId('Bob')" in query for query in reads) + assert reads[-2:] == [ + "g.V().hasLabel('person').has('name','Alice').count()", + "g.V().hasLabel('person').has('name','Bob').count()", + ] + + +def test_manage_graph_data_import_allows_edge_to_existing_vertex(monkeypatch): + _mock_schema(monkeypatch) + counts = iter([0, 0, 1]) + reads = [] + + def fake_read(query): + reads.append(query) + return {"data": [next(counts)], "total": 1, "duration_ms": 1, "is_read": True} + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + fake_read, + ) + + result = manage_graph_data_module.manage_graph_data( + mode="import", + graph_data={ + "vertices": [{"label": "person", "properties": {"name": "Alice"}}], + "edges": [ + { + "label": "knows", + "source_label": "person", + "source": "Alice", + "target_label": "person", + "target": "Bob", + } + ], + }, + ) + + assert result["ok"] is True + preview = result["data"]["preview"][1] + assert preview["source_planned_count"] == 1 + assert preview["source_live_count"] == 0 + assert preview["source_matched_count"] == 1 + assert preview["target_planned_count"] == 0 + assert preview["target_live_count"] == 1 + assert preview["target_matched_count"] == 1 + assert reads[-2:] == [ + "g.V().hasLabel('person').has('name','Alice').count()", + "g.V().hasLabel('person').has('name','Bob').count()", + ] + + +def test_graph_data_to_change_plan_does_not_degrade_numeric_ids_to_properties(): + result = manage_graph_data_module.graph_data_to_change_plan( + { + "vertices": [ + {"id": 123, "label": "person", "properties": {"name": "Alice"}}, + {"id": 456, "label": "person", "properties": {"name": "Bob"}}, + ], + "edges": [ + { + "label": "knows", + "outV": "123", + "outVLabel": "person", + "inV": "456", + "inVLabel": "person", + } + ], + } + ) + + edge_op = result["operations"][2] + assert edge_op["source_match"] == {"id": 123} + assert edge_op["target_match"] == {"id": 456} + + +def test_create_vertex_query_preserves_explicit_id(): + query = _create_vertex_query( + { + "op": "create_vertex", + "label": "person", + "id": "1:Alice", + "properties": {"name": "Alice"}, + } + ) + + assert query == "g.addV('person').property(T.id,'1:Alice').property('name','Alice')" + + +def test_dry_run_create_vertex_rejects_existing_explicit_id(monkeypatch): + read = [] + + def fake_read(query): + read.append(query) + return {"data": [1], "total": 1, "duration_ms": 1, "is_read": True} + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + fake_read, + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + { + "operations": [ + { + "op": "create_vertex", + "label": "person", + "id": "1:Alice", + "properties": {"age": 31}, + } + ] + }, + _live_schema(), + ) + + assert result["valid"] is False + assert result["preview"][0]["id_live_count"] == 1 + assert any( + "create_vertex id identity already exists" in error["reason"] + for error in result["errors"] + ) + assert read == ["g.V().hasLabel('person').hasId('1:Alice').count()"] + + +def test_dry_run_create_vertex_rejects_existing_primary_key(monkeypatch): + read = [] + + def fake_read(query): + read.append(query) + return {"data": [1], "total": 1, "duration_ms": 1, "is_read": True} + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + fake_read, + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + { + "operations": [ + { + "op": "create_vertex", + "label": "person", + "properties": {"name": "Alice"}, + } + ] + }, + _live_schema(), + ) + + assert result["valid"] is False + assert result["preview"][0]["primary_key_live_count"] == 1 + assert any( + "create_vertex primary_key identity already exists" in error["reason"] + for error in result["errors"] + ) + assert read == ["g.V().hasLabel('person').has('name','Alice').count()"] + + +def test_create_edge_query_matches_endpoints_by_id(): + query = _create_edge_query( + { + "op": "create_edge", + "label": "knows", + "source_label": "person", + "source_match": {"id": "1:Alice"}, + "target_label": "person", + "target_match": {"id": "1:Bob"}, + } + ) + + assert ( + query == "g.V().hasLabel('person').hasId('1:Alice').as('s')" + ".V().hasLabel('person').hasId('1:Bob').addE('knows').from('s')" + ) + + +def test_dry_run_create_edge_rejects_non_unique_source(monkeypatch): + counts = iter([2, 1]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + { + "operations": [ + { + "op": "create_edge", + "label": "knows", + "source_label": "person", + "source_match": {"id": "1:Alice"}, + "target_label": "person", + "target_match": {"id": "1:Bob"}, + } + ] + }, + _live_schema(), + ) + + assert result["valid"] is False + assert ( + "create_edge source endpoint matched_count must be 1, got 2" + in (result["errors"][0]["reason"]) + ) + assert result["preview"][0]["source_matched_count"] == 2 + + +def test_dry_run_create_edge_accepts_same_batch_vertex_id_with_live_lookup( + monkeypatch, +): + read = [] + + def fake_read(query): + read.append(query) + return {"data": [0], "total": 1, "duration_ms": 1, "is_read": True} + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + fake_read, + ) + + result = manage_graph_data_module.dry_run_graph_change_plan( + { + "operations": [ + { + "op": "create_vertex", + "label": "person", + "id": "1:Alice", + "properties": {"name": "Alice"}, + }, + { + "op": "create_vertex", + "label": "person", + "id": "1:Bob", + "properties": {"name": "Bob"}, + }, + { + "op": "create_edge", + "label": "knows", + "source_label": "person", + "source_match": {"id": "1:Alice"}, + "target_label": "person", + "target_match": {"id": "1:Bob"}, + }, + ] + }, + _live_schema(), + ) + + assert result["valid"] is True + assert result["preview"][2]["source_matched_count"] == 1 + assert result["preview"][2]["source_planned_count"] == 1 + assert result["preview"][2]["source_live_count"] == 0 + assert result["preview"][2]["target_matched_count"] == 1 + assert result["preview"][2]["target_planned_count"] == 1 + assert result["preview"][2]["target_live_count"] == 0 + assert read == [ + "g.V().hasLabel('person').hasId('1:Alice').count()", + "g.V().hasLabel('person').has('name','Alice').count()", + "g.V().hasLabel('person').hasId('1:Bob').count()", + "g.V().hasLabel('person').has('name','Bob').count()", + "g.V().hasLabel('person').hasId('1:Alice').count()", + "g.V().hasLabel('person').hasId('1:Bob').count()", + ] + + +def test_dry_run_create_edge_normalizes_same_batch_numeric_ids(monkeypatch): + read = [] + + def fake_read(query): + read.append(query) + return {"data": [0], "total": 1, "duration_ms": 1, "is_read": True} + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + fake_read, + ) + + plan = manage_graph_data_module.graph_data_to_change_plan( + { + "vertices": [ + {"id": 123, "label": "person", "properties": {"name": "Alice"}}, + {"id": 456, "label": "person", "properties": {"name": "Bob"}}, + ], + "edges": [ + { + "label": "knows", + "outV": "123", + "outVLabel": "person", + "inV": "456", + "inVLabel": "person", + } + ], + } + ) + + result = manage_graph_data_module.dry_run_graph_change_plan(plan, _live_schema()) + + assert result["valid"] is True + assert result["preview"][2]["source_matched_count"] == 1 + assert result["preview"][2]["target_matched_count"] == 1 + assert len(read) == 6 + + +def test_dry_run_create_edge_rejects_same_batch_endpoint_with_live_duplicate( + monkeypatch, +): + counts = iter([1, 0, 1, 0]) + read = [] + + def fake_read(query): + read.append(query) + return {"data": [next(counts)], "total": 1, "duration_ms": 1, "is_read": True} + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + fake_read, + ) + + result = manage_graph_data_module.dry_run_graph_change_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", + "source_match": {"name": "Alice"}, + "target_label": "person", + "target_match": {"name": "Bob"}, + }, + ] + }, + _live_schema(), + ) + + assert result["valid"] is False + preview = result["preview"][2] + assert preview["source_planned_count"] == 1 + assert preview["source_live_count"] == 1 + assert preview["source_matched_count"] == 2 + assert preview["target_matched_count"] == 1 + assert any( + "create_vertex primary_key identity already exists" in error["reason"] + for error in result["errors"] + ) + assert any( + "create_edge source endpoint matched_count must be 1, got 2" in error["reason"] + for error in result["errors"] + ) + assert len(read) == 4 + + +def test_execute_create_vertex_rechecks_identity_before_write(monkeypatch): + counts = iter([0, 1]) + writes = [] + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + + def fake_write(query, **_kwargs): + writes.append(query) + return {"success": True, "affected": 1, "duration_ms": 1, "is_write": True} + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_write", + fake_write, + ) + + result = manage_graph_data_module.execute_graph_change_plan( + { + "operations": [ + { + "op": "create_vertex", + "label": "person", + "properties": {"name": "Alice"}, + }, + { + "op": "create_vertex", + "label": "person", + "properties": {"name": "Bob"}, + }, + ] + }, + live_schema=_live_schema(), + ) + + assert result["success"] is False + assert result["status"] == "partial" + assert writes == ["g.addV('person').property('name','Alice')"] + assert result["failed_items"][0]["operation_index"] == 1 + assert result["failed_items"][0]["error"]["type"] == "INVALID_GRAPH_DATA" + assert ( + result["failed_items"][0]["error"]["details"]["identity_type"] == "primary_key" + ) + + +def test_execute_create_edge_rejects_zero_affected(monkeypatch): + counts = iter([1, 1]) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_write", + lambda _query, **_kwargs: {"success": True, "affected": 0, "is_write": True}, + ) + + result = manage_graph_data_module.execute_graph_change_plan( + { + "operations": [ + { + "op": "create_edge", + "label": "knows", + "source_label": "person", + "source_match": {"id": "1:Alice"}, + "target_label": "person", + "target_match": {"id": "1:Bob"}, + } + ] + } + ) + + assert result["ok"] is False + assert result["error"]["type"] == "FLOW_EXECUTION_FAILED" + assert "affected 0 element" in result["error"]["message"] + + +def test_graph_data_to_change_plan_preserves_explicit_zero_endpoint_id(): + result = manage_graph_data_module.graph_data_to_change_plan( + { + "vertices": [], + "edges": [ + { + "label": "knows", + "source_label": "person", + "source": 0, + "target_label": "person", + "target": 1, + } + ], + } + ) + + edge_op = result["operations"][0] + assert edge_op["source_match"] == {"id": 0} + assert edge_op["target_match"] == {"id": 1} + + +def test_manage_graph_data_requires_confirm(monkeypatch): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + counts = iter([1, 0]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + + result = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_vertex_plan(), + dry_run=False, + confirm=False, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "CONFIRM_REQUIRED" + + +def test_manage_graph_data_rejects_update_mode(monkeypatch): + _mock_schema(monkeypatch) + + result = manage_graph_data_module.manage_graph_data( + mode="update", + change_plan=_delete_vertex_plan(), + ) + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert "Use 'import' or 'delete'" in result["error"]["message"] + + +def test_manage_graph_data_plan_hash_mismatch(monkeypatch): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + counts = iter([1, 0]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + + result = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_vertex_plan(), + dry_run=False, + confirm=True, + plan_hash="0000000000000000", + nonce="test_nonce", + expires_at=9999999999.0, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "PLAN_HASH_MISMATCH" + + +def test_manage_graph_data_plan_hash_expired(monkeypatch): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + counts = iter([1, 0, 1, 0]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + dry_run = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_vertex_plan(), + ) + + result = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_vertex_plan(), + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=dry_run["data"]["plan_context"]["nonce"], + expires_at=0, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "PLAN_EXPIRED" + + +def test_manage_graph_data_dry_run_returns_plan_hash(monkeypatch): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + counts = iter([1, 0]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + + result = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_vertex_plan(), + ) + + assert result["ok"] is True + assert re.fullmatch(r"[0-9a-f]{32}", result["data"]["plan_hash"]) + assert result["data"]["confirmable"] is True + + +def test_manage_graph_data_readonly_dry_run_warns_plan_must_be_regenerated( + monkeypatch, +): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + counts = iter([1, 0, 1, 0]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + + dry_run = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_vertex_plan(), + ) + + assert dry_run["ok"] is True + assert dry_run["data"]["confirmable"] is False + assert dry_run["data"]["readonly_preview_only"] is True + assert any("preview-only" in warning for warning in dry_run["warnings"]) + assert any("rerun dry_run" in action for action in dry_run["next_actions"]) + + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + result = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_vertex_plan(), + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=dry_run["data"]["plan_context"]["nonce"], + expires_at=dry_run["data"]["plan_context"]["expires_at"], + ) + + assert result["ok"] is False + assert result["error"]["type"] == "PLAN_HASH_MISMATCH" + + +def test_manage_graph_data_plan_hash_schema_field_order_same_hash(): + plan = _delete_vertex_plan() + schema = _live_schema() + reordered_schema = _live_schema() + reordered_schema["schema"]["propertykeys"] = list( + reversed(reordered_schema["schema"]["propertykeys"]) + ) + reordered_schema["schema"]["vertexlabels"][0]["properties"] = ["age", "name"] + reordered_schema["schema"]["vertexlabels"][0]["primaryKeys"] = ["name"] + reordered_schema["schema"]["vertexlabels"][0].pop("primary_keys") + reordered_schema["schema"]["edgelabels"][0]["sourceLabel"] = "person" + reordered_schema["schema"]["edgelabels"][0]["targetLabel"] = "person" + reordered_schema["schema"]["edgelabels"][0].pop("source_label") + reordered_schema["schema"]["edgelabels"][0].pop("target_label") + + first = manage_graph_data_module.calculate_graph_change_plan_hash( + plan, + schema_summary=manage_graph_data_module._schema_summary(schema), + ) + second = manage_graph_data_module.calculate_graph_change_plan_hash( + plan, + schema_summary=manage_graph_data_module._schema_summary(reordered_schema), + ) + + assert first == second + + +def test_manage_graph_data_plan_hash_schema_primary_key_change_different_hash(): + plan = _delete_vertex_plan() + schema = _live_schema() + changed_schema = deepcopy(schema) + changed_schema["schema"]["vertexlabels"][0]["primary_keys"] = ["age"] + + first = manage_graph_data_module.calculate_graph_change_plan_hash( + plan, + schema_summary=manage_graph_data_module._schema_summary(schema), + ) + second = manage_graph_data_module.calculate_graph_change_plan_hash( + plan, + schema_summary=manage_graph_data_module._schema_summary(changed_schema), + ) + + assert first != second + + +def test_manage_graph_data_plan_hash_schema_metadata_ignored_same_hash(): + plan = _delete_vertex_plan() + schema = _live_schema() + schema_with_metadata = deepcopy(schema) + schema_with_metadata["server_time"] = "2026-05-26T00:00:00Z" + schema_with_metadata["schema"]["vertexlabels"][0]["id"] = 99 + schema_with_metadata["schema"]["vertexlabels"][0]["user_data"] = {"x": "y"} + schema_with_metadata["simple_schema"] = {"unrelated": ["metadata"]} + + first = manage_graph_data_module.calculate_graph_change_plan_hash( + plan, + schema_summary=manage_graph_data_module._schema_summary(schema), + ) + second = manage_graph_data_module.calculate_graph_change_plan_hash( + plan, + schema_summary=manage_graph_data_module._schema_summary(schema_with_metadata), + ) + + assert first == second + + +def test_manage_graph_data_readonly_rejects_execution(monkeypatch): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + writes = [] + counts = iter([1, 0, 1, 0]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_write", + lambda query, **_kwargs: writes.append(query), + ) + dry_run = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_vertex_plan(), + ) + + result = manage_graph_data_module.manage_graph_data( + mode="delete", + change_plan=_delete_vertex_plan(), + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=dry_run["data"]["plan_context"]["nonce"], + expires_at=dry_run["data"]["plan_context"]["expires_at"], + ) + + assert result["ok"] is False + assert result["error"]["type"] == "READONLY_VIOLATION" + assert writes == [] + + +def test_manage_graph_data_partial_write_returns_error_envelope(monkeypatch): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + + graph_data = { + "vertices": [ + {"label": "person", "properties": {"name": "Alice"}}, + {"label": "person", "properties": {"name": "Bob"}}, + ], + "edges": [], + } + writes = [] + counts = iter([0, 0, 0, 0, 0, 0]) + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: { + "data": [next(counts)], + "total": 1, + "duration_ms": 1, + "is_read": True, + }, + ) + + def fake_write(query, **_kwargs): + writes.append(query) + if len(writes) == 1: + return {"success": True, "affected": 1, "duration_ms": 1, "is_write": True} + return { + "success": False, + "error_type": "connection_error", + "message": "write failed", + } + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, "execute_gremlin_write", fake_write + ) + + dry_run = manage_graph_data_module.manage_graph_data( + mode="import", + graph_data=graph_data, + ) + result = manage_graph_data_module.manage_graph_data( + mode="import", + graph_data=graph_data, + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=dry_run["data"]["plan_context"]["nonce"], + expires_at=dry_run["data"]["plan_context"]["expires_at"], + ) + + assert result["ok"] is False + assert result["error"]["type"] == "FLOW_EXECUTION_FAILED" + details = result["error"]["details"] + assert details["status"] == "partial" + assert details["success"] is False + assert details["planned"] == {"create_vertex": 2} + assert details["written"] == {"create_vertex": 1} + assert details["failed_items"][0]["operation_index"] == 1 + assert details["failed_items"][0]["op"] == "create_vertex" + assert len(writes) == 2 + + +def test_manage_graph_data_import_validates_graph_payload(monkeypatch): + _mock_schema(monkeypatch) + + result = manage_graph_data_module.manage_graph_data( + mode="import", + graph_data={ + "vertices": [{"label": "person", "properties": {"age": 31}}], + "edges": [], + }, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "SCHEMA_MISMATCH" diff --git a/hugegraph-mcp/tests/test_manage_schema.py b/hugegraph-mcp/tests/test_manage_schema.py new file mode 100644 index 000000000..e740282d3 --- /dev/null +++ b/hugegraph-mcp/tests/test_manage_schema.py @@ -0,0 +1,651 @@ +# 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. + +import re + +from hugegraph_mcp.tools import manage_schema as manage_schema_module +from hugegraph_mcp.tools.manage_schema import manage_schema + + +def _empty_schema(): + return { + "schema": { + "propertykeys": [], + "vertexlabels": [], + "edgelabels": [], + "indexlabels": [], + }, + "simple_schema": {}, + "readonly": False, + } + + +def _schema( + *, + propertykeys=None, + vertexlabels=None, + edgelabels=None, + indexlabels=None, +): + return { + "schema": { + "propertykeys": propertykeys or [], + "vertexlabels": vertexlabels or [], + "edgelabels": edgelabels or [], + "indexlabels": indexlabels or [], + }, + "simple_schema": {}, + "readonly": False, + } + + +def _property_key(name="age"): + return {"type": "create_property_key", "name": name, "data_type": "INT"} + + +def _vertex_label(name="person", properties=None, primary_keys=None): + operation = {"type": "create_vertex_label", "name": name} + if properties is not None: + operation["properties"] = properties + if primary_keys is not None: + operation["primary_keys"] = primary_keys + return operation + + +def _edge_label( + name="knows", source_label="person", target_label="person", properties=None +): + operation = { + "type": "create_edge_label", + "name": name, + "source_label": source_label, + "target_label": target_label, + } + if properties is not None: + operation["properties"] = properties + return operation + + +def _index_label( + name="personByAge", base_type="VERTEX", base_label="person", fields=None +): + operation = { + "type": "create_index_label", + "name": name, + "base_type": base_type, + "base_label": base_label, + } + if fields is not None: + operation["fields"] = fields + return operation + + +def _live_pk(name): + return {"name": name, "data_type": "TEXT"} + + +def _live_vertex(name, properties=None): + return {"name": name, "properties": properties or []} + + +def _live_edge(name, source_label="person", target_label="software"): + return { + "name": name, + "source_label": source_label, + "target_label": target_label, + } + + +def test_manage_schema_design(): + result = manage_schema( + mode="design", + operations=[ + { + "thought": "Need a graph for users", + "thought_number": 2, + "total_thoughts": 5, + "next_thought_needed": True, + } + ], + ) + + assert result["ok"] is True + assert result["data"]["thought_number"] == 2 + assert result["data"]["total_thoughts"] == 5 + assert result["data"]["next_thought_needed"] is True + + +def test_manage_schema_design_does_not_fetch_live_schema(monkeypatch): + def raise_connection_error(): + raise Exception("connection refused") + + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", raise_connection_error + ) + + result = manage_schema( + mode="design", + operations=[ + { + "thought": "Need a graph for users", + "thought_number": 1, + "total_thoughts": 4, + "next_thought_needed": True, + } + ], + ) + + assert result["ok"] is True + + +def test_manage_schema_validate_valid(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema(mode="validate", operations=[_property_key()]) + + assert result["ok"] is True + assert result["data"]["valid"] is True + assert result["data"]["errors"] == [] + + +def test_manage_schema_validate_returns_connection_failed_when_schema_unreachable( + monkeypatch, +): + def raise_connection_error(): + raise Exception("connection refused") + + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", raise_connection_error + ) + + result = manage_schema(mode="validate", operations=[_property_key()]) + + assert result["ok"] is False + assert result["error"]["type"] == "CONNECTION_FAILED" + assert result["error"]["retryable"] is True + assert result["error"]["details"]["stage"] == "schema_fetch" + + +def test_manage_schema_validate_invalid_missing_name(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="validate", + operations=[{"type": "create_property_key", "data_type": "TEXT"}], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + assert result["data"]["errors"][0]["operation_index"] == 0 + assert "missing required field: name" in result["data"]["errors"][0]["reason"] + + +def test_manage_schema_validate_rejects_delete(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="validate", + operations=[{"type": "delete_vertex_label", "name": "person"}], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + assert "unsupported delete/drop type" in result["data"]["errors"][0]["reason"] + + +def test_manage_schema_validate_rejects_unknown_property_key(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(propertykeys=[_live_pk("name")]), + ) + + result = manage_schema( + mode="validate", + operations=[ + { + "type": "create_vertex_label", + "name": "person", + "properties": ["name", "age"], + "primary_keys": ["name"], + } + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + error = result["data"]["errors"][0] + assert error["operation_index"] == 0 + assert "undefined property key" in error["reason"] + assert "age" in error["reason"] + + +def test_manage_schema_validate_rejects_unknown_edge_endpoint(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(vertexlabels=[_live_vertex("person")]), + ) + + result = manage_schema( + mode="validate", + operations=[ + { + "type": "create_edge_label", + "name": "created", + "source_label": "person", + "target_label": "software", + } + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + error = result["data"]["errors"][0] + assert error["operation_index"] == 0 + assert "target_label references undefined vertex label: software" == error["reason"] + + +def test_manage_schema_validate_rejects_duplicate_vertex_label(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(vertexlabels=[_live_vertex("person")]), + ) + + result = manage_schema( + mode="validate", + operations=[{"type": "create_vertex_label", "name": "person"}], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + error = result["data"]["errors"][0] + assert error["operation_index"] == 0 + assert error["reason"] == "vertex label already exists: person" + + +def test_manage_schema_validate_accepts_semantically_valid_operations(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema( + propertykeys=[_live_pk("name")], + vertexlabels=[_live_vertex("person")], + edgelabels=[_live_edge("created")], + ), + ) + + result = manage_schema( + mode="validate", + operations=[ + { + "type": "create_index_label", + "name": "personByName", + "base_type": "VERTEX", + "base_label": "person", + "fields": ["name"], + } + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is True + assert result["data"]["errors"] == [] + + +def test_manage_schema_dry_run_returns_connection_failed_when_schema_unreachable( + monkeypatch, +): + def raise_connection_error(): + raise Exception("connection refused") + + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", raise_connection_error + ) + + result = manage_schema(mode="dry_run", operations=[_property_key()]) + + assert result["ok"] is False + assert result["error"]["type"] == "CONNECTION_FAILED" + assert result["error"]["retryable"] is True + assert result["error"]["details"]["stage"] == "schema_fetch" + + +def test_same_batch_pk_to_vertex_label(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="validate", + operations=[ + _property_key("age"), + _vertex_label("person", properties=["age"], primary_keys=["age"]), + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is True + assert result["data"]["errors"] == [] + + +def test_same_batch_vertex_to_edge_label(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="validate", + operations=[ + _vertex_label("person"), + _vertex_label("software"), + _edge_label("created", source_label="person", target_label="software"), + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is True + assert result["data"]["errors"] == [] + + +def test_same_batch_label_to_index(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="validate", + operations=[ + _property_key("name"), + _vertex_label("person", properties=["name"], primary_keys=["name"]), + _index_label("personByName", base_label="person", fields=["name"]), + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is True + assert result["data"]["errors"] == [] + + +def test_same_batch_full_chain(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="validate", + operations=[ + _property_key("name"), + _property_key("weight"), + _vertex_label("person", properties=["name"], primary_keys=["name"]), + _vertex_label("software", properties=["name"], primary_keys=["name"]), + _edge_label( + "created", + source_label="person", + target_label="software", + properties=["weight"], + ), + _index_label( + "createdByWeight", + base_type="EDGE", + base_label="created", + fields=["weight"], + ), + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is True + assert result["data"]["errors"] == [] + + +def test_same_batch_unknown_reference(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="validate", + operations=[_vertex_label("person", properties=["missing"])], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + error = result["data"]["errors"][0] + assert error["operation_index"] == 0 + assert "undefined property key" in error["reason"] + assert "missing" in error["reason"] + + +def test_same_batch_duplicate_definition(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="validate", + operations=[_property_key("age"), _property_key("age")], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + error = result["data"]["errors"][0] + assert error["operation_index"] == 1 + assert error["reason"] == ( + "duplicate create_property_key name age within the same batch" + ) + + +def test_same_batch_edge_missing_endpoint(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="validate", + operations=[ + _vertex_label("person"), + _edge_label("created", source_label="person", target_label="software"), + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + error = result["data"]["errors"][0] + assert error["operation_index"] == 1 + assert error["reason"] == "target_label references undefined vertex label: software" + + +def test_manage_schema_dry_run(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema(mode="dry_run", operations=[_property_key()]) + + assert result["ok"] is True + assert result["data"]["valid"] is True + assert re.fullmatch(r"[0-9a-f]{32}", result["data"]["plan_hash"]) + assert "mutation_summary" in result["data"] + assert isinstance(result["data"]["warnings"], list) + + +def test_manage_schema_dry_run_invalid_schema_has_no_plan_hash(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(propertykeys=[_live_pk("name")]), + ) + + result = manage_schema( + mode="dry_run", + operations=[ + { + "type": "create_vertex_label", + "name": "person", + "properties": ["age"], + } + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + assert "plan_hash" not in result["data"] + + +def test_manage_schema_dry_run_same_ops_same_hash(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + first = manage_schema(mode="dry_run", operations=[_property_key()]) + second = manage_schema(mode="dry_run", operations=[_property_key()]) + + assert first["data"]["plan_hash"] == second["data"]["plan_hash"] + + +def test_manage_schema_dry_run_different_ops_different_hash(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + first = manage_schema(mode="dry_run", operations=[_property_key("age")]) + second = manage_schema(mode="dry_run", operations=[_property_key("score")]) + + assert first["data"]["plan_hash"] != second["data"]["plan_hash"] + + +def test_manage_schema_plan_hash_schema_field_order_same_hash(): + operations = [_property_key()] + schema = _schema( + propertykeys=[ + {"name": "name", "data_type": "TEXT"}, + {"name": "age", "data_type": "INT"}, + ], + vertexlabels=[ + { + "name": "person", + "properties": [{"name": "name"}, {"name": "age"}], + "primary_keys": ["name"], + }, + ], + edgelabels=[ + {"name": "knows", "source_label": "person", "target_label": "person"}, + ], + ) + reordered_schema = _schema( + propertykeys=[ + {"name": "age", "data_type": "INT"}, + {"name": "name", "data_type": "TEXT"}, + ], + vertexlabels=[ + { + "name": "person", + "properties": [{"name": "age"}, {"name": "name"}], + "primaryKeys": ["name"], + }, + ], + edgelabels=[ + {"name": "knows", "sourceLabel": "person", "targetLabel": "person"}, + ], + ) + + first = manage_schema_module.calculate_plan_hash(operations, schema) + second = manage_schema_module.calculate_plan_hash(operations, reordered_schema) + + assert first == second + + +def test_manage_schema_plan_hash_schema_primary_key_change_different_hash(): + operations = [_property_key()] + schema = _schema( + vertexlabels=[ + {"name": "person", "properties": ["name", "age"], "primary_keys": ["name"]}, + ], + ) + changed_schema = _schema( + vertexlabels=[ + {"name": "person", "properties": ["name", "age"], "primary_keys": ["age"]}, + ], + ) + + first = manage_schema_module.calculate_plan_hash(operations, schema) + second = manage_schema_module.calculate_plan_hash(operations, changed_schema) + + assert first != second + + +def test_manage_schema_plan_hash_schema_metadata_ignored_same_hash(): + operations = [_property_key()] + schema = _schema( + propertykeys=[{"name": "name", "data_type": "TEXT"}], + vertexlabels=[ + {"name": "person", "properties": ["name"], "primary_keys": ["name"]} + ], + ) + schema_with_metadata = _schema( + propertykeys=[ + { + "id": 1, + "name": "name", + "data_type": "TEXT", + "user_data": {"x": "y"}, + } + ], + vertexlabels=[ + { + "id": 99, + "name": "person", + "properties": ["name"], + "primary_keys": ["name"], + "user_data": {"x": "y"}, + } + ], + ) + schema_with_metadata["server_time"] = "2026-05-26T00:00:00Z" + + first = manage_schema_module.calculate_plan_hash(operations, schema) + second = manage_schema_module.calculate_plan_hash(operations, schema_with_metadata) + + assert first == second + + +def test_manage_schema_plan_hash_operation_order_different_hash(): + schema = _empty_schema() + operations = [_property_key("age"), _property_key("score")] + reordered_operations = [_property_key("score"), _property_key("age")] + + first = manage_schema_module.calculate_plan_hash(operations, schema) + second = manage_schema_module.calculate_plan_hash(reordered_operations, schema) + + assert first != second + + +def test_manage_schema_apply_is_not_an_internal_v1_mode(): + result = manage_schema( + mode="apply", + operations=[_property_key()], + confirm=True, + plan_hash="0000000000000000", + ) + + assert result["ok"] is False + assert result["error"]["type"] == "SCHEMA_MISMATCH" + assert "Unsupported manage_schema mode: apply" in result["error"]["message"] diff --git a/hugegraph-mcp/tests/test_plan_hash.py b/hugegraph-mcp/tests/test_plan_hash.py new file mode 100644 index 000000000..26a497141 --- /dev/null +++ b/hugegraph-mcp/tests/test_plan_hash.py @@ -0,0 +1,358 @@ +# 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. + +"""Tests for plan_hash module (Milestone 4).""" + +from hugegraph_mcp.envelope import ErrorType +from hugegraph_mcp.plan_hash import ( + PlanContext, + build_plan_context, + compute_payload_digest, + compute_plan_hash, + verify_plan_hash, +) + + +def test_plan_hash_changes_when_graph_url_changes(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_URL", "http://server-a:8080") + ctx_a, hash_a = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123" + ) + + monkeypatch.setenv("HUGEGRAPH_URL", "http://server-b:8080") + ctx_b, hash_b = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123" + ) + + assert hash_a != hash_b + + +def test_plan_hash_changes_when_graph_name_changes(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_GRAPH", "graph_a") + _, hash_a = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123" + ) + + monkeypatch.setenv("HUGEGRAPH_GRAPH", "graph_b") + _, hash_b = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123" + ) + + assert hash_a != hash_b + assert len(hash_a) == 32 + + +def test_plan_hash_changes_when_graphspace_changes(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_GRAPHSPACE", "space_a") + _, hash_a = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123" + ) + + monkeypatch.setenv("HUGEGRAPH_GRAPHSPACE", "space_b") + _, hash_b = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123" + ) + + assert hash_a != hash_b + + +def test_plan_hash_changes_when_principal_changes(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_USER", "alice") + _, hash_a = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123" + ) + + monkeypatch.setenv("HUGEGRAPH_USER", "bob") + _, hash_b = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123" + ) + + assert hash_a != hash_b + + +def test_plan_hash_changes_when_readonly_changes(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + _, hash_a = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123" + ) + + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + _, hash_b = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123" + ) + + assert hash_a != hash_b + + +def test_plan_hash_changes_when_payload_changes(monkeypatch): + _, hash_a = build_plan_context( + tool_name="test", mode="import", payload_digest="aaa" + ) + + _, hash_b = build_plan_context( + tool_name="test", mode="import", payload_digest="bbb" + ) + + assert hash_a != hash_b + + +def test_plan_hash_changes_when_tool_name_changes(): + context = PlanContext( + tool_name="import_graph_data_tool", + mode="import", + graph_url="http://test:8080", + graph_name="testgraph", + graphspace="DEFAULT", + principal="testuser", + readonly=True, + payload_digest="abc", + schema_hash="schema", + nonce="mynonce", + expires_at=1000, + ) + other_tool_context = PlanContext( + **{**context.__dict__, "tool_name": "delete_graph_data_tool"} + ) + + assert compute_plan_hash(context) != compute_plan_hash(other_tool_context) + + +def test_plan_hash_changes_when_schema_hash_changes(monkeypatch): + _, hash_a = build_plan_context( + tool_name="test", mode="import", payload_digest="abc", schema_hash="schema1" + ) + + _, hash_b = build_plan_context( + tool_name="test", mode="import", payload_digest="abc", schema_hash="schema2" + ) + + assert hash_a != hash_b + + +def test_plan_hash_changes_when_extra_context_changes(): + context = PlanContext( + tool_name="test", + mode="import", + graph_url="http://test:8080", + graph_name="testgraph", + graphspace="DEFAULT", + principal="testuser", + readonly=True, + payload_digest="abc", + schema_hash="schema", + nonce="mynonce", + expires_at=1000, + extra_context={"target": "import"}, + ) + other_context = PlanContext( + **{**context.__dict__, "extra_context": {"target": "delete"}} + ) + + assert compute_plan_hash(context) != compute_plan_hash(other_context) + + +def test_plan_hash_changes_when_expires_at_changes(): + context = PlanContext( + tool_name="test", + mode="import", + graph_url="http://test:8080", + graph_name="testgraph", + graphspace="DEFAULT", + principal="testuser", + readonly=True, + payload_digest="abc", + schema_hash="schema", + nonce="mynonce", + expires_at=1000, + ) + extended_context = PlanContext(**{**context.__dict__, "expires_at": 2000}) + + assert compute_plan_hash(context) != compute_plan_hash(extended_context) + + +def test_verify_plan_hash_accepts_matching_hash(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_URL", "http://test:8080") + monkeypatch.setenv("HUGEGRAPH_GRAPH", "testgraph") + monkeypatch.setenv("HUGEGRAPH_USER", "testuser") + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + + context, plan_hash = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123", nonce="mynonce" + ) + + valid, error_type, details = verify_plan_hash( + submitted_hash=plan_hash, + tool_name="test", + mode="import", + payload_digest="abc123", + nonce="mynonce", + expires_at=context.expires_at, + ) + + assert valid is True + assert error_type is None + + +def test_verify_plan_hash_rejects_mismatched_hash(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_URL", "http://test:8080") + context, _ = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123", nonce="mynonce" + ) + + valid, error_type, details = verify_plan_hash( + submitted_hash="wrong_hash", + tool_name="test", + mode="import", + payload_digest="abc123", + nonce="mynonce", + expires_at=context.expires_at, + ) + + assert valid is False + assert error_type == ErrorType.PLAN_HASH_MISMATCH + + +def test_verify_plan_hash_rejects_mismatched_tool_name(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_URL", "http://test:8080") + context, plan_hash = build_plan_context( + tool_name="import_graph_data_tool", + mode="import", + payload_digest="abc123", + nonce="mynonce", + ) + + valid, error_type, details = verify_plan_hash( + submitted_hash=plan_hash, + tool_name="delete_graph_data_tool", + mode="import", + payload_digest="abc123", + nonce="mynonce", + expires_at=context.expires_at, + ) + + assert valid is False + assert error_type == ErrorType.PLAN_HASH_MISMATCH + + +def test_verify_plan_hash_rejects_mismatched_extra_context(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_URL", "http://test:8080") + context, plan_hash = build_plan_context( + tool_name="test", + mode="import", + payload_digest="abc123", + nonce="mynonce", + extra_context={"plan_tool_name": "import_graph_data_tool"}, + ) + + valid, error_type, details = verify_plan_hash( + submitted_hash=plan_hash, + tool_name="test", + mode="import", + payload_digest="abc123", + nonce="mynonce", + expires_at=context.expires_at, + extra_context={"plan_tool_name": "delete_graph_data_tool"}, + ) + + assert valid is False + assert error_type == ErrorType.PLAN_HASH_MISMATCH + + +def test_verify_plan_hash_rejects_missing_nonce(monkeypatch): + valid, error_type, details = verify_plan_hash( + submitted_hash="any_hash", + tool_name="test", + mode="import", + payload_digest="abc123", + nonce=None, + ) + + assert valid is False + assert error_type == ErrorType.PLAN_HASH_MISMATCH + + +def test_verify_plan_hash_rejects_missing_expires_at(monkeypatch): + context, plan_hash = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123", nonce="mynonce" + ) + + valid, error_type, details = verify_plan_hash( + submitted_hash=plan_hash, + tool_name="test", + mode="import", + payload_digest="abc123", + nonce=context.nonce, + expires_at=None, + ) + + assert valid is False + assert error_type == ErrorType.PLAN_EXPIRED + + +def test_compute_payload_digest_is_stable(): + d1 = compute_payload_digest({"a": 1, "b": 2}) + d2 = compute_payload_digest({"b": 2, "a": 1}) + + assert d1 == d2 + assert len(d1) == 32 + + +def test_plan_context_is_frozen(): + context, _ = build_plan_context( + tool_name="test", mode="import", payload_digest="abc" + ) + + try: + context.tool_name = "other" + assert False, "Should be frozen" + except AttributeError: + pass + + +def test_verify_plan_hash_rejects_expired_plan(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_URL", "http://test:8080") + + context, plan_hash = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123", nonce="mynonce" + ) + + # Set expires_at to the past + valid, error_type, details = verify_plan_hash( + submitted_hash=plan_hash, + tool_name="test", + mode="import", + payload_digest="abc123", + nonce="mynonce", + expires_at=0.0, # expired long ago + ) + + assert valid is False + assert error_type == ErrorType.PLAN_EXPIRED + + +def test_verify_plan_hash_rejects_extended_expires_at(monkeypatch): + context, plan_hash = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123", nonce="mynonce" + ) + + valid, error_type, details = verify_plan_hash( + submitted_hash=plan_hash, + tool_name="test", + mode="import", + payload_digest="abc123", + nonce="mynonce", + expires_at=context.expires_at + 600, + ) + + assert valid is False + assert error_type == ErrorType.PLAN_HASH_MISMATCH diff --git a/hugegraph-mcp/tests/test_readonly_mode.py b/hugegraph-mcp/tests/test_readonly_mode.py new file mode 100644 index 000000000..4a18b09b1 --- /dev/null +++ b/hugegraph-mcp/tests/test_readonly_mode.py @@ -0,0 +1,123 @@ +# 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. + +"""Test that HUGEGRAPH_MCP_READONLY properly disables write tools.""" + +import asyncio +import importlib +import os +from unittest.mock import patch + + +def get_registered_tools(): + """Helper to get registered MCP tool names from a running server instance.""" + import hugegraph_mcp.server + + async def _get_tools(): + list_tools = getattr(hugegraph_mcp.server.mcp, "_mcp_list_tools", None) + if list_tools is None: + list_tools = getattr(hugegraph_mcp.server.mcp, "_list_tools") + tools = await list_tools() + return [t.name for t in tools] + + return asyncio.run(_get_tools()) + + +def test_readonly_env_parsing(): + """Test that various readonly env values are parsed correctly.""" + test_cases = [ + ("true", True), + ("1", True), + ("yes", True), + ("TRUE", True), + ("True", True), + ("false", False), + ("0", False), + ("no", False), + ("", False), + ("invalid", False), + ] + + for env_value, expected in test_cases: + with patch.dict(os.environ, {"HUGEGRAPH_MCP_READONLY": env_value}, clear=True): + # Import and check READONLY value + import importlib + + import hugegraph_mcp.server + + importlib.reload(hugegraph_mcp.server) + + assert expected == hugegraph_mcp.server.READONLY + + +def test_write_tools_available_when_not_readonly(monkeypatch): + """Test that all V1 tools are registered when not in readonly mode.""" + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + + import hugegraph_mcp.server + + importlib.reload(hugegraph_mcp.server) + tools = get_registered_tools() + # V1 stable tools + assert "inspect_graph_tool" in tools + assert "generate_gremlin_tool" in tools + assert "execute_gremlin_read_tool" in tools + assert "extract_graph_data_tool" in tools + assert "design_schema_tool" in tools + assert "apply_schema_tool" in tools + assert "import_graph_data_tool" in tools + assert "delete_graph_data_tool" in tools + assert "query_graph_tool" not in tools + assert "manage_schema_tool" not in tools + assert "manage_graph_data_tool" not in tools + # Admin-gated debug tools + assert "execute_gremlin_write_tool" in tools + assert "refresh_vid_embeddings_tool" in tools + assert len(tools) == 10 + + +def test_write_tools_disabled_when_readonly(monkeypatch): + """Test that all V1 tools remain registered in readonly mode.""" + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + + import hugegraph_mcp.server + + importlib.reload(hugegraph_mcp.server) + tools = get_registered_tools() + # All tools are registered; readonly blocks execution, not registration + assert "inspect_graph_tool" in tools + assert "generate_gremlin_tool" in tools + assert "execute_gremlin_read_tool" in tools + assert "extract_graph_data_tool" in tools + assert "design_schema_tool" in tools + assert "apply_schema_tool" in tools + assert "import_graph_data_tool" in tools + assert "delete_graph_data_tool" in tools + assert "query_graph_tool" not in tools + assert "manage_schema_tool" not in tools + assert "manage_graph_data_tool" not in tools + assert "execute_gremlin_write_tool" in tools + assert "refresh_vid_embeddings_tool" in tools + assert len(tools) == 10 + + +def test_readonly_mode_default(monkeypatch): + """Test that default mode is readonly when env is not set (V1 safe default).""" + monkeypatch.delenv("HUGEGRAPH_MCP_READONLY", raising=False) + + import hugegraph_mcp.server + + importlib.reload(hugegraph_mcp.server) + assert hugegraph_mcp.server.READONLY is True + tools = get_registered_tools() + assert len(tools) == 10 diff --git a/hugegraph-mcp/tests/test_refresh_vid_embeddings.py b/hugegraph-mcp/tests/test_refresh_vid_embeddings.py new file mode 100644 index 000000000..74fd72c85 --- /dev/null +++ b/hugegraph-mcp/tests/test_refresh_vid_embeddings.py @@ -0,0 +1,69 @@ +# 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. + +from unittest.mock import Mock + +from hugegraph_mcp.envelope import envelope_ok +from hugegraph_mcp.tools import refresh_vid_embeddings as refresh_vid_embeddings_module + + +def test_refresh_vid_embeddings_success(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + post = Mock( + return_value=envelope_ok( + {"ok": True, "data": {"added": 3, "removed": 1, "summary": "done"}} + ) + ) + monkeypatch.setattr(refresh_vid_embeddings_module, "post", post) + + result = refresh_vid_embeddings_module.refresh_vid_embeddings(confirm=True) + + assert result["ok"] is True + assert result["data"] == {"added": 3, "removed": 1, "summary": "done"} + post.assert_called_once_with("/vid-embeddings/refresh", json={}) + + +def test_refresh_vid_embeddings_parses_remove_variants(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + post = Mock(return_value=envelope_ok("Remove 2 vectors, add 3 vectors.")) + monkeypatch.setattr(refresh_vid_embeddings_module, "post", post) + + result = refresh_vid_embeddings_module.refresh_vid_embeddings(confirm=True) + + assert result["ok"] is True + assert result["data"]["added"] == 3 + assert result["data"]["removed"] == 2 + + +def test_refresh_vid_embeddings_readonly(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + post = Mock() + monkeypatch.setattr(refresh_vid_embeddings_module, "post", post) + + result = refresh_vid_embeddings_module.refresh_vid_embeddings(confirm=True) + + assert result["ok"] is False + assert result["error"]["type"] == "READONLY_VIOLATION" + post.assert_not_called() + + +def test_refresh_vid_embeddings_missing_confirm(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + post = Mock() + monkeypatch.setattr(refresh_vid_embeddings_module, "post", post) + + result = refresh_vid_embeddings_module.refresh_vid_embeddings(confirm=False) + + assert result["ok"] is False + assert result["error"]["type"] == "CONFIRM_REQUIRED" + post.assert_not_called() diff --git a/hugegraph-mcp/tests/test_schema_utils.py b/hugegraph-mcp/tests/test_schema_utils.py new file mode 100644 index 000000000..bbb7253fc --- /dev/null +++ b/hugegraph-mcp/tests/test_schema_utils.py @@ -0,0 +1,100 @@ +# 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. + +import inspect + +from hugegraph_mcp.tools import graph_data_validate +from hugegraph_mcp.tools.schema_utils import normalized_schema_summary, schema_payload + + +def test_normalized_schema_summary_uses_shared_canonical_shape(): + schema = { + "schema": { + "propertykeys": [ + {"name": "name", "dataType": "TEXT", "id": 1}, + {"name": "rank", "data_type": "INT", "cardinality": "SINGLE"}, + ], + "vertexlabels": [ + { + "name": "city", + "properties": [{"name": "name"}, "rank"], + "primaryKeys": [{"name": "name"}], + "nullableKeys": ["rank"], + "id": 2, + } + ], + "edgelabels": [ + { + "name": "knows", + "sourceLabel": "person", + "target_label": "person", + "properties": [{"name": "rank"}], + "frequency": "SINGLE", + } + ], + "indexlabels": [ + { + "name": "cityByName", + "baseType": "VERTEX_LABEL", + "base_label": "city", + "indexType": "SECONDARY", + "fields": [{"name": "name"}], + "status": "CREATED", + } + ], + } + } + + assert normalized_schema_summary(schema) == { + "propertykeys": [ + {"name": "name", "data_type": "TEXT"}, + {"name": "rank", "data_type": "INT", "cardinality": "SINGLE"}, + ], + "vertexlabels": [ + { + "name": "city", + "properties": ["name", "rank"], + "primary_keys": ["name"], + "nullable_keys": ["rank"], + } + ], + "edgelabels": [ + { + "name": "knows", + "source_label": "person", + "target_label": "person", + "properties": ["rank"], + "frequency": "SINGLE", + } + ], + "indexlabels": [ + { + "name": "cityByName", + "base_type": "VERTEX_LABEL", + "base_label": "city", + "index_type": "SECONDARY", + "fields": ["name"], + } + ], + } + + +def test_schema_payload_preserves_explicit_empty_schema(): + assert schema_payload({"schema": {}}) == {} + + +def test_graph_data_validate_does_not_reverse_import_ingest_module(): + source = inspect.getsource(graph_data_validate) + + assert "import ingest_graph_data" not in source + assert "tools.ingest_graph_data" not in source diff --git a/hugegraph-mcp/tests/test_v1_stable_tools.py b/hugegraph-mcp/tests/test_v1_stable_tools.py new file mode 100644 index 000000000..f74c7a0b0 --- /dev/null +++ b/hugegraph-mcp/tests/test_v1_stable_tools.py @@ -0,0 +1,383 @@ +# 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. + +"""Tests for V1 stable tools and admin gate (Milestone 2).""" + +import asyncio +import logging +from unittest.mock import Mock +import warnings + +from hugegraph_mcp import server +from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok +from hugegraph_mcp.guard import Capability + + +async def _list_mcp_tools(): + list_tools = getattr(server.mcp, "_mcp_list_tools", None) + if list_tools is None: + list_tools = getattr(server.mcp, "_list_tools") + return await list_tools() + + +def _assert_v1_envelope_shape(result): + assert set(result) == {"ok", "data", "error", "warnings", "next_actions", "meta"} + assert result["meta"]["request_id"].startswith("req-") + assert "graph" in result["meta"] + assert "graphspace" in result["meta"] + assert "readonly" in result["meta"] + assert "duration_ms" in result["meta"] + + +def test_public_tool_contract_lists_only_v1_tools(): + async def _tool_names(): + tools = await _list_mcp_tools() + return {tool.name for tool in tools} + + assert asyncio.run(_tool_names()) == { + "inspect_graph_tool", + "generate_gremlin_tool", + "execute_gremlin_read_tool", + "extract_graph_data_tool", + "design_schema_tool", + "apply_schema_tool", + "import_graph_data_tool", + "delete_graph_data_tool", + "refresh_vid_embeddings_tool", + "execute_gremlin_write_tool", + } + + +def test_public_tool_argument_models_do_not_emit_schema_shadow_warning(): + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + + async def _list_tools(): + return await _list_mcp_tools() + + asyncio.run(_list_tools()) + + assert not any("shadows an attribute" in str(item.message) for item in captured) + + +def test_server_import_restores_logging_globals(): + assert logging.handlers.RotatingFileHandler is server._OriginalRotatingFileHandler + assert logging.root.manager.disable < logging.CRITICAL + + +def test_generate_gremlin_tool_routes_to_generate_gremlin(monkeypatch): + expected = envelope_ok({"gremlin": "g.V().count()"}) + mock = Mock(return_value=expected) + monkeypatch.setattr(server, "generate_gremlin", mock) + + result = server.generate_gremlin_tool( + query="count vertices", + execute=True, + output_types=["vertex"], + ) + + _assert_v1_envelope_shape(result) + assert result["ok"] is True + assert result["data"] == expected["data"] + mock.assert_called_once_with( + query="count vertices", + execute=True, + output_types=["vertex"], + ) + + +def test_execute_gremlin_read_tool_routes_to_execute_gremlin_read(monkeypatch): + expected = envelope_ok({"data": [1, 2, 3]}) + mock = Mock(return_value=expected) + monkeypatch.setattr(server, "execute_gremlin_read", mock) + + result = server.execute_gremlin_read_tool(gremlin_query="g.V().limit(3)") + + _assert_v1_envelope_shape(result) + assert result["ok"] is True + assert result["data"] == expected["data"] + mock.assert_called_once_with("g.V().limit(3)") + + +def test_extract_graph_data_tool_routes_to_extract_graph_data(monkeypatch): + expected = envelope_ok({"graph_data": {"vertices": [], "edges": []}}) + mock = Mock(return_value=expected) + monkeypatch.setattr(server, "extract_graph_data", mock) + + result = server.extract_graph_data_tool( + text="Alice knows Bob.", + graph_schema={"vertexlabels": ["person"]}, + example_prompt="extract people", + ) + + _assert_v1_envelope_shape(result) + assert result["ok"] is True + assert result["data"] == expected["data"] + mock.assert_called_once_with( + text="Alice knows Bob.", + schema={"vertexlabels": ["person"]}, + example_prompt="extract people", + ) + + +def test_design_schema_tool_routes_to_manage_schema_design(monkeypatch): + expected = envelope_ok({"suggestions": []}) + mock = Mock(return_value=expected) + monkeypatch.setattr(server, "manage_schema", mock) + + result = server.design_schema_tool(operations=[{"op": "add_vertex_label"}]) + + _assert_v1_envelope_shape(result) + assert result["ok"] is True + assert result["data"] == expected["data"] + mock.assert_called_once_with(mode="design", operations=[{"op": "add_vertex_label"}]) + + +def test_apply_schema_tool_validate_routes_to_manage_schema(monkeypatch): + expected = envelope_ok({"valid": True}) + mock = Mock(return_value=expected) + monkeypatch.setattr(server, "manage_schema", mock) + + result = server.apply_schema_tool( + mode="validate", operations=[{"op": "add_vertex_label"}] + ) + + _assert_v1_envelope_shape(result) + assert result["ok"] is True + assert result["data"] == expected["data"] + mock.assert_called_once_with( + mode="validate", + operations=[{"op": "add_vertex_label"}], + confirm=False, + plan_hash=None, + ) + + +def test_apply_schema_tool_dry_run_routes_to_manage_schema(monkeypatch): + expected = envelope_ok({"plan_hash": "abc123"}) + mock = Mock(return_value=expected) + monkeypatch.setattr(server, "manage_schema", mock) + + result = server.apply_schema_tool( + mode="dry_run", operations=[{"op": "add_vertex_label"}] + ) + + _assert_v1_envelope_shape(result) + assert result["ok"] is True + assert result["data"] == expected["data"] + mock.assert_called_once_with( + mode="dry_run", + operations=[{"op": "add_vertex_label"}], + confirm=False, + plan_hash=None, + ) + + +def test_apply_schema_tool_apply_returns_feature_disabled(): + result = server.apply_schema_tool(mode="apply", operations=[{"op": "test"}]) + + _assert_v1_envelope_shape(result) + assert result["ok"] is False + assert result["error"]["type"] == "FEATURE_DISABLED" + assert result["error"]["source"] == "apply_schema_tool" + assert "apply" in result["error"]["message"].lower() + + +def test_delete_graph_data_tool_routes_to_manage_graph_data_delete(monkeypatch): + expected = envelope_ok({"valid": True, "preview": [], "plan_hash": "abc123"}) + mock = Mock(return_value=expected) + monkeypatch.setattr(server, "manage_graph_data", mock) + change_plan = { + "operations": [ + { + "op": "delete_vertex", + "label": "person", + "match": {"name": "Alice"}, + } + ] + } + + result = server.delete_graph_data_tool(change_plan=change_plan) + + _assert_v1_envelope_shape(result) + assert result["ok"] is True + assert result["data"] == expected["data"] + mock.assert_called_once_with( + mode="delete", + change_plan=change_plan, + dry_run=True, + confirm=False, + plan_hash=None, + nonce=None, + expires_at=None, + plan_tool_name="delete_graph_data_tool", + ) + + +def test_generate_gremlin_tool_aligns_error_source(monkeypatch): + expected = { + **envelope_ok(), + "ok": False, + "data": None, + "error": { + "type": "HUGEGRAPH_AI_UNAVAILABLE", + "message": "AI disabled", + "suggestion": None, + "retryable": False, + "source": "hugegraph-ai", + "details": {}, + }, + } + mock = Mock(return_value=expected) + monkeypatch.setattr(server, "generate_gremlin", mock) + + result = server.generate_gremlin_tool(query="count vertices") + + _assert_v1_envelope_shape(result) + assert result["ok"] is False + assert result["error"]["source"] == "generate_gremlin_tool" + + +def test_execute_gremlin_read_tool_aligns_error_source(monkeypatch): + expected = envelope_err( + ErrorType.UNSAFE_GREMLIN, + "Unsafe query", + source="gremlin_tools", + ) + mock = Mock(return_value=expected) + monkeypatch.setattr(server, "execute_gremlin_read", mock) + + result = server.execute_gremlin_read_tool(gremlin_query="g.addV('person')") + + _assert_v1_envelope_shape(result) + assert result["ok"] is False + assert result["error"]["source"] == "execute_gremlin_read_tool" + + +def test_admin_gate_blocks_write_tool_by_default(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "false") + + result = server.execute_gremlin_write_tool(gremlin_query="g.addV('test')") + + assert result["ok"] is False + assert result["error"]["type"] == "FEATURE_DISABLED" + assert "ADMIN_MODE" in result["error"]["message"] + assert "HUGEGRAPH_MCP_READONLY=false" in result["error"]["suggestion"] + + +def test_admin_gate_blocks_refresh_embeddings_by_default(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "false") + + result = server.refresh_vid_embeddings_tool(confirm=True) + + assert result["ok"] is False + assert result["error"]["type"] == "FEATURE_DISABLED" + assert "ADMIN_MODE" in result["error"]["message"] + + +def test_admin_gate_allows_write_tool_when_enabled(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "true") + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + expected = envelope_ok({"data": "ok"}) + mock = Mock(return_value=expected) + monkeypatch.setattr(server, "execute_gremlin_write", mock) + + result = server.execute_gremlin_write_tool(gremlin_query="g.addV('test')") + + _assert_v1_envelope_shape(result) + assert result["ok"] is True + assert result["data"] == expected["data"] + mock.assert_called_once_with("g.addV('test')", capability=Capability.DEBUG_WRITE) + + +def test_admin_gate_blocks_write_tool_when_readonly(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "true") + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + mock = Mock() + monkeypatch.setattr(server, "execute_gremlin_write", mock) + + result = server.execute_gremlin_write_tool(gremlin_query="g.addV('test')") + + assert result["ok"] is False + assert result["error"]["type"] == "READONLY_VIOLATION" + assert result["error"]["source"] == "execute_gremlin_write_tool" + assert result["error"]["details"]["required_env"] == { + "HUGEGRAPH_MCP_ADMIN_MODE": "true", + "HUGEGRAPH_MCP_READONLY": "false", + } + mock.assert_not_called() + + +def test_admin_gate_allows_refresh_embeddings_when_enabled(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "true") + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + expected = envelope_ok({"data": "refreshed"}) + mock = Mock(return_value=expected) + monkeypatch.setattr(server, "refresh_vid_embeddings", mock) + + result = server.refresh_vid_embeddings_tool(confirm=True) + + _assert_v1_envelope_shape(result) + assert result["ok"] is True + assert result["data"] == expected["data"] + mock.assert_called_once_with(confirm=True) + + +def test_admin_gate_reads_current_env_after_import(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "false") + blocked = server.execute_gremlin_write_tool(gremlin_query="g.addV('test')") + assert blocked["ok"] is False + assert blocked["error"]["type"] == "FEATURE_DISABLED" + + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "true") + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + expected = envelope_ok({"data": "ok"}) + mock = Mock(return_value=expected) + monkeypatch.setattr(server, "execute_gremlin_write", mock) + + result = server.execute_gremlin_write_tool(gremlin_query="g.addV('test')") + + _assert_v1_envelope_shape(result) + assert result["ok"] is True + assert result["data"] == expected["data"] + mock.assert_called_once_with("g.addV('test')", capability=Capability.DEBUG_WRITE) + + +def test_execute_gremlin_write_tool_wraps_unexpected_exceptions(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "true") + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + mock = Mock(side_effect=RuntimeError("boom")) + monkeypatch.setattr(server, "execute_gremlin_write", mock) + + result = server.execute_gremlin_write_tool(gremlin_query="g.addV('test')") + + _assert_v1_envelope_shape(result) + assert result["ok"] is False + assert result["error"]["type"] == "FLOW_EXECUTION_FAILED" + assert result["error"]["source"] == "execute_gremlin_write_tool" + assert "boom" in result["error"]["message"] + + +def test_refresh_vid_embeddings_tool_wraps_unexpected_exceptions(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "true") + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + mock = Mock(side_effect=RuntimeError("boom")) + monkeypatch.setattr(server, "refresh_vid_embeddings", mock) + + result = server.refresh_vid_embeddings_tool(confirm=True) + + _assert_v1_envelope_shape(result) + assert result["ok"] is False + assert result["error"]["type"] == "FLOW_EXECUTION_FAILED" + assert result["error"]["source"] == "refresh_vid_embeddings_tool" + assert "boom" in result["error"]["message"] diff --git a/hugegraph-python-client/src/tests/api/test_auth.py b/hugegraph-python-client/src/tests/api/test_auth.py index 22218ded8..3537daa97 100644 --- a/hugegraph-python-client/src/tests/api/test_auth.py +++ b/hugegraph-python-client/src/tests/api/test_auth.py @@ -140,7 +140,11 @@ def test_target_operations(self): # HugeGraph 1.7.0+ returns target_resources as a keyed map such as # {"VERTEX#person": [{...}]}; older payloads used a list shape. target_resources = target["target_resources"] - self.assertEqual(target_resources["VERTEX#person"][0]["properties"]["city"], "Shanghai") + if isinstance(target_resources, dict): + properties = target_resources["VERTEX#person"][0]["properties"] + else: + properties = target_resources[0]["properties"] + self.assertEqual(properties["city"], "Shanghai") # Delete the target self.auth.delete_target(target["id"]) diff --git a/hugegraph-python-client/src/tests/api/test_auth_routing.py b/hugegraph-python-client/src/tests/api/test_auth_routing.py index eb2c13a0d..df4cef5dc 100644 --- a/hugegraph-python-client/src/tests/api/test_auth_routing.py +++ b/hugegraph-python-client/src/tests/api/test_auth_routing.py @@ -21,6 +21,8 @@ import pytest import requests from pyhugegraph.api.auth import AuthManager +from pyhugegraph.api.schema_manage.edge_label import EdgeLabel +from pyhugegraph.utils.huge_config import HGraphConfig from pyhugegraph.utils.huge_requests import HGraphSession pytestmark = pytest.mark.contract @@ -58,6 +60,15 @@ def request(self, path: str, method: str = "GET", validator=None, **kwargs): return {"url": self.last, "method": method} +class DummySchemaSession: + def __init__(self): + self.requests = [] + + def request(self, path: str, method: str = "GET", validator=None, **kwargs): + self.requests.append({"path": path, "method": method, **kwargs}) + return {"ok": True} + + @pytest.mark.parametrize( "endpoint, method_call, args, expected_subpath", [ @@ -120,6 +131,56 @@ def test_groups_are_server_level(): assert "auth/groups" in sess2.last +class _VersionResponse: + def __init__(self, core: str): + self._core = core + + def json(self): + return {"versions": {"core": self._core}} + + +def test_hgraph_config_does_not_auto_enable_graphspace_before_1_7(monkeypatch): + monkeypatch.setattr( + "pyhugegraph.utils.huge_config.requests.get", + lambda *_args, **_kwargs: _VersionResponse("1.6.0"), + ) + + cfg = HGraphConfig("127.0.0.1:8080", "admin", "pwd", "hugegraph") + + assert cfg.version == [1, 6, 0] + assert cfg.graphspace is None + assert cfg.gs_supported is False + + +def test_hgraph_config_auto_enables_default_graphspace_for_1_7(monkeypatch): + monkeypatch.setattr( + "pyhugegraph.utils.huge_config.requests.get", + lambda *_args, **_kwargs: _VersionResponse("1.7.0"), + ) + + cfg = HGraphConfig("127.0.0.1:8080", "admin", "pwd", "hugegraph") + + assert cfg.version == [1, 7, 0] + assert cfg.graphspace == "DEFAULT" + assert cfg.gs_supported is True + + +def test_edge_label_parent_emits_sub_edge_payload(): + sess = DummySchemaSession() + edge_label = EdgeLabel(sess) + edge_label.create_parameter_holder() + edge_label.add_parameter("name", "knows_more") + edge_label.add_parameter("not_exist", True) + + edge_label.sourceLabel("person").targetLabel("person").parent("knows").create() + + request = sess.requests[-1] + assert request["path"] == "schema/edgelabels" + assert request["method"] == "POST" + assert '"parent_label": "knows"' in request["data"] + assert '"edgelabel_type": "SUB"' in request["data"] + + def test_session_debug_log_redacts_sensitive_kwargs(): cfg = DummyCfg(url="http://127.0.0.1:8080", graphspace=None, gs_supported=False, graph_name="g") cfg.username = "admin" diff --git a/skills/hugegraph-data-importer/SKILL.md b/skills/hugegraph-data-importer/SKILL.md new file mode 100644 index 000000000..26d4400e3 --- /dev/null +++ b/skills/hugegraph-data-importer/SKILL.md @@ -0,0 +1,36 @@ +--- +name: hugegraph-data-importer +description: Route HugeGraph MCP V1 graph data extraction and import tasks to stable public tools. Use when the user asks to extract graph data from text, import structured vertices or edges, verify an import, or asks whether table, SQL, update, or delete graph writes are available. +--- + +# HugeGraph Data Importer + +## Tool Routes + +| Goal | Tool | +| --- | --- | +| Inspect schema, primary keys, or edge endpoints | `inspect_graph_tool(include_raw_schema=true)` | +| Extract candidate graph data from text | `extract_graph_data_tool(text, schema?, example_prompt?)` | +| Preview structured vertex/edge import | `import_graph_data_tool(mode="ingest", graph_data, dry_run=true)` | +| Execute confirmed import | `import_graph_data_tool(mode="ingest", graph_data, dry_run=false, confirm=true, plan_hash, nonce, expires_at)` | +| Preview controlled vertex/edge delete | `delete_graph_data_tool(change_plan, dry_run=true)` | +| Execute confirmed controlled delete | `delete_graph_data_tool(change_plan, dry_run=false, confirm=true, plan_hash, nonce, expires_at)` | +| Verify imported data | `execute_gremlin_read_tool(gremlin_query)` | + +## Order + +```text +inspect_graph_tool -> extract_graph_data_tool or prepare graph_data +-> import_graph_data_tool(dry_run=true) +-> import_graph_data_tool(dry_run=false, confirm=true, plan_hash, nonce, expires_at) +-> execute_gremlin_read_tool +``` + +For controlled deletes: + +```text +inspect_graph_tool(include_raw_schema=true) +-> delete_graph_data_tool(dry_run=true) +-> delete_graph_data_tool(dry_run=false, confirm=true, plan_hash, nonce, expires_at) +-> execute_gremlin_read_tool +``` diff --git a/skills/hugegraph-data-importer/agents/openai.yaml b/skills/hugegraph-data-importer/agents/openai.yaml new file mode 100644 index 000000000..74146a199 --- /dev/null +++ b/skills/hugegraph-data-importer/agents/openai.yaml @@ -0,0 +1,21 @@ +# +# 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. +# + +interface: + display_name: "HugeGraph Data Importer" + short_description: "Extract and import graph data safely" + default_prompt: "Use $hugegraph-data-importer to extract or import HugeGraph data through the current MCP V1 safe path." diff --git a/skills/hugegraph-operator/SKILL.md b/skills/hugegraph-operator/SKILL.md new file mode 100644 index 000000000..826e23576 --- /dev/null +++ b/skills/hugegraph-operator/SKILL.md @@ -0,0 +1,27 @@ +--- +name: hugegraph-operator +description: Route HugeGraph MCP operational checks to status, schema, permission, AI availability, and read-only verification tools. Use when the user asks to check the current graph, schema, MCP connection, readonly state, HugeGraph-AI state, counts, indexes, or readiness. +--- + +# HugeGraph Operator + +## Tool Routes + +| Goal | Tool | +| --- | --- | +| Quick check connection, status, permissions, counts | `inspect_graph_tool(include_raw_schema=false)` | +| Inspect full schema, primary keys, indexes, edge endpoints | `inspect_graph_tool(include_raw_schema=true)` | +| Check readonly, allow_ai, graph, or graphspace | `inspect_graph_tool(include_raw_schema=false)` | +| Verify whether graph data exists | `execute_gremlin_read_tool(gremlin_query)` | +| Get schema context before query | `inspect_graph_tool(include_raw_schema=true)` | +| Check schema before import | `inspect_graph_tool(include_raw_schema=true)` | +| Check schema before schema design | `inspect_graph_tool(include_raw_schema=true)` | +| Check AI generation readiness | `inspect_graph_tool(include_raw_schema=false)`, then optionally `generate_gremlin_tool(query, execute=false)` | + +## Order + +```text +Status check: inspect_graph_tool +Schema audit: inspect_graph_tool(include_raw_schema=true) +Data verification: inspect_graph_tool -> execute_gremlin_read_tool +``` diff --git a/skills/hugegraph-operator/agents/openai.yaml b/skills/hugegraph-operator/agents/openai.yaml new file mode 100644 index 000000000..9e7099e6a --- /dev/null +++ b/skills/hugegraph-operator/agents/openai.yaml @@ -0,0 +1,21 @@ +# +# 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. +# + +interface: + display_name: "HugeGraph Operator" + short_description: "Inspect graph status and schema" + default_prompt: "Use $hugegraph-operator to check the current HugeGraph MCP status, schema, permissions, and next actions." diff --git a/skills/hugegraph-query-analyst/SKILL.md b/skills/hugegraph-query-analyst/SKILL.md new file mode 100644 index 000000000..f55f18b51 --- /dev/null +++ b/skills/hugegraph-query-analyst/SKILL.md @@ -0,0 +1,25 @@ +--- +name: hugegraph-query-analyst +description: Route HugeGraph MCP V1 graph query tasks to stable read-only tools. Use when the user asks graph questions, asks to generate Gremlin, provides Gremlin to execute safely, or needs read-only graph exploration. +--- + +# HugeGraph Query Analyst + +## Tool Routes + +| Goal | Tool | +| --- | --- | +| Inspect schema, labels, properties, or edge directions | `inspect_graph_tool(include_raw_schema=false)` or `inspect_graph_tool(include_raw_schema=true)` | +| Generate Gremlin from natural language | `generate_gremlin_tool(query, execute=false)` | +| Answer a natural-language graph question | `generate_gremlin_tool(query, execute=false)` -> `execute_gremlin_read_tool(gremlin_query)` | +| Execute user-provided Gremlin | `execute_gremlin_read_tool(gremlin_query)` | +| Generate only, without execution | `generate_gremlin_tool(query, execute=false)` | + + +## Order + +```text +Need schema: inspect_graph_tool +Natural-language query: generate_gremlin_tool -> execute_gremlin_read_tool +Existing Gremlin: execute_gremlin_read_tool +``` diff --git a/skills/hugegraph-query-analyst/agents/openai.yaml b/skills/hugegraph-query-analyst/agents/openai.yaml new file mode 100644 index 000000000..a6fdd2911 --- /dev/null +++ b/skills/hugegraph-query-analyst/agents/openai.yaml @@ -0,0 +1,21 @@ +# +# 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. +# + +interface: + display_name: "HugeGraph Query Analyst" + short_description: "Query graphs with read-only MCP" + default_prompt: "Use $hugegraph-query-analyst to answer this graph question through MCP read-only query tools." diff --git a/skills/hugegraph-regression-tester/SKILL.md b/skills/hugegraph-regression-tester/SKILL.md new file mode 100644 index 000000000..4a32b40c5 --- /dev/null +++ b/skills/hugegraph-regression-tester/SKILL.md @@ -0,0 +1,32 @@ +--- +name: hugegraph-regression-tester +description: Route HugeGraph MCP V1 regression testing tasks to the current public tool surface, including inspection, read-only queries, Gremlin generation, extraction, controlled import, schema validate/dry-run, safety guards, and admin-gated tool checks. +--- + +# HugeGraph Regression Tester + +## Tool Routes + +| Test goal | Tool | +| --- | --- | +| MCP, graph status, or schema | `inspect_graph_tool(include_raw_schema=false)` and when needed `inspect_graph_tool(include_raw_schema=true)` | +| Read-only Gremlin query | `execute_gremlin_read_tool(gremlin_query)` | +| Natural-language Gremlin generation | `generate_gremlin_tool(query, execute=false)` | +| Generate then execute read query | `generate_gremlin_tool(query, execute=false)` -> `execute_gremlin_read_tool(gremlin_query)` | +| Write Gremlin rejection | `execute_gremlin_read_tool(unsafe_write_query)` | +| Text-to-graph extraction | `extract_graph_data_tool(text, schema?, example_prompt?)` | +| Import dry-run | `import_graph_data_tool(mode="ingest", graph_data, dry_run=true)` | +| Import confirm | `import_graph_data_tool(mode="ingest", graph_data, dry_run=false, confirm=true, plan_hash, nonce, expires_at)` | +| Verify import | `execute_gremlin_read_tool(gremlin_query)` | +| Schema design | `design_schema_tool(operations?)` | +| Schema validation | `apply_schema_tool(mode="validate", operations)` | +| Schema dry-run | `apply_schema_tool(mode="dry_run", operations)` | +| Admin write-tool gate check | `execute_gremlin_write_tool(gremlin_query)` | +| VID refresh gate check | `refresh_vid_embeddings_tool(confirm=false)` | + +## Order + +```text +inspect -> generate/read -> extract -> import dry-run -> import confirm +-> verify read -> schema validate/dry-run -> safety/gate checks +``` diff --git a/skills/hugegraph-regression-tester/agents/openai.yaml b/skills/hugegraph-regression-tester/agents/openai.yaml new file mode 100644 index 000000000..6f87cff08 --- /dev/null +++ b/skills/hugegraph-regression-tester/agents/openai.yaml @@ -0,0 +1,21 @@ +# +# 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. +# + +interface: + display_name: "HugeGraph Regression Tester" + short_description: "Test current MCP V1 capabilities" + default_prompt: "Use $hugegraph-regression-tester to run a real HugeGraph MCP V1 regression test and report pass/fail results." diff --git a/skills/hugegraph-schema-designer/SKILL.md b/skills/hugegraph-schema-designer/SKILL.md new file mode 100644 index 000000000..9547c2654 --- /dev/null +++ b/skills/hugegraph-schema-designer/SKILL.md @@ -0,0 +1,25 @@ +--- +name: hugegraph-schema-designer +description: Route HugeGraph MCP V1 schema design, validation, and dry-run preview tasks to stable public tools. Use when the user asks to design vertex labels, edge labels, properties, primary keys, indexes, or preview schema operations. +--- + +# HugeGraph Schema Designer + +## Tool Routes + +| Goal | Tool | +| --- | --- | +| Inspect current schema | `inspect_graph_tool(include_raw_schema=true)` | +| Design schema operations | `design_schema_tool(operations?)` | +| Validate schema operations | `apply_schema_tool(mode="validate", operations)` | +| Dry-run schema operations | `apply_schema_tool(mode="dry_run", operations)` | +| Execute real schema apply | No V1 stable tool | +| Delete or roll back schema | No V1 stable tool | + +## Order + +```text +inspect_graph_tool -> design_schema_tool +-> apply_schema_tool(mode="validate") +-> apply_schema_tool(mode="dry_run") +``` diff --git a/skills/hugegraph-schema-designer/agents/openai.yaml b/skills/hugegraph-schema-designer/agents/openai.yaml new file mode 100644 index 000000000..37c3b1713 --- /dev/null +++ b/skills/hugegraph-schema-designer/agents/openai.yaml @@ -0,0 +1,21 @@ +# +# 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. +# + +interface: + display_name: "HugeGraph Schema Designer" + short_description: "Design and preview graph schema" + default_prompt: "Use $hugegraph-schema-designer to design and dry-run preview a HugeGraph schema plan." From 3dedc6be864aacf78396412fa80826e405a6f628 Mon Sep 17 00:00:00 2001 From: duyifeng01 Date: Sat, 11 Jul 2026 12:20:54 +0800 Subject: [PATCH 3/4] feat(mcp): add and harden P0a graph toolset Change-Id: Ia85907918f09080d00f1546efe45040aede06ee2 --- hugegraph-mcp/README.md | 41 +- hugegraph-mcp/README.zh-CN.md | 41 +- .../docs/graph_mcp_review_findings_fixplan.md | 543 +++++++ .../docs/graph_mcp_review_fix_plan.md | 396 +++++ .../docs/p0a-integration-checklist.md | 797 ++++++++++ hugegraph-mcp/hugegraph_mcp/__init__.py | 2 - .../hugegraph_mcp/confirmable_workflow.py | 67 + hugegraph-mcp/hugegraph_mcp/envelope.py | 95 +- hugegraph-mcp/hugegraph_mcp/error_mapping.py | 85 + hugegraph-mcp/hugegraph_mcp/gremlin_tools.py | 66 +- .../hugegraph_mcp/hugegraph_ai_client.py | 14 +- hugegraph-mcp/hugegraph_mcp/plan_hash.py | 1 - hugegraph-mcp/hugegraph_mcp/server.py | 186 ++- .../hugegraph_mcp/tools/generate_gremlin.py | 4 +- .../hugegraph_mcp/tools/inspect_schema.py | 292 ++++ .../hugegraph_mcp/tools/manage_graph_data.py | 45 +- .../hugegraph_mcp/tools/manage_schema.py | 1102 ++++++++++++- .../tools/mutate_graph_properties.py | 674 ++++++++ .../hugegraph_mcp/tools/query_graph_data.py | 473 ++++++ .../tests/integration/test_real_write_path.py | 173 ++- hugegraph-mcp/tests/test_envelope.py | 2 +- hugegraph-mcp/tests/test_error_handling.py | 186 +++ hugegraph-mcp/tests/test_generate_gremlin.py | 61 +- .../tests/test_hugegraph_ai_client.py | 7 +- .../tests/test_import_graph_data_tool.py | 7 + .../tests/test_inspect_schema_tool.py | 119 ++ hugegraph-mcp/tests/test_manage_schema.py | 1381 ++++++++++++++++- .../test_mutate_graph_properties_tool.py | 443 ++++++ hugegraph-mcp/tests/test_plan_hash.py | 2 + .../tests/test_query_graph_data_tool.py | 352 +++++ hugegraph-mcp/tests/test_readonly_mode.py | 93 +- hugegraph-mcp/tests/test_v1_stable_tools.py | 157 +- .../src/pyhugegraph/api/graph.py | 114 +- .../api/schema_manage/index_label.py | 215 +-- .../api/schema_manage/property_key.py | 378 ++--- .../src/pyhugegraph/utils/id_format.py | 18 + .../src/tests/api/test_graph.py | 22 +- .../src/tests/api/test_schema.py | 71 +- .../src/tests/api/test_traverser.py | 10 +- .../src/tests/api/test_vertex_id_format.py | 102 +- pyproject.toml | 3 + 41 files changed, 8230 insertions(+), 610 deletions(-) create mode 100644 hugegraph-mcp/docs/graph_mcp_review_findings_fixplan.md create mode 100644 hugegraph-mcp/docs/graph_mcp_review_fix_plan.md create mode 100644 hugegraph-mcp/docs/p0a-integration-checklist.md create mode 100644 hugegraph-mcp/hugegraph_mcp/confirmable_workflow.py create mode 100644 hugegraph-mcp/hugegraph_mcp/error_mapping.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/inspect_schema.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/mutate_graph_properties.py create mode 100644 hugegraph-mcp/hugegraph_mcp/tools/query_graph_data.py create mode 100644 hugegraph-mcp/tests/test_inspect_schema_tool.py create mode 100644 hugegraph-mcp/tests/test_mutate_graph_properties_tool.py create mode 100644 hugegraph-mcp/tests/test_query_graph_data_tool.py diff --git a/hugegraph-mcp/README.md b/hugegraph-mcp/README.md index 41292651f..caadc0e49 100644 --- a/hugegraph-mcp/README.md +++ b/hugegraph-mcp/README.md @@ -2,7 +2,7 @@ [中文文档](README.zh-CN.md) -HugeGraph MCP is a Model Context Protocol server for HugeGraph. V1 is designed as a safe, controlled, thin adapter layer: it exposes a small set of stable tools and centralizes configuration, permission checks, read-only Gremlin validation, the dry-run/confirm write safety chain, and the unified response envelope. +HugeGraph MCP is a Model Context Protocol server for HugeGraph. It is designed as a safe, controlled, thin adapter layer: it exposes a small set of stable tools and centralizes configuration, permission checks, read-only Gremlin validation, the dry-run/confirm write safety chain, and the unified response envelope. **Requires HugeGraph Server >= 1.7.0** (MCP defaults to `graphspace=DEFAULT` and relies on graphspace-scoped API routes that are not available in older versions). @@ -10,7 +10,7 @@ HugeGraph MCP is a Model Context Protocol server for HugeGraph. V1 is designed a ### Design Boundary -V1 does not turn MCP into a second business kernel. The MCP layer is responsible for: +HugeGraph MCP does not turn MCP into a second business kernel. The MCP layer is responsible for: - Exposing stable MCP tool interfaces - Reading runtime configuration @@ -22,25 +22,42 @@ V1 does not turn MCP into a second business kernel. The MCP layer is responsible ### Public Tool Surface -V1 exposes these stable tools to users: +The default `v2_core` toolset registers 13 MCP tools: 11 normal user-facing stable tools plus 2 admin/debug tools that are registered but blocked by default. + +The normal user-facing stable tools are: - `inspect_graph_tool` +- `inspect_schema_tool` +- `query_graph_data_tool` - `generate_gremlin_tool` - `execute_gremlin_read_tool` - `extract_graph_data_tool` -- `import_graph_data_tool` -- `delete_graph_data_tool` - `design_schema_tool` - `apply_schema_tool` +- `mutate_graph_properties_tool` +- `import_graph_data_tool` +- `delete_graph_data_tool` These tools are still registered in MCP, but they are admin/debug capabilities and are blocked by default when `HUGEGRAPH_MCP_ADMIN_MODE=false`. Write-capable admin tools also require `HUGEGRAPH_MCP_READONLY=false`: - `execute_gremlin_write_tool` - `refresh_vid_embeddings_tool` +### Toolset Selection + +`HUGEGRAPH_MCP_TOOLSET` controls the public tool contract: + +| Value | Tools | Intended use | +|-------|------:|--------------| +| `v1` | 10 | Compatibility mode for old clients; exposes the original stable tools and admin/debug tools, hides the three `v2_core` tools, and keeps `apply_schema_tool(mode="apply")` disabled | +| `v2_core` | 13 | New deployment default; exposes the V1 tools plus `inspect_schema_tool`, `query_graph_data_tool`, and `mutate_graph_properties_tool`, and enables the P0a schema create apply path | + +When `HUGEGRAPH_MCP_TOOLSET` is unset, the server defaults to `v2_core`. Any value other than exact `v1` is treated as `v2_core`. +Toolset selection is applied when the MCP server starts and registers tools; restart the MCP server after changing this variable. + ### Unified Response Envelope -V1 high-level tools return a unified envelope: +High-level tools return a unified envelope: ```json { @@ -78,14 +95,17 @@ When a call fails, `ok=false` and `error` uses this structure: | Tool | Description | |------|-------------| -| `inspect_graph_tool` | Inspect HugeGraph Server status, schema summary, vertex/edge counts, readonly state, and AI availability | +| `inspect_graph_tool` | Inspect HugeGraph Server status, schema summary, vertex/edge counts, readonly state, AI availability, and current MCP tool contract fields | +| `inspect_schema_tool` | Inspect schema objects, relations, and index labels; supports filtering by property key, vertex label, edge label, or index label | +| `query_graph_data_tool` | Query vertices or edges by typed operations (`get_by_id`, `get_by_ids`, `page`, `condition`) with explicit limits and no Gremlin full-scan fallback | | `generate_gremlin_tool` | Generate Gremlin from natural language; defaults to generation only; `execute=true` still requires read-only validation | -| `execute_gremlin_read_tool` | Execute read-only Gremlin queries; rejects queries whose safety cannot be confirmed | +| `execute_gremlin_read_tool` | Execute read-only Gremlin queries; rejects queries whose safety cannot be confirmed and supports `limit_policy` for unbounded reads | | `extract_graph_data_tool` | Extract candidate graph data from natural language text and return vertex/edge structures without writing to HugeGraph | | `import_graph_data_tool` | Structured graph data import entrypoint; real writes must pass `dry_run -> plan_hash -> confirm` | | `delete_graph_data_tool` | Controlled delete entrypoint; supports only exact vertex or edge deletion, not conditional bulk delete or cascade delete | | `design_schema_tool` | Provide schema design guidance from proposed schema operations without modifying the database | -| `apply_schema_tool` | V1 supports only schema `validate` and `dry_run`; real `apply` is currently disabled | +| `apply_schema_tool` | Validate and dry-run schema operations; in `v2_core`, confirmed `apply` supports only `create_property_key`, `create_vertex_label`, and `create_edge_label`; in `v1`, real `apply` remains disabled | +| `mutate_graph_properties_tool` | Append or eliminate properties on one exact vertex or edge; both operations require `dry_run -> plan_hash -> confirm` and reject stale targets | | `execute_gremlin_write_tool` | Execute direct Gremlin writes; disabled by default and available only when `HUGEGRAPH_MCP_ADMIN_MODE=true` and `HUGEGRAPH_MCP_READONLY=false` | | `refresh_vid_embeddings_tool` | Refresh VID embeddings and mutate index state; disabled by default and available only when `HUGEGRAPH_MCP_ADMIN_MODE=true` and `HUGEGRAPH_MCP_READONLY=false` | @@ -112,7 +132,7 @@ dry_run=true - Graph URL - Graph name - Graph space -- Permission state such as readonly/admin flags +- MCP readonly state - Current schema hash - Normalized payload digest - Nonce @@ -175,6 +195,7 @@ All configuration is read from environment variables. | `HUGEGRAPH_GRAPH` | unset | Override graph name separately | | `HUGEGRAPH_USER` | `admin` | HugeGraph username | | `HUGEGRAPH_PASSWORD` | `""` | HugeGraph password | +| `HUGEGRAPH_MCP_TOOLSET` | `v2_core` | Public tool contract: `v1` for 10-tool compatibility mode, `v2_core` for the 13-tool default | | `HUGEGRAPH_MCP_READONLY` | `true` | Whether readonly mode is enabled | | `HUGEGRAPH_MCP_ALLOW_AI` | `false` | Whether HugeGraph-AI calls are allowed | | `HUGEGRAPH_MCP_ADMIN_MODE` | `false` | Whether admin/debug tools are enabled | diff --git a/hugegraph-mcp/README.zh-CN.md b/hugegraph-mcp/README.zh-CN.md index ef6214e34..4de8c491d 100644 --- a/hugegraph-mcp/README.zh-CN.md +++ b/hugegraph-mcp/README.zh-CN.md @@ -2,7 +2,7 @@ [English](README.md) -HugeGraph MCP 是 HugeGraph 的 Model Context Protocol Server。V1 的定位是安全、可控的薄适配层:对外暴露少量稳定工具,内部统一处理配置、权限、只读 Gremlin 校验、dry-run/confirm 写入安全链和统一响应格式。 +HugeGraph MCP 是 HugeGraph 的 Model Context Protocol Server。它的定位是安全、可控的薄适配层:对外暴露少量稳定工具,内部统一处理配置、权限、只读 Gremlin 校验、dry-run/confirm 写入安全链和统一响应格式。 **要求 HugeGraph Server >= 1.7.0**(MCP 默认使用 `graphspace=DEFAULT` 并依赖 graphspace 路由 API,旧版本不支持)。 @@ -10,7 +10,7 @@ HugeGraph MCP 是 HugeGraph 的 Model Context Protocol Server。V1 的定位是 ### 设计边界 -V1 不把 MCP 做成另一套业务内核。MCP 层负责: +HugeGraph MCP 不把 MCP 做成另一套业务内核。MCP 层负责: - 暴露稳定 MCP 工具接口 - 读取运行时配置 @@ -24,26 +24,43 @@ V1 不把 MCP 做成另一套业务内核。MCP 层负责: ### 对外工具面 -V1 对用户暴露稳定工具: +默认 `v2_core` 工具集注册 13 个 MCP 工具:其中 11 个是普通用户稳定工具,另外 2 个是管理/调试工具,默认注册但受 admin mode 阻断。 + +普通用户稳定工具包括: - `inspect_graph_tool` +- `inspect_schema_tool` +- `query_graph_data_tool` - `generate_gremlin_tool` - `execute_gremlin_read_tool` - `extract_graph_data_tool` -- `import_graph_data_tool` -- `delete_graph_data_tool` - `design_schema_tool` - `apply_schema_tool` +- `mutate_graph_properties_tool` +- `import_graph_data_tool` +- `delete_graph_data_tool` 以下工具仍注册在 MCP 中,但属于管理/调试能力,默认受 `HUGEGRAPH_MCP_ADMIN_MODE=false` 阻断。具备写入能力的管理工具还要求 `HUGEGRAPH_MCP_READONLY=false`: - `execute_gremlin_write_tool` - `refresh_vid_embeddings_tool` +### 工具集选择 + +`HUGEGRAPH_MCP_TOOLSET` 控制对外工具契约: + +| 取值 | 工具数 | 用途 | +|------|------:|------| +| `v1` | 10 | 兼容旧客户端;只暴露原有稳定工具和管理/调试工具,隐藏三个 `v2_core` 新工具,并保持 `apply_schema_tool(mode="apply")` 禁用 | +| `v2_core` | 13 | 新部署默认值;在 V1 工具基础上增加 `inspect_schema_tool`、`query_graph_data_tool`、`mutate_graph_properties_tool`,并启用 P0a 范围内的 schema create apply 路径 | + +未设置 `HUGEGRAPH_MCP_TOOLSET` 时,服务默认使用 `v2_core`。除精确的 `v1` 之外,其他取值都会按 `v2_core` 处理。 +工具集选择在 MCP server 启动并注册工具时生效;修改该变量后需要重启 MCP server。 + ### 统一响应格式 -V1 高层工具返回统一 envelope: +高层工具返回统一 envelope: ```json { @@ -82,14 +99,17 @@ V1 高层工具返回统一 envelope: | 工具 | 说明 | |------|------| -| `inspect_graph_tool` | 查看 HugeGraph Server 状态、schema 摘要、点边计数、readonly 状态和 AI 可用性 | +| `inspect_graph_tool` | 查看 HugeGraph Server 状态、schema 摘要、点边计数、readonly 状态、AI 可用性和当前 MCP 工具契约字段 | +| `inspect_schema_tool` | 查看 schema 对象、关系和索引标签;支持按 property key、vertex label、edge label 或 index label 过滤 | +| `query_graph_data_tool` | 通过类型化操作查询点或边(`get_by_id`、`get_by_ids`、`page`、`condition`),有显式 limit,不自动降级为 Gremlin 全图扫描 | | `generate_gremlin_tool` | 根据自然语言生成 Gremlin;默认只生成,不执行;`execute=true` 时也必须通过只读校验 | -| `execute_gremlin_read_tool` | 执行只读 Gremlin 查询;无法确认安全时拒绝执行 | +| `execute_gremlin_read_tool` | 执行只读 Gremlin 查询;无法确认安全时拒绝执行,并支持 `limit_policy` 处理无界读取 | | `extract_graph_data_tool` | 从自然语言文本抽取候选图数据,返回点和边结构,不写入 HugeGraph | | `import_graph_data_tool` | 结构化图数据导入入口;真实写入必须经过 `dry_run -> plan_hash -> confirm` | | `delete_graph_data_tool` | 受控删除入口;只支持精确删除点或边,不支持条件批量删除和级联删除 | | `design_schema_tool` | 根据 schema 操作草案给出设计建议,不修改数据库 | -| `apply_schema_tool` | V1 只支持 schema `validate` 和 `dry_run`;真实 `apply` 当前禁用 | +| `apply_schema_tool` | 校验和 dry-run schema 操作;`v2_core` 中确认后的 `apply` 只支持 `create_property_key`、`create_vertex_label`、`create_edge_label`;`v1` 中真实 `apply` 仍禁用 | +| `mutate_graph_properties_tool` | 对单个精确点或边追加/淘汰属性;两种操作都必须经过 `dry_run -> plan_hash -> confirm`,目标变更时拒绝执行 | | `execute_gremlin_write_tool` | 直接执行 Gremlin 写语句;默认禁用,仅 `HUGEGRAPH_MCP_ADMIN_MODE=true` 且 `HUGEGRAPH_MCP_READONLY=false` 时可用 | | `refresh_vid_embeddings_tool` | 刷新 VID embeddings,会改变索引状态;默认禁用,仅 `HUGEGRAPH_MCP_ADMIN_MODE=true` 且 `HUGEGRAPH_MCP_READONLY=false` 时可用 | @@ -117,7 +137,7 @@ dry_run=true - graph url - graph name - graph space -- readonly/admin 等权限状态 +- MCP readonly 状态 - 当前 schema hash - normalized payload digest - nonce @@ -178,6 +198,7 @@ scalar 端点是 same-payload import 的便捷写法,但在单主键 live sche | `HUGEGRAPH_GRAPH` | 未设置 | 单独覆盖 graph name | | `HUGEGRAPH_USER` | `admin` | HugeGraph 用户名 | | `HUGEGRAPH_PASSWORD` | `""` | HugeGraph 密码 | +| `HUGEGRAPH_MCP_TOOLSET` | `v2_core` | 对外工具契约:`v1` 为 10 工具兼容模式,`v2_core` 为 13 工具默认模式 | | `HUGEGRAPH_MCP_READONLY` | `true` | 是否启用只读模式 | | `HUGEGRAPH_MCP_ALLOW_AI` | `false` | 是否允许调用 HugeGraph-AI | | `HUGEGRAPH_MCP_ADMIN_MODE` | `false` | 是否启用管理/调试工具 | diff --git a/hugegraph-mcp/docs/graph_mcp_review_findings_fixplan.md b/hugegraph-mcp/docs/graph_mcp_review_findings_fixplan.md new file mode 100644 index 000000000..9eb29d6b5 --- /dev/null +++ b/hugegraph-mcp/docs/graph_mcp_review_findings_fixplan.md @@ -0,0 +1,543 @@ +# HugeGraph MCP Review Findings Fix Plan + +本文档基于 `graph-mcp` 分支近期提交(`ae85315` schema/pytest marker 修复、`f2ccdab` schema 校验加固、`c76770f` edge id URL 编码修复、`f8902ea` 真实 edge id 测试、`d807e03` confirm workflow 加固)之后的一次代码评审,记录发现的问题、根因、修复方案和验收标准。 + +所有行号基于评审时读取的当前代码状态(`graph-mcp` HEAD = `d807e03`),与评审原始描述基本一致,个别函数起止行有 1-3 行的自然漂移,已在下文更新为准确值。 + +本文档只给修复方案,不改动任何源代码。 + +## 0. 问题总览 + +| 优先级 | 问题 | 影响文件 | +| --- | --- | --- | +| P1 | schema 校验存在遗漏分支,非法 `id_strategy`/枚举字段/标识符字段能穿透 dry-run 拿到 `plan_hash`,apply 阶段才失败 | `hugegraph_mcp/tools/manage_schema.py` | +| P2 | 响应脱敏正则覆盖不全,HTTP header、Python dict repr、无分隔符裸文本三种真实敏感信息载体不脱敏 | `hugegraph_mcp/envelope.py` | +| 次要-1 | `_id_dedupe_key` 对混合 key 类型 dict 输入抛裸 `TypeError`,违反 envelope 契约 | `hugegraph_mcp/tools/query_graph_data.py` | +| 次要-2 | `format_vertex_id_path` 与 `format_edge_id_path` 序列化语义不对称,无文档说明设计意图 | `hugegraph-python-client/src/pyhugegraph/utils/id_format.py` | + +## 1. P1(阻塞):schema 校验遗漏分支导致 dry-run 对必失败操作发 plan_hash + +### 1.1 问题描述 + +**位置 1:`id_strategy` 校验只在精确匹配 `"PRIMARY_KEY"` 时触发** + +`hugegraph_mcp/tools/manage_schema.py:215-224`(`_validate_vertex_primary_keys` 函数头部): + +```python +def _validate_vertex_primary_keys( + *, + idx: int, + operation: dict[str, Any], + property_keys: set[str], + errors: list[ValidationError], +) -> None: + id_strategy = str(operation.get("id_strategy", "PRIMARY_KEY")).upper() + if id_strategy != "PRIMARY_KEY": + return +``` + +只有当 `id_strategy` 恰好等于 `"PRIMARY_KEY"` 时才继续校验 `primary_keys` 必填。任何其他取值——包括拼写错误(`"Primarykey"`、`"PRIMARY_KEYS"`)、非法枚举(`"FOO"`)、带空格(`"PRIMARY_KEY "`,`.upper()` 不会去除空格)——都会在这一行直接 `return`,被当作"非 PRIMARY_KEY 策略,不需要校验 primary_keys"处理,函数直接放行。 + +而 apply 阶段的 `_apply_vertex_label_options`(`hugegraph_mcp/tools/manage_schema.py:876-887`): + +```python +def _apply_vertex_label_options(builder, operation: dict[str, Any]) -> None: + id_strategy = str(operation.get("id_strategy", "PRIMARY_KEY")).upper() + id_strategy_methods = { + "PRIMARY_KEY": "usePrimaryKeyId", + "CUSTOMIZE_STRING": "useCustomizeStringId", + "CUSTOMIZE_NUMBER": "useCustomizeNumberId", + "AUTOMATIC": "useAutomaticId", + } + method_name = id_strategy_methods.get(id_strategy) + if method_name is None: + raise ValueError(f"Unsupported vertex label id_strategy: {id_strategy}") + getattr(builder, method_name)() +``` + +对 `id_strategy` 做严格枚举查表,任何不在 `id_strategy_methods` 四个键里的值都会 `raise ValueError`。 + +结果:`id_strategy="FOO"` 这样的非法输入,在 `validate_schema_operations` / `dry_run_schema_operations` 阶段被当作"非 PRIMARY_KEY,跳过 primary_keys 检查"直接放行,`dry_run` 返回 `valid=true` 并附带 `plan_hash`;到 `apply` 阶段调用 `_apply_vertex_label_options` 才抛 `ValueError`。 + +**位置 2:`validate_schema_operations` 对标识符字段和枚举字段的前置校验缺失** + +`hugegraph_mcp/tools/manage_schema.py:407-421`(必填字段检查,位于 `validate_schema_operations` 内): + +```python +for field in REQUIRED_FIELDS[op_type]: + if field not in operation or operation[field] in (None, ""): + errors.append( + _validation_error( + idx, + operation, + f"missing required field: {field}", + 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] +): + continue +``` + +`REQUIRED_FIELDS`(`manage_schema.py:59-64`)覆盖 `name`、`source_label`、`target_label`、`base_label` 等标识符字段,但检查逻辑只判断 `field not in operation or operation[field] in (None, "")`——即只检查"键不存在"或"值恰好是 `None` 或空字符串 `""`"。这意味着: + +- 传入 `name=123`(int)、`name=[]`(list)、`name=True`(bool):这些值都不在 `(None, "")` 集合里,判断为"已提供",直接放行,不会报 `missing required field`。后续 `name in live_vertex_labels` 之类的集合成员判断在类型不匹配时静默返回 `False`,不会报错,直接进入 `available_vertex_labels.add(name)`(如 `manage_schema.py:539-542`),把非字符串值当作合法名字加入"本批次已创建"集合。 +- 传入 `name=" "`(纯空格字符串):不在 `(None, "")` 里,同样放行,成为一个视觉上是空白、实际是非空字符串的 schema 对象名字。 + +对枚举字段(`data_type`、`cardinality`、`aggregate_type`、`id_strategy`、`frequency`)则完全没有出现在 `validate_schema_operations` 的任何校验分支中——除了上面提到的 `id_strategy` 半成品校验外,`data_type`、`cardinality`、`aggregate_type`(property key 相关)和 `frequency`(edge label 相关)在 validate 阶段没有任何检查。这些值全部推迟到 apply 阶段,通过各自的查表在 `_apply_property_key_options`(`manage_schema.py:828-873`)和 `_apply_edge_label_options`(`manage_schema.py:897-914`)里报错: + +- `data_type` 不在 `data_type_methods`(`manage_schema.py:830-843`)→ apply 阶段 `ValueError`。 +- `cardinality` 不在 `cardinality_methods`(`manage_schema.py:849-857`)→ apply 阶段 `ValueError`。 +- `aggregate_type` 提供但不在 `aggregate_methods`(`manage_schema.py:860-873`)→ apply 阶段 `ValueError`。 +- `frequency` 提供但不是 `"SINGLE"`/`"MULTIPLE"`(`manage_schema.py:906-914`)→ apply 阶段 `ValueError`。 + +注:`create_index_label` 的 `base_type` 字段例外——`validate_schema_operations` 在 `manage_schema.py:494-524` 已经做了 `base_type == "VERTEX"` / `"EDGE"` 的前置分支校验,非法值会在 validate 阶段报 `unsupported base_type for index label`。这部分已经是正确模式,P1 修复应参照这个已有分支的写法,而不是重新发明。 + +### 1.2 根因 + +`validate_schema_operations` 的设计意图是"提前复现 apply 阶段所有会失败的检查",但历史上只对少数字段(`base_type`、`source_label`/`target_label` 的存在性、`primary_keys` 的精确 `PRIMARY_KEY` 分支)做了这种复现,其余枚举字段的校验逻辑只存在于 apply 阶段的查表里,从未被"抽出一份、放到 validate 阶段也跑一次"。这正是本分支自己在 `graph_mcp_review_fix_plan.md` 的 Fix-10 想解决的"dry-run 通过但 apply 失败造成部分写入"问题的同类分支,Fix-10 只填了 `primary_keys` 缺失这一种情况,没有覆盖 `id_strategy` 本身的枚举合法性,也没有覆盖其他枚举字段和标识符字段的类型校验。 + +### 1.3 修复方案 + +#### 1.3.1 把 apply 阶段的枚举表提升为模块级常量,validate 和 apply 共享同一份 + +在 `manage_schema.py` 靠近 `REQUIRED_FIELDS`(第 59 行附近)新增模块级常量,把当前分散在 `_apply_property_key_options`、`_apply_vertex_label_options`、`_apply_edge_label_options` 函数体内的四个局部字典提升为模块级只读映射: + +```python +# 提升为模块级常量,validate 和 apply 共用同一份合法值来源, +# 避免两处枚举表分别维护导致漂移。 +PROPERTY_KEY_DATA_TYPES = frozenset( + {"TEXT", "INT", "INTEGER", "LONG", "DOUBLE", "FLOAT", + "BOOLEAN", "BOOL", "DATE", "BYTE", "BLOB", "OBJECT"} +) +PROPERTY_KEY_CARDINALITIES = frozenset({"SINGLE", "SET", "LIST"}) +PROPERTY_KEY_AGGREGATE_TYPES = frozenset({"OLD", "SUM", "MIN", "MAX"}) +VERTEX_LABEL_ID_STRATEGIES = frozenset( + {"PRIMARY_KEY", "CUSTOMIZE_STRING", "CUSTOMIZE_NUMBER", "AUTOMATIC"} +) +EDGE_LABEL_FREQUENCIES = frozenset({"SINGLE", "MULTIPLE"}) +``` + +`_apply_property_key_options`、`_apply_vertex_label_options`、`_apply_edge_label_options` 内部的 `*_methods` 字典(方法名映射)保持不变,只是把"合法值集合"这一层单独提出来给 validate 复用;apply 阶段仍然用完整的 `*_methods` 字典做实际方法分发,两者的 key 集合必须保持一致(可在测试里加一条断言,见 1.5)。 + +#### 1.3.2 在 `validate_schema_operations` 中新增枚举校验函数,覆盖所有当前遗漏字段 + +新增一个通用枚举校验 helper,放在 `_validate_property_references` 和 `_validate_vertex_primary_keys` 之间(约第 213 行附近): + +```python +def _validate_enum_field( + *, + idx: int, + operation: dict[str, Any], + field: str, + allowed_values: frozenset[str], + errors: list[ValidationError], + required: bool, + default: str | None = None, +) -> str | None: + """校验枚举字段合法性,返回规范化后的值(大写、trim),非法时追加 error 并返回 None。 + + default 非 None 时,字段缺失按 default 处理(复现 apply 阶段的默认值语义, + 例如 id_strategy 缺失按 PRIMARY_KEY 处理,cardinality 缺失按 SINGLE 处理)。 + """ + raw_value = operation.get(field) + if raw_value in (None, ""): + if not required: + return default + raw_value = default + + if not isinstance(raw_value, str): + errors.append(_validation_error( + idx, operation, + f"{field} must be a string, got {type(raw_value).__name__}", + f"Use one of: {', '.join(sorted(allowed_values))} for {field}.", + )) + return None + + normalized = raw_value.strip().upper() + if normalized not in allowed_values: + errors.append(_validation_error( + idx, operation, + f"unsupported {field}: {raw_value!r}", + f"Use one of: {', '.join(sorted(allowed_values))} for {field}.", + )) + return None + + return normalized +``` + +在 `validate_schema_operations` 的主循环里,针对每种 op_type 调用这个 helper: + +- **`create_property_key`** 分支(`manage_schema.py:424-432` 附近,`op_type == "create_property_key"` 的 if 块内)新增: + - `_validate_enum_field(field="data_type", allowed_values=PROPERTY_KEY_DATA_TYPES, required=True, default="TEXT")` + - `_validate_enum_field(field="cardinality", allowed_values=PROPERTY_KEY_CARDINALITIES, required=False, default="SINGLE")` + - 如果 `operation.get("aggregate_type")` 非空,再调用 `_validate_enum_field(field="aggregate_type", allowed_values=PROPERTY_KEY_AGGREGATE_TYPES, required=False)`(`aggregate_type` 本身是可选字段,只在提供时校验合法性,不提供不报错——对齐 apply 阶段 `if aggregate_type:` 的可选语义)。 + + 注:`data_type` 已经在 `REQUIRED_FIELDS["create_property_key"]` 里被判断"非空",这里新增的是"值必须在合法枚举内"这一层,两者不冲突,做的是不同的检查。 + +- **`create_vertex_label`** 分支(`manage_schema.py:433-455` 附近):在调用 `_validate_vertex_primary_keys` 之前,先调用: + - `id_strategy = _validate_enum_field(field="id_strategy", allowed_values=VERTEX_LABEL_ID_STRATEGIES, required=False, default="PRIMARY_KEY")` + - 如果 `id_strategy is None`(校验失败,已经追加了 error),跳过后续 `_validate_vertex_primary_keys` 调用,避免同一个非法输入被 `_validate_vertex_primary_keys` 内部 `str(...).upper()` 重新解析一遍产生第二条容易让人误解的 error(`_validate_vertex_primary_keys` 内部原有的 `str(operation.get("id_strategy", "PRIMARY_KEY")).upper()` 这行判断逻辑可以删除,改为直接接收上面已经规范化好的 `id_strategy` 参数,见 1.3.3)。 + +- **`create_edge_label`** 分支(`manage_schema.py:456-483` 附近)新增: + - 如果 `operation.get("frequency")` 非空,调用 `_validate_enum_field(field="frequency", allowed_values=EDGE_LABEL_FREQUENCIES, required=False)`(`frequency` 同样是可选字段,不提供不报错)。 + +#### 1.3.3 修正 `_validate_vertex_primary_keys` 的 `id_strategy` 分支判断逻辑 + +`_validate_vertex_primary_keys`(`manage_schema.py:215-299`)当前独立重新解析 `id_strategy`,与 1.3.2 新增的枚举校验存在潜在的双重解析问题。修复方式:把已经校验、规范化过的 `id_strategy` 值通过参数传入,函数体不再自己从 `operation` 里重新取值: + +```python +def _validate_vertex_primary_keys( + *, + idx: int, + operation: dict[str, Any], + id_strategy: str | None, # 新增参数:由调用方传入已规范化的枚举值(可能为 None,表示上一步枚举校验已失败) + property_keys: set[str], + errors: list[ValidationError], +) -> None: + if id_strategy is None: + # id_strategy 本身非法,已经在枚举校验里报过错, + # 不再对 primary_keys 做二次判断,避免同一处输入报两条无关的 error。 + return + if id_strategy != "PRIMARY_KEY": + return + # ... 后续 primary_keys 非空/类型/引用校验逻辑不变 ... +``` + +调用处(`manage_schema.py:450-455` 附近)相应调整为先取 `_validate_enum_field` 的返回值,再传给 `_validate_vertex_primary_keys`: + +```python +id_strategy = _validate_enum_field( + idx=idx, operation=operation, field="id_strategy", + allowed_values=VERTEX_LABEL_ID_STRATEGIES, errors=errors, + required=False, default="PRIMARY_KEY", +) +_validate_vertex_primary_keys( + idx=idx, operation=operation, id_strategy=id_strategy, + property_keys=available_property_keys, errors=errors, +) +``` + +`_validation_warnings`(`manage_schema.py:316-330`)里同样有一段独立的 `id_strategy = str(operation.get("id_strategy", "PRIMARY_KEY")).upper()` 解析逻辑,用于判断"非 PRIMARY_KEY 且无 primary_keys"时给 warning。这段属于 warning(非阻塞),本次 P1 修复不强制改动,但建议同批顺手把它也换成调用同一个 `_validate_enum_field` 的规范化结果(避免全模块出现第三份重复的 `id_strategy` 解析逻辑);如果不改,需在文档里注明这处已知的、影响范围仅限 warning 文案的重复解析,不影响 valid/plan_hash 的正确性。 + +#### 1.3.4 新增标识符字段类型校验:`name`/`source_label`/`target_label`/`base_label` + +在 `REQUIRED_FIELDS` 必填检查之后(`manage_schema.py:407-421` 之后,紧接着原有的 `name = operation.get("name")` 那一行之前),新增一个统一的标识符字符串校验: + +```python +IDENTIFIER_FIELDS = { + "create_property_key": ("name",), + "create_vertex_label": ("name",), + "create_edge_label": ("name", "source_label", "target_label"), + "create_index_label": ("name", "base_label"), +} + + +def _validate_identifier_field( + *, idx: int, operation: dict[str, Any], field: str, errors: list[ValidationError], +) -> bool: + """校验标识符字段必须是 str 且 strip 后非空。返回是否通过。""" + value = operation.get(field) + if not isinstance(value, str) or not value.strip(): + errors.append(_validation_error( + idx, operation, + f"{field} must be a non-empty string, got {value!r}", + f"Provide {field} as a non-empty string identifier.", + )) + return False + return True +``` + +在主循环里,对每个 `op_type` 已经通过 `REQUIRED_FIELDS` 存在性检查之后,立即调用: + +```python +identifier_ok = all( + _validate_identifier_field(idx=idx, operation=operation, field=field, errors=errors) + for field in IDENTIFIER_FIELDS.get(op_type, ()) +) +if not identifier_ok: + continue # 标识符非法,跳过后续依赖 name/label 做集合成员判断的逻辑 +``` + +这一步补上"类型必须是 str"和"strip 后非空"两层,覆盖评审提到的 `name=123`、`name=[]`、`name=" "` 这几类当前能穿透 `(None, "")` 检查的输入。放在 `REQUIRED_FIELDS` 检查之后、`name in live_vertex_labels` 之类判断之前,确保非字符串或空白字符串不会进入后续 `available_vertex_labels.add(name)`(`manage_schema.py:539-542`)这样的集合操作。 + +### 1.4 验收标准 + +1. 对 `create_vertex_label` 操作,`id_strategy` 取以下任意非法值时,`manage_schema(mode="dry_run", ...)` 必须返回 `valid=false`,且返回体中**不包含** `plan_hash` 字段(当前 `dry_run_schema_operations` 在 `validation["valid"]` 为 `False` 时会直接 `return validation`,不会走到生成 `plan_hash` 的分支——修复后只需确保这些非法值确实被判定为 `invalid`,`plan_hash` 缺失是既有代码路径的自然结果,不需要额外改动): + - `id_strategy="FOO"`(不存在的枚举值) + - `id_strategy="Primary_key"`(大小写不一致——当前实现虽然会 `.upper()` 规范化,但拼写错误如 `"Primarykey"` 无空格分隔仍应判定非法) + - `id_strategy="PRIMARY_KEY "`(带尾部空格) + - `id_strategy=123`(非字符串类型) +2. 对 `create_property_key` 操作,`data_type` 取 `"FOOBAR"` 或非字符串类型时,`dry_run` 返回 `valid=false`,不含 `plan_hash`。 +3. 对 `create_property_key` 操作,`cardinality` 取非法值(如 `"MANY"`)时,`dry_run` 返回 `valid=false`。 +4. 对 `create_property_key` 操作,`aggregate_type` 提供但取非法值(如 `"AVG"`)时,`dry_run` 返回 `valid=false`;`aggregate_type` 不提供时不报错。 +5. 对 `create_edge_label` 操作,`frequency` 提供但取非法值(如 `"ONCE"`)时,`dry_run` 返回 `valid=false`;`frequency` 不提供时不报错。 +6. 对任意 `create_*` 操作,`name` 取非字符串(如 `123`、`[]`、`true`)或纯空白字符串(如 `" "`)时,`dry_run` 返回 `valid=false`。 +7. 对 `create_edge_label` 操作,`source_label`/`target_label` 取非字符串或纯空白字符串时,`dry_run` 返回 `valid=false`。 +8. 对 `create_index_label` 操作,`base_label` 取非字符串或纯空白字符串时,`dry_run` 返回 `valid=false`。 +9. 回归验证:所有合法输入(`id_strategy` 四个正确取值、`data_type` 十二个正确取值、`cardinality` 三个正确取值、`frequency` 两个正确取值或不提供)在 `dry_run` 阶段仍然返回 `valid=true` 并附带 `plan_hash`,不能因为新增校验误伤合法路径。 +10. 回归验证:`create_index_label` 的 `base_type` 校验行为保持不变(`manage_schema.py:494-524` 现有逻辑不受影响)。 +11. 新增的模块级枚举常量(`PROPERTY_KEY_DATA_TYPES` 等)与对应 `*_methods` 字典(`data_type_methods` 等)的 key 集合完全一致——建议加一条测试直接断言 `set(PROPERTY_KEY_DATA_TYPES) == set(data_type_methods.keys())`,防止未来只改一处导致 validate 和 apply 的合法值集合漂移。 + +### 1.5 需要新增的测试用例清单(不新增代码,仅列出测试点) + +全部添加到 `hugegraph-mcp/tests/test_manage_schema.py`: + +- `test_dry_run_rejects_invalid_id_strategy_enum`:`id_strategy="FOO"`,断言 `valid is False` 且响应中无 `plan_hash` 键。 +- `test_dry_run_rejects_id_strategy_with_trailing_whitespace`:`id_strategy="PRIMARY_KEY "`,断言 `valid is False`。 +- `test_dry_run_rejects_non_string_id_strategy`:`id_strategy=123`,断言 `valid is False`。 +- `test_dry_run_accepts_all_valid_id_strategies`:遍历 `PRIMARY_KEY`/`CUSTOMIZE_STRING`/`CUSTOMIZE_NUMBER`/`AUTOMATIC`(`CUSTOMIZE_STRING`/`CUSTOMIZE_NUMBER`/`AUTOMATIC` 不提供 `primary_keys`),断言均 `valid is True` 且含 `plan_hash`。 +- `test_dry_run_rejects_invalid_data_type_enum`:`create_property_key` 的 `data_type="FOOBAR"`,断言 `valid is False`。 +- `test_dry_run_rejects_invalid_cardinality_enum`:`cardinality="MANY"`,断言 `valid is False`。 +- `test_dry_run_rejects_invalid_aggregate_type_when_provided`:`aggregate_type="AVG"`,断言 `valid is False`;对照用例 `aggregate_type` 不提供时应 `valid is True`。 +- `test_dry_run_rejects_invalid_edge_frequency_when_provided`:`frequency="ONCE"`,断言 `valid is False`;对照用例 `frequency` 不提供时应 `valid is True`。 +- `test_dry_run_rejects_non_string_name`:对 `create_vertex_label`/`create_edge_label`/`create_property_key`/`create_index_label` 四种类型分别传 `name=123`,断言均 `valid is False`。 +- `test_dry_run_rejects_blank_name`:`name=" "`,断言 `valid is False`。 +- `test_dry_run_rejects_non_string_source_or_target_label`:`create_edge_label` 的 `source_label=123` 或 `target_label=""`(空格),断言 `valid is False`。 +- `test_dry_run_rejects_non_string_base_label`:`create_index_label` 的 `base_label=123`,断言 `valid is False`。 +- `test_enum_tables_stay_in_sync_with_apply_methods`:直接在测试文件里断言 `manage_schema_module.PROPERTY_KEY_DATA_TYPES`、`PROPERTY_KEY_CARDINALITIES`、`PROPERTY_KEY_AGGREGATE_TYPES`、`VERTEX_LABEL_ID_STRATEGIES`、`EDGE_LABEL_FREQUENCIES` 分别与 `_apply_property_key_options`/`_apply_vertex_label_options`/`_apply_edge_label_options` 内部对应 `*_methods` 字典的 key 集合相等(可通过 import 内部函数后用 `inspect` 或直接复制常量名断言,具体实现方式留给写测试时决定)。 +- `test_invalid_enum_input_never_reaches_apply_stage_error`:构造一个 batch,第一个操作合法(会真实创建成功——需 mock `_apply_one_operation` 或使用现有的 `_schema`/`_vertex_label` fixture 体系),第二个操作 `id_strategy="FOO"`;断言 `mode="dry_run"` 阶段就能拦截(`valid=false`),不需要真正调用 `mode="apply"` 走到 partial 状态(这是复现"部分写入"场景的最小回归用例,确保修复后同一个 batch 不会在 apply 阶段才失败)。 + +## 2. P2(建议同批修,非阻塞):`sanitize_for_response` 脱敏正则覆盖不全 + +### 2.1 问题描述 + +`hugegraph_mcp/envelope.py:98-115`(`_sanitize_text` 函数): + +```python +def _sanitize_text(value: str) -> str: + if not _may_contain_sensitive_marker(value): + return _redact_url_userinfo(value) + try: + parsed = json.loads(value) + except json.JSONDecodeError: + redacted = re.sub( + r'(?i)("?[a-z0-9_-]*(?:api_key|authorization|password|passwd|pwd|secret|token)[a-z0-9_-]*"?\s*[:=]\s*)"[^"]*"', + rf"\1\"{REDACTED_VALUE}\"", + value, + ) + redacted = re.sub( + r"(?i)(api_key|authorization|password|passwd|pwd|secret|token)=([^&\s]+)", + rf"\1={REDACTED_VALUE}", + redacted, + ) + return _redact_url_userinfo(redacted) + return json.dumps(sanitize_for_response(parsed), ensure_ascii=False) +``` + +当前两条正则各自只覆盖一种格式: + +- 第一条(`manage_schema.py` 无关,此处为 `envelope.py:104-108`)只匹配 **`"key": "value"`**(双引号包裹值,冒号或等号后跟双引号字符串)。 +- 第二条(`envelope.py:109-113`)只匹配 **`key=value`**(等号连接,值不含 `&` 或空白,无需引号)。 + +对以下三种真实格式完全不生效(已用 Python 脚本实测验证,见下): + +1. **HTTP header 打印格式**:`Authorization: Bearer abc123token`(冒号 + 空格,值不加引号)。这是 `requests` 库在打印 `response.request.headers` 或异常信息里附带请求头时最常见的格式,第一条正则要求值被双引号包裹,第二条正则要求 `=` 连接,两者都不匹配"冒号+空格+无引号裸词"这种结构。实测:脱敏前后字符串完全相同,未发生任何替换。 +2. **Python dict repr(单引号)**:`{'Authorization': 'Bearer abc123', 'token': 'xyz'}`。这是 Python 对 dict 对象做 `str()`/`repr()` 时的默认输出格式(键值都用单引号),常见于 `requests.exceptions.RequestException` 的 `args` 或 `response.headers` 被直接拼进异常消息的场景。第一条正则的 `"[^"]*"` 只认双引号,对单引号包裹的值不生效。实测:脱敏前后字符串完全相同。 +3. **无分隔符裸文本**:`request failed, token abc123 rejected`。这是最容易在自由文本异常消息里出现的形式——没有 `:` 也没有 `=`,敏感词和值之间只有空格。两条正则都要求"敏感词后紧跟冒号或等号",对这种纯自然语言拼接完全不生效。实测:脱敏前后字符串完全相同。 + +而 `_may_contain_sensitive_marker`(`envelope.py:118-120`)判断是否需要进入脱敏分支的逻辑("文本中包含敏感词或包含 `://`")对上述三种格式都能正确触发(因为 `token`/`authorization` 关键词本身存在),问题不在"是否进入脱敏分支",而在"进入分支后两条具体正则都不匹配这三种真实结构"。 + +危害:`requests` 库的异常(如 `requests.exceptions.ConnectionError`、`HTTPError`)在 `str(exc)` 时经常会把请求上下文(包括 headers)序列化进异常消息,如果异常消息最终通过 `envelope_err` 的 `details={"error": str(exc)}` 这类模式(参见 `manage_schema.py:694` 的 `details={"stage": "schema_fetch", "error": str(exc)}`)返回给调用方,上述三种格式的真实 Authorization header 或 token 值会原样出现在 MCP 响应里。 + +### 2.2 根因 + +两条正则设计时只考虑了"结构化配置/URL query string"这一类场景(JSON 字段、URL query 参数),没有覆盖"异常消息里的自然语言拼接"和"HTTP/Python 对象的字符串化表示"这两类同样常见、但结构完全不同的敏感信息载体。`_may_contain_sensitive_marker` 的判断逻辑本身是够用的("关键词打点"策略对三种新格式一样有效),缺口只在于具体替换用的正则模式集合太窄。 + +### 2.3 修复方案 + +在现有两条正则之后,追加三条新正则,分别覆盖三种缺口格式。修改位置是 `_sanitize_text` 函数体内 `except json.JSONDecodeError:` 分支(`envelope.py:103-114`): + +```python +except json.JSONDecodeError: + redacted = re.sub( + r'(?i)("?[a-z0-9_-]*(?:api_key|authorization|password|passwd|pwd|secret|token)[a-z0-9_-]*"?\s*[:=]\s*)"[^"]*"', + rf"\1\"{REDACTED_VALUE}\"", + value, + ) + redacted = re.sub( + r"(?i)(api_key|authorization|password|passwd|pwd|secret|token)=([^&\s]+)", + rf"\1={REDACTED_VALUE}", + redacted, + ) + # 新增 1:HTTP header 打印格式,冒号+空格分隔,值不加引号, + # 值本身允许包含空格(如 "Bearer abc123"),直到行尾/逗号/引号结束。 + redacted = re.sub( + r"(?i)((?:api_key|authorization|password|passwd|pwd|secret|token)\s*:\s*)" + r"([^,\"'\n]+)", + rf"\1{REDACTED_VALUE}", + redacted, + ) + # 新增 2:Python dict repr,单引号包裹 key 和 value。 + redacted = re.sub( + r"(?i)('[a-z0-9_-]*(?:api_key|authorization|password|passwd|pwd|secret|token)" + r"[a-z0-9_-]*'\s*:\s*)'[^']*'", + rf"\1'{REDACTED_VALUE}'", + redacted, + ) + # 新增 3:无分隔符裸文本,敏感词后紧跟一个"值样 token" + # (允许字母数字、下划线、短横线、点号的连续片段), + # 敏感词和值之间允许 0 个或多个空格/冒号/等号。 + redacted = re.sub( + r"(?i)\b(api_key|authorization|password|passwd|pwd|secret|token)" + r"(\s*[:=]?\s+)([a-z0-9_.\-]{4,})\b", + rf"\1\2{REDACTED_VALUE}", + redacted, + ) + return _redact_url_userinfo(redacted) +``` + +设计要点说明: + +- **新增 1(HTTP header)** 故意不限制值的字符集合(只排除逗号、引号、换行),因为 header 值可能是 `Bearer ` 这种带空格的复合值,需要整体脱敏而不是只脱敏 token 部分——否则 `Bearer` 字样保留、真正的 token 值被替换看起来更安全,但如果值格式不是 `Bearer xxx` 而是别的 scheme,遗漏就会发生。整体替换更保守。 +- **新增 2(dict repr)** 复用第一条正则(双引号版)的结构,只是把引号符号换成单引号,两条正则模式高度对称,便于维护时同步修改。 +- **新增 3(裸文本)** 是最宽泛的一条,刻意加了 `\b`(word boundary)和"值至少 4 个字符”的限制(`{4,}`),避免过度匹配把普通英文单词"token"后面跟的任意短词也脱敏掉(例如 "the token is invalid" 中 "is" 只有2个字符,不会被误判为需要脱敏的值);同时正则里 `[a-z0-9_.\-]` 不含空格,所以只脱敏紧跟在敏感词后的"一个类 token 片段",不会像新增1那样吞掉后续整句话——这是三条新正则里唯一一条需要谨慎控制误伤范围的(宽泛正则容易把正常业务文本误判成敏感信息),建议实现时先跑一遍现有测试集全集(`test_error_handling.py`、`test_manage_schema.py` 等所有涉及错误消息拼接的用例)确认没有回归。 +- 三条新正则都放在现有两条之后按顺序 `re.sub`,允许对同一段文本连续应用多条规则(例如一段文本同时包含 JSON 格式和裸文本格式的两个不同敏感字段)。 + +替代方案(如果多条正则的可维护性成为顾虑):改用一次性的"分隔符无关"策略——先用一条正则统一识别"敏感词 + 紧邻的任意非空白分隔符(`:`/`=`/空格中的 0 个或多个组合)+ 值”,用命名分组一次捕获,而不是分别为四种分隔符风格各写一条正则。这个方案实现更紧凑,但正则本身复杂度更高、调试更难,且需要同时正确处理"值可能带引号也可能不带”的两种子情况,建议只在上面的分步方案在实测中出现明显维护负担时再考虑切换。 + +### 2.4 验收标准 + +1. 输入 `"Authorization: Bearer abc123token"`,脱敏后不再包含 `abc123token` 字面值。 +2. 输入 `"{'Authorization': 'Bearer abc123', 'token': 'xyz'}"`,脱敏后 `abc123` 和 `xyz` 均被替换为 `REDACTED_VALUE`,dict 的整体结构(花括号、逗号)保持不变。 +3. 输入 `"request failed, token abc123 rejected"`,脱敏后 `abc123` 被替换,`request failed,` 和 `rejected` 两段无关文本保持原样不被误删。 +4. 回归:现有两种已支持格式(`"api_key": "xxx"` 和 `api_key=xxx`)继续正确脱敏,不因新增正则顺序问题产生双重替换或替换失败。 +5. 回归:不包含任何敏感词的普通错误消息(如 `"vertex label already exists: person"`)脱敏前后完全一致,新增的裸文本正则不会误伤正常业务文案。 +6. 回归:URL userinfo 脱敏(`_redact_url_userinfo`,`envelope.py:123-124`)逻辑不受影响,仍在所有新增正则之后最后执行一次。 + +### 2.5 需要新增的测试用例清单(不新增代码,仅列出测试点) + +全部添加到 `hugegraph-mcp/tests/test_error_handling.py`(该文件已在 `f2ccdab` 提交中新增,是脱敏/错误映射类测试的现有落点): + +- `test_sanitize_redacts_http_header_format`:输入 `"Authorization: Bearer abc123token"`,断言输出不含 `abc123token`。 +- `test_sanitize_redacts_python_dict_repr_format`:输入 `"{'Authorization': 'Bearer abc123', 'token': 'xyz'}"`,断言输出不含 `abc123` 和 `xyz`,且仍是合法可读的类 dict 字符串结构。 +- `test_sanitize_redacts_bare_text_format`:输入 `"request failed, token abc123 rejected"`,断言输出不含 `abc123`,且包含 `request failed` 和 `rejected` 字面片段(确认无关文本未被误删)。 +- `test_sanitize_preserves_existing_quoted_json_format`:输入现有支持的 `'{"api_key": "sk-xxx"}'` 格式,断言仍正确脱敏(防止新增正则顺序或组合导致回归)。 +- `test_sanitize_preserves_existing_query_string_format`:输入现有支持的 `"token=abc123&other=1"` 格式,断言 `token=` 后的值被脱敏,`other=1` 不受影响。 +- `test_sanitize_does_not_redact_unrelated_text`:输入不含任何敏感词的普通句子(如 `"vertex label already exists: person"`),断言脱敏前后完全相等。 +- `test_sanitize_bare_text_does_not_over_match_short_values`:输入类似 `"the token is invalid"` 的句子(`token` 后紧跟的 `is` 只有 2 个字符),断言 `is` 未被误判为需脱敏的值(验证新增正则 3 的 `{4,}` 长度下限按预期工作,不误伤正常英文短词)。 + +## 3. 次要问题(可选一并给方案,不强制) + +### 3.1 `_id_dedupe_key` 对混合 key 类型 dict 输入抛裸 `TypeError` + +**问题描述** + +`hugegraph_mcp/tools/query_graph_data.py:424-429`: + +```python +def _id_dedupe_key(item: Any, *, target: str) -> str: + return json.dumps( + {"target": target, "type": type(item).__name__, "value": item}, + sort_keys=True, + default=str, + ) +``` + +被 `_normalize_ids`(`query_graph_data.py:406-421`)在一个 `for` 循环内部直接调用,循环体和调用方(`query_graph_data.py:83`,`operation == "get_by_ids"` 分支)都没有包裹 `try/except`。 + +已用 Python 3.14 环境实测:当 `item` 是一个混合 key 类型的 dict(如 `{1: "a", "1": "b"}`)时,`json.dumps(..., sort_keys=True)` 在内部尝试对 dict 的 key 排序时会比较 `int` 和 `str`,抛出: + +``` +TypeError: '<' not supported between instances of 'str' and 'int' +``` + +这是一个裸 `TypeError`,不会被 `query_graph_data` 函数体内现有的 `try/except Exception as exc: return _query_error(exc)`(`query_graph_data.py:104-105`)捕获——因为 `_normalize_ids` 的调用点(`query_graph_data.py:83`)在这个 `try` 块**之前**执行(`try` 块从 `manager = _graph_manager()` 那一行开始,`normalized_ids = _normalize_ids(...)` 在 `try` 块外)。所以这个 `TypeError` 会直接从 `query_graph_data` 函数抛出,不经过任何 envelope 包装,违反了"所有 MCP 工具通过 envelope_ok/envelope_err 返回一致结构"的契约(`envelope.py:14-17` 模块 docstring 明确写的设计原则)。 + +**根因** + +`_normalize_ids` 的调用被安排在获取 `manager` 和真正发起查询的 `try` 块之外,是因为它属于"输入规范化"步骤,逻辑上被当作和后面的 `_validate_inputs`(已经在 `try` 块外,且自身有完善的返回值式错误处理,不会抛异常)同一类操作。但 `_normalize_ids` 内部调用的 `_id_dedupe_key` 实际上可能因为输入数据本身的结构(如混合类型的 dict key)抛出未预期的异常,这一点在原实现里没有被考虑到。 + +**修复方案(不新增代码行数结构,只调整既有代码的包裹范围/前置校验方式,二选一)** + +**方案 A(推荐):把 `_normalize_ids` 调用纳入现有 try 块** + +把 `query_graph_data.py:82-86` 的 `normalized_ids = ...` 这一行移动到 `query_graph_data.py:89` 的 `try:` 块内部(即 `manager = _graph_manager()` 之前或之后均可,逻辑上放在 `manager = _graph_manager()` 之前更合理,因为不依赖 manager),让现有的 `except Exception as exc: return _query_error(exc)` 自然覆盖到这条路径。这是改动范围最小的方案——不需要新写一个 try/except,只是把已有的一行代码挪到已有 try 块的覆盖范围内。需要确认 `_query_error`(错误映射函数)对一个裸 `TypeError`(不是 HugeGraph 相关异常)能给出合理的 `error_type`(可能会落到默认的 `SERVER_ERROR` 或类似兜底分类,这属于 `error_mapping.py` 的既有兜底行为,不需要为这一种情况新增专门分类,只要不再是裸异常穿透即可)。 + +**方案 B:在 `_id_dedupe_key` 之前做前置类型校验,提前在 `_validate_inputs` 阶段拦截** + +在 `_validate_inputs`(`query_graph_data.py:126-...`)的 `get_by_ids` 分支(现有的 `query_graph_data.py:156-168`,已经在检查 `ids` 是否是非空列表、是否含空值、长度是否超限)里,新增一条对 `ids` 列表内每个元素的类型检查:如果 `target="edge"` 场景允许 dict 作为复合 id 表示(需先确认这一点在当前实现里是否真的是设计意图——如果混合 key 类型的 dict 本身就不是一个当前设计里"允许"的合法输入形态,那么这条校验应该直接拒绝"dict 类型的 id 元素",而不是只拒绝"key 类型混合"这一种子情况,需要先确认 dict 类型的 id 元素在业务上是否本来就该被支持)。这个方案需要先回答一个业务问题:dict 类型的 id 元素是否是当前 `get_by_ids` 支持的合法输入?如果不是,直接在 `_validate_inputs` 里加"每个 id 元素不能是 dict/list"的类型白名单校验即可,比方案 A 更早拦截、报错信息也更明确("id 元素类型不支持",而不是"序列化失败"这种偏技术细节的错误)。如果 dict 本来就是合法输入形态(例如用于表示某种复合 id),则应该采用方案 A,因为方案 B 无法在不改变"允许 dict"这一行为的前提下解决"dict 内部 key 类型混合"这个更细的问题。 + +由于本次评审未确认 dict 类型 id 元素在业务上是否为设计支持的输入形态,**建议默认采用方案 A**(改动更小、不依赖对这一业务问题的判断,且能覆盖"混合 key 类型 dict"之外任何其他未预见的序列化异常输入),方案 B 作为在确认"dict 不应该是合法 id 元素"之后的加强项。 + +**验收标准** + +- 输入 `ids=[{1: "a", "1": "b"}]` 调用 `query_graph_data(target="vertex", operation="get_by_ids", ids=[...])`,返回值必须是一个合法的 envelope 结构(`ok=False`,`error` 字段非空),不能抛出未捕获的 `TypeError`。 +- 回归:现有所有合法 `ids` 输入(字符串、整数、正常结构的 dict)的去重行为不受影响。 + +**需要新增的测试用例清单** + +添加到 `hugegraph-mcp/tests/test_query_graph_data_tool.py`: + +- `test_get_by_ids_with_mixed_key_type_dict_returns_envelope_error`:`ids=[{1: "a", "1": "b"}]`,断言返回值是合法 envelope(`ok is False`,包含 `error.type`),不抛异常。 +- `test_get_by_ids_dedupe_still_works_for_normal_inputs`:现有正常输入(字符串重复 id)的去重行为回归测试,确认修复没有改变正常路径行为。 + +### 3.2 `format_vertex_id_path` 与 `format_edge_id_path` 序列化语义不对称 + +**问题描述** + +`hugegraph-python-client/src/pyhugegraph/utils/id_format.py:50-60`: + +```python +def format_vertex_id_path(vertex_id, allow_none: bool = False) -> str | None: + formatted_id = format_vertex_id(vertex_id, allow_none=allow_none) + if formatted_id is None: + return None + return quote(formatted_id, safe="") + + +def format_edge_id_path(edge_id) -> str: + if edge_id is None: + raise ValueError("The edge id can't be None") + return quote(str(edge_id), safe="") +``` + +`format_vertex_id_path` 内部先调用 `format_vertex_id`(`id_format.py:27-47`),后者会用 `json.dumps(vertex_id, allow_nan=False)` 把 vertex id 包装成一个 JSON 字符串(例如整数 `123` 会变成字符串 `"123"` 带外层引号,字符串 `"abc"` 会变成 `"\"abc\""` 带转义引号),然后再对这个 JSON 字符串整体做 `quote`。而 `format_edge_id_path` 直接对 `str(edge_id)` 做 `quote`,不经过 `json.dumps` 这一层。 + +两者的行为差异是有意为之还是遗漏尚不确定——vertex id 需要 `json.dumps` 包装大概率是因为 HugeGraph server 端对 vertex id 的路径参数格式要求本身就是"JSON 字面量字符串"(区分数字型 id 和字符串型 id 需要依赖 JSON 的类型语法,比如 `123` vs `"123"`),而 edge id 在 HugeGraph 里本身就是一个复合字符串(形如 `S1:alice>11>>S2:bob`,参照 `graph_mcp_review_fix_plan.md` Fix-11 提到的真实 edge id 格式),不需要额外的 JSON 类型区分。这个假设合理,但当前代码里没有任何注释说明这一点,容易让后续维护者误以为是"漏了一步 json.dumps 的 bug”而去"修复"它,破坏实际预期行为。 + +**修复方案** + +不改变任何函数行为,只在两个函数各自加一条简短 docstring,说明各自的设计意图和差异原因: + +```python +def format_vertex_id_path(vertex_id, allow_none: bool = False) -> str | None: + """将 vertex id 格式化为 URL path 片段。 + + 先经过 format_vertex_id 的 json.dumps 包装, + 以 JSON 字面量语法区分数字型 id(如 123)和字符串型 id(如 "123")—— + HugeGraph server 端按此语法解析 vertex id 的类型,与 edge id 的语义不同。 + """ + formatted_id = format_vertex_id(vertex_id, allow_none=allow_none) + if formatted_id is None: + return None + return quote(formatted_id, safe="") + + +def format_edge_id_path(edge_id) -> str: + """将 edge id 格式化为 URL path 片段。 + + edge id 本身是 HugeGraph 生成的复合字符串(如 "S1:alice>11>>S2:bob"), + 不需要 JSON 类型区分,直接对字符串形式做 URL 编码。 + """ + if edge_id is None: + raise ValueError("The edge id can't be None") + return quote(str(edge_id), safe="") +``` + +**验收标准** + +- 两个函数行为完全不变(这是纯文档补充,无行为改动)。 +- docstring 准确反映当前实际实现逻辑,不引入与代码不符的描述。 +- 建议在合入前找到当初引入这一差异的提交(`c76770f fix(client): encode edge ids in graph API paths`)确认这确实是有意设计,而不是本文档基于代码结构反推出的合理化解释——如果原作者确认这是遗漏而非设计,应该走单独的行为修复而不是补文档掩盖问题。 + +(这一条不需要新增测试,因为不改变行为。) + +## 4. 修复顺序建议 + +1. 先做 P1(阻塞项),因为它直接影响 dry-run 的正确性保证,是本分支"防止部分写入"这一安全设计的核心承诺。 +2. P2 建议紧跟 P1 同批修,因为改动范围小(只涉及一个函数内的正则表达式),且是真实的信息泄露风险。 +3. 次要项 3.1(`_id_dedupe_key` 异常处理)建议一并评估修复方案 A,改动量极小(挪动一行代码位置),性价比高。 +4. 次要项 3.2(docstring 补充)优先级最低,可以放到任意一次顺手的小改动里处理,或者单独发一个纯文档 PR。 diff --git a/hugegraph-mcp/docs/graph_mcp_review_fix_plan.md b/hugegraph-mcp/docs/graph_mcp_review_fix_plan.md new file mode 100644 index 000000000..73a5eac8e --- /dev/null +++ b/hugegraph-mcp/docs/graph_mcp_review_fix_plan.md @@ -0,0 +1,396 @@ +# HugeGraph MCP Review Fix Plan + +本文档基于 `graph-mcp` 分支对 `hugegraph-mcp` 模块的审查结果,拆解后续修复任务、具体改动点和验收标准。 + +目标是把当前分支从“功能可用”推进到“可稳定评审、可真实 HugeGraph 回归、错误语义可信、后续易维护”的状态。 + +## 0. 当前修复状态 + +截至本轮修复,Phase 0、Phase 1、Phase 2 和 Phase 3 的核心项已落地: + +- Phase 0: diff check、ruff check、format check、pytest marker 入口已修复;2026-07-06 复核发现的 `tests/test_manage_schema.py` 格式化漂移已重新格式化。 +- Phase 1: PRIMARY_KEY schema 校验、badcase 错误分类、edge id URL 编码均已修复并补测试。 +- Phase 2: toolset 生效时机、AI disabled 语义、table import disabled suggestion 已同步到代码和文档。 +- Phase 3: 已抽取轻量 `confirmable_workflow` helper,并接入 schema/data/mutate 三条写入确认链路。 +- Post-review: `mutate_graph_properties` post-read 异常已在 success data 中脱敏;schema object missing 已优先归类为 `SCHEMA_MISMATCH`;admin/debug 和 table import 的 `FEATURE_DISABLED` 文案已去掉误导性的 V1 表述;README 已明确默认 `v2_core` 注册 13 个工具,其中 11 个普通稳定工具、2 个 admin/debug 工具默认阻断。 +- 真实 HG 测试已新增 edge by-id typed query 与 edge property append/eliminate 覆盖;schema 创建后增加可见性等待,降低服务端异步可见性导致的瞬态失败。 + +本轮已复跑并通过: + +```bash +uv run --project hugegraph-mcp ruff format --check hugegraph-mcp +uv run --project hugegraph-mcp pytest hugegraph-mcp/tests/test_error_handling.py hugegraph-mcp/tests/test_mutate_graph_properties_tool.py hugegraph-mcp/tests/test_v1_stable_tools.py hugegraph-mcp/tests/test_import_graph_data_tool.py hugegraph-mcp/tests/test_manage_schema.py -q +RUN_MCP_REAL_HUGEGRAPH_TESTS=1 uv run --project hugegraph-mcp pytest hugegraph-mcp/tests/integration/test_real_write_path.py -vv +``` + +真实 HugeGraph 集成测试本轮已在本机环境通过;合入前仍建议在 PR 描述中记录 HugeGraph Server 版本、图空间、图名和命令输出摘要。 + +## 1. 修复原则 + +- 先修确定性阻塞项,再修真实行为问题,最后做结构治理。 +- 每个行为修复必须有至少一个对应单元测试;涉及 HugeGraph Server 行为的修复必须补真实 HG 回归测试。 +- 不扩大公开 MCP 工具契约;只修正当前 `v2_core` / `v1` 已声明能力。 +- 不把 HugeGraph Server 自身限制包装成 MCP 功能缺失;错误信息需要明确区分“服务端限制”“输入错误”“连接失败”“功能禁用”。 +- 所有写路径继续保留 `dry_run -> plan_hash -> confirm` 安全链。 + +## 2. 总体验收门槛 + +全部修复完成后,必须通过以下检查: + +```bash +cd /Users/uleng/Code/hugegraph-ai + +git diff --check apache/main...HEAD -- hugegraph-mcp +uv run --project hugegraph-mcp ruff check hugegraph-mcp +uv run --project hugegraph-mcp ruff format --check hugegraph-mcp +uv run --project hugegraph-mcp pytest hugegraph-mcp/tests -m "not live and not integration and not real_hugegraph and not llm" +RUN_MCP_REAL_HUGEGRAPH_TESTS=1 uv run --project hugegraph-mcp pytest hugegraph-mcp/tests/integration/test_real_write_path.py -vv +``` + +如果本地 HugeGraph Server 不可用,真实 HG 测试可以暂缓,但必须在 PR 合入前补跑,并在 PR 描述中写明 HugeGraph Server 版本、图空间、图名和命令输出摘要。 + +## 3. 分阶段修复计划 + +| 阶段 | 优先级 | 目标 | 允许合并到下一阶段的条件 | +| --- | --- | --- | --- | +| Phase 0 | P0 | 清理发布和 CI 阻塞项 | diff check、ruff check、format check、非真实 HG 测试全部通过 | +| Phase 1 | P1 | 修正会影响真实使用的行为问题 | 新增 badcase 单测通过,真实 HG 写路径通过 | +| Phase 2 | P2 | 修正文档契约和错误提示 | README 与实际行为一致,错误码和 suggestion 可操作 | +| Phase 3 | P2/P3 | 降低后续维护成本 | 重复确认链路和错误映射收敛,现有测试不退化 | + +## 4. Phase 0: 发布卫生和测试入口 + +### Fix-00: 清理格式和空白问题 + +**问题** + +- `git diff --check` 报告 `hugegraph_mcp/__init__.py` 文件尾部多余空行。 +- `ruff format --check` 报告 5 个文件需要格式化: + - `hugegraph_mcp/tools/inspect_schema.py` + - `hugegraph_mcp/tools/manage_schema.py` + - `hugegraph_mcp/tools/mutate_graph_properties.py` + - `hugegraph_mcp/tools/query_graph_data.py` + - `tests/test_manage_schema.py` + +**具体修复** + +1. 删除 `hugegraph_mcp/__init__.py` 末尾多余空行。 +2. 执行 `uv run --project hugegraph-mcp ruff format hugegraph-mcp`。 +3. 检查格式化 diff,确认没有引入行为变化。 + +**验收标准** + +```bash +git diff --check apache/main...HEAD -- hugegraph-mcp +uv run --project hugegraph-mcp ruff format --check hugegraph-mcp +uv run --project hugegraph-mcp ruff check hugegraph-mcp +``` + +三条命令必须全部通过。 + +### Fix-01: 统一 pytest markers + +**问题** + +根 `pyproject.toml` 开启了 `--strict-markers --strict-config`,但真实 HG 测试使用的 `real_hugegraph`、`live`、`llm` markers 只在 `hugegraph-mcp/pyproject.toml` 中声明。从仓库根收集测试时,存在 unknown marker 失败风险。 + +**具体修复** + +1. 在根 `pyproject.toml` 的 `[tool.pytest.ini_options].markers` 中补充: + - `live: requires a real HugeGraph Server` + - `real_hugegraph: requires a real HugeGraph Server` + - `llm: requires HugeGraph-AI or an external LLM provider` +2. 保留 `hugegraph-mcp/pyproject.toml` 中的 markers,除非确认模块独立运行不再需要。 +3. 补充一条文档说明:推荐从 `uv run --project hugegraph-mcp pytest ...` 运行 MCP 测试。 + +**验收标准** + +```bash +uv run pytest --collect-only hugegraph-mcp/tests/integration/test_real_write_path.py +uv run --project hugegraph-mcp pytest --collect-only hugegraph-mcp/tests/integration/test_real_write_path.py +``` + +两条命令都不能出现 unknown marker 或 strict config 错误。 + +## 5. Phase 1: 行为正确性和严谨性 + +### Fix-10: PRIMARY_KEY vertex label 必须有 primary_keys + +**问题** + +`create_vertex_label` 默认 `id_strategy=PRIMARY_KEY`,但缺少 `primary_keys` 时当前只产生 warning。这样 dry-run 可能返回 `valid=true` 和 `plan_hash`,真正 apply 时才失败,并且可能已经创建了前面的 property keys,造成部分写入。 + +**具体修复** + +1. 修改 `hugegraph_mcp/tools/manage_schema.py` 的 schema operation 校验逻辑。 +2. 当 `type=create_vertex_label` 且 `id_strategy` 为空或 `PRIMARY_KEY` 时: + - `primary_keys` 必须是非空列表。 + - `primary_keys` 中的每个字段必须出现在该 vertex label 的 `properties` 中。 + - `primary_keys` 中的每个字段必须已经存在于 live schema,或在同批 `create_property_key` 操作中创建。 +3. 对 `id_strategy=AUTOMATIC`、`CUSTOMIZE_STRING`、`CUSTOMIZE_NUMBER` 不强制要求 `primary_keys`。 +4. 将当前“无 primary_keys warning”改成只对非 PRIMARY_KEY 策略保留,或直接删除该 warning。 + +**需要新增或调整的测试** + +- `tests/test_manage_schema.py` + - `PRIMARY_KEY` 缺少 `primary_keys` 时,`dry_run` 返回 `valid=false`。 + - `PRIMARY_KEY` 的 `primary_keys` 不在 `properties` 中时,返回 validation error。 + - `PRIMARY_KEY` 引用不存在的 property key 时,返回 validation error。 + - `AUTOMATIC` 不提供 `primary_keys` 时仍可 dry-run 成功。 + +**验收标准** + +- 缺主键的 PRIMARY_KEY schema 不再返回 `plan_hash`。 +- dry-run 阶段即可阻断会导致 apply 半路失败的输入。 +- 以下命令通过: + +```bash +uv run --project hugegraph-mcp pytest hugegraph-mcp/tests/test_manage_schema.py -v +``` + +### Fix-11: edge id 查询和属性修改必须处理 URL reserved characters + +**问题** + +HugeGraph edge id 可能包含 `>`、`:` 等 URL path reserved characters。当前 `query_graph_data_tool` 的 edge `get_by_id`、`mutate_graph_properties_tool` 的 edge target fetch 和 edge append/eliminate 直接传 raw edge id 给 pyhugegraph。如果底层 client 未编码 path 参数,真实请求可能失败或命中错误路径。 + +**具体修复** + +优先方案: + +1. 在 `hugegraph-python-client` 的 edge-by-id URL 构造层统一 URL encode path 参数。 +2. 确认 vertex id 和 edge id 的 path 参数都不会被重复编码。 +3. 在 `hugegraph-mcp` 侧保留原始 id 作为业务 id,不在多个调用点重复编码。 + +备选方案: + +1. 如果不改 client,则在 MCP 内新增一个很薄的 HugeGraph edge id helper。 +2. 所有 edge by-id 读取、append、eliminate 都通过同一个 helper 调用。 +3. 禁止各工具自行拼接或自行编码。 + +**需要新增或调整的测试** + +- `hugegraph-python-client` 若修改 client: + - URL router/path format 测试覆盖 `S1:alice>11>...` 这类 edge id。 +- `hugegraph-mcp/tests/test_query_graph_data_tool.py` + - mock client 断言 edge id 不被 MCP 破坏。 +- `hugegraph-mcp/tests/test_mutate_graph_properties_tool.py` + - mock client 断言 edge mutate 使用统一 helper。 +- `hugegraph-mcp/tests/integration/test_real_write_path.py` + - 创建两个 vertex 和一条 edge。 + - 从写入结果或查询结果拿真实 edge id。 + - 用 `query_graph_data_tool(target="edge", operation="get_by_id")` 查询成功。 + - 用 `mutate_graph_properties_tool(target="edge")` append/eliminate 一个 nullable property 成功。 + +**验收标准** + +- 真实 HugeGraph 上,包含 reserved characters 的 edge id 可以完成 get、append、eliminate。 +- 修复不改变公开 MCP 参数形态,用户仍传 HugeGraph 原始 edge id。 +- 以下命令通过: + +```bash +uv run --project hugegraph-mcp pytest hugegraph-mcp/tests/test_query_graph_data_tool.py hugegraph-mcp/tests/test_mutate_graph_properties_tool.py -v +RUN_MCP_REAL_HUGEGRAPH_TESTS=1 uv run --project hugegraph-mcp pytest hugegraph-mcp/tests/integration/test_real_write_path.py -vv +``` + +### Fix-12: badcase 错误分类必须准确 + +**问题** + +当前错误映射过于粗糙: + +- HugeGraph no-index 错误只匹配 `noindexexception` 或 `no index`,不能覆盖真实错误文本中的 `not indexed` / `may not match secondary condition`。 +- mutation 目标不存在时,从 `getVertexById` / `getEdgeById` 抛出的 NotFound 类错误被包装为 `CONNECTION_FAILED`,用户会误以为是连接问题。 + +**具体修复** + +1. 新增或扩展统一错误分类 helper,例如 `hugegraph_mcp/error_mapping.py`。 +2. no-index 判断至少覆盖: + - `noindexexception` + - `no index` + - `not indexed` + - `may not match secondary condition` +3. target not found 判断至少覆盖: + - HTTP 404 + - `NotFound` + - `not found` + - HugeGraph 返回体中明确的 vertex/edge 不存在信息 +4. `query_graph_data_tool` 对 no-index 返回: + - `error_type=NO_INDEX` + - `retryable=false` + - suggestion 指向创建索引或改用 exact id 查询。 +5. `mutate_graph_properties_tool` 对目标不存在返回: + - `error_type=NOT_FOUND`,如果当前 `ErrorType` 没有该枚举,则新增。 + - `retryable=false` + - suggestion 指向确认 id、target 类型和图空间。 + +**需要新增或调整的测试** + +- `tests/test_query_graph_data_tool.py` + - 模拟 `not indexed` 文本,断言 `NO_INDEX`。 + - 模拟 `may not match secondary condition` 文本,断言 `NO_INDEX`。 +- `tests/test_mutate_graph_properties_tool.py` + - 模拟 vertex 404,断言 `NOT_FOUND`。 + - 模拟 edge 404,断言 `NOT_FOUND`。 +- `tests/test_error_handling.py` + - 如果新增统一 error mapper,在这里补纯函数测试。 + +**验收标准** + +- badcase 不再统一掉进 `SERVER_ERROR` 或 `CONNECTION_FAILED`。 +- 错误响应中的 `suggestion` 能指导下一步动作。 +- 以下命令通过: + +```bash +uv run --project hugegraph-mcp pytest hugegraph-mcp/tests/test_query_graph_data_tool.py hugegraph-mcp/tests/test_mutate_graph_properties_tool.py hugegraph-mcp/tests/test_error_handling.py -v +``` + +## 6. Phase 2: 文档契约和用户提示 + +### Fix-20: 明确 HUGEGRAPH_MCP_TOOLSET 的生效时机 + +**问题** + +README 把 `HUGEGRAPH_MCP_TOOLSET` 描述为公开工具契约选择器,但该变量不在 `CONFIG_ENV_NAMES` 中。实际工具注册发生在 server import / startup 期间,用户容易误以为它和普通 config 一样可被运行期缓存刷新感知。 + +**具体修复** + +1. 在 README 和中文 README 的 Toolset Selection 段落明确: + - `HUGEGRAPH_MCP_TOOLSET` 在 MCP server 启动和工具注册时生效。 + - 修改该变量后需要重启 MCP server。 +2. 在 `inspect_graph_tool` 输出中继续展示当前 toolset。 +3. 可选:将 `HUGEGRAPH_MCP_TOOLSET` 加入配置诊断元数据,但不要让它暗示运行期可热切换。 + +**验收标准** + +- README 明确写出“修改后需要重启 MCP server”。 +- `inspect_graph_tool` 仍能显示当前 toolset。 +- 文档没有承诺热更新。 + +### Fix-21: AI disabled 错误语义可操作 + +**问题** + +`HUGEGRAPH_MCP_ALLOW_AI=false` 时,AI 请求返回 `HUGEGRAPH_AI_UNAVAILABLE` 和 `AI calls are disabled`,但没有 suggestion。用户难以区分“功能被配置关闭”和“AI 服务不可用”。 + +**具体修复** + +推荐方案: + +1. 当 `allow_ai=false` 时返回 `FEATURE_DISABLED`。 +2. suggestion 写明:设置 `HUGEGRAPH_MCP_ALLOW_AI=true` 并重启 MCP server,或使用不依赖 AI 的工具。 +3. details 保留 `method` 和 `url`,但不要泄露密码、token 或 auth header。 + +兼容方案: + +1. 继续返回 `HUGEGRAPH_AI_UNAVAILABLE`。 +2. message 改为 `AI calls are disabled by configuration`。 +3. 必须增加 suggestion 和 `details.reason=allow_ai_false`。 + +**需要新增或调整的测试** + +- `tests/test_hugegraph_ai_client.py` + - `allow_ai=false` 时断言 error_type、message、suggestion、retryable。 + +**验收标准** + +- 用户能从错误响应直接判断是否需要改配置。 +- 不泄露认证信息。 + +### Fix-22: 修正 table import disabled suggestion + +**问题** + +`import_graph_data_tool(mode="table")` 返回的 suggestion 写成 `Use mode='extract' with extract_graph_data_tool instead`,但实际公开入口是 `import_graph_data_tool(mode="extract")`。 + +**具体修复** + +1. 修改 `server.py` 中 `mode=="table"` 的 suggestion。 +2. 推荐文案: + - `Use import_graph_data_tool(mode='extract') for text extraction, then import_graph_data_tool(mode='ingest') for validated graph_data.` + +**需要新增或调整的测试** + +- `tests/test_import_graph_data_tool.py` + - 断言 table disabled 响应中的 suggestion 包含正确入口。 + +**验收标准** + +- 文案不再引用不存在或误导性的调用方式。 + +## 7. Phase 3: 可维护性治理 + +### Fix-30: 收敛 HugeGraph 错误映射 + +**问题** + +Gremlin、typed query、mutation 等工具各自用字符串匹配 HugeGraph 错误,导致同类错误在不同入口返回不同 `error_type`、`retryable` 和 suggestion。 + +**具体修复** + +1. 新增统一错误映射模块,例如: + - `hugegraph_mcp/error_mapping.py` +2. 提供纯函数: + - `classify_hugegraph_exception(exc) -> ErrorClassification` + - `classify_hugegraph_error_message(message) -> ErrorClassification` +3. `ErrorClassification` 至少包含: + - `error_type` + - `retryable` + - `suggestion` + - `reason` +4. 先接入 `query_graph_data_tool` 和 `mutate_graph_properties_tool`。 +5. 第二步再评估是否接入 `gremlin_tools.py`,避免一次性改动过大。 + +**验收标准** + +- no-index、not-found、connection 类错误的分类测试集中在一个测试文件中。 +- 工具层只补充 `source`、`target`、`id` 等上下文,不再重复散落字符串匹配。 + +### Fix-31: 抽象 confirmable write workflow + +**问题** + +schema、graph data、ingest 等路径都实现了类似的 `dry_run -> plan_hash -> readonly -> confirm -> execute -> verify` 状态机。重复实现会让安全链、过期提示、hash context 和错误码逐步漂移。 + +**具体修复** + +1. 先不要大规模重写业务逻辑。 +2. 新增内部 helper,例如 `hugegraph_mcp/confirmable_workflow.py`。 +3. 第一阶段只抽公共校验: + - readonly preview 处理。 + - `confirm=true` 时 required fields 检查。 + - `plan_hash` / `nonce` / `expires_at` 校验错误包装。 +4. 第二阶段再抽执行模板: + - `validate_payload` + - `build_plan_context` + - `execute` + - `verify` +5. 每接入一个工具,就保留原测试并新增至少一个 stale plan / readonly / expired plan 回归测试。 + +**验收标准** + +- schema/data/import 三类写路径在 readonly、plan mismatch、plan expired 时返回一致错误结构。 +- 现有 plan_hash 测试和 write-path 测试全部通过。 +- PR diff 中业务逻辑变化可审查,不出现一次性大搬迁导致行为难以确认。 + +## 8. 建议 PR 拆分 + +| PR | 包含修复 | 原因 | +| --- | --- | --- | +| PR-1 | Fix-00, Fix-01 | 纯工程卫生和测试入口,风险低,先解除阻塞 | +| PR-2 | Fix-10, Fix-12 | schema 严谨性和 badcase 错误语义,影响用户输入校验 | +| PR-3 | Fix-11 | edge id 可能涉及 `hugegraph-python-client`,需要独立评审和真实 HG 验证 | +| PR-4 | Fix-20, Fix-21, Fix-22 | 文档和错误提示契约修复 | +| PR-5 | Fix-30, Fix-31 | 维护性治理,等行为稳定后再做 | + +## 9. 最终验收清单 + +修复完成后,逐项确认: + +- [ ] `git diff --check apache/main...HEAD -- hugegraph-mcp` 通过。 +- [ ] `ruff check` 和 `ruff format --check` 通过。 +- [ ] 非真实 HG 测试通过,且新增 badcase 测试覆盖本计划中的所有错误路径。 +- [ ] 真实 HG 测试通过,至少覆盖 schema create、data import、typed query、edge by-id、edge mutate、delete、readonly gate;当前新增 `test_edge_by_id_query_and_mutate_handles_real_hugegraph_edge_id` 覆盖 edge by-id 与 edge mutate。 +- [ ] README 和 README.zh-CN 对 toolset、AI disabled、table disabled、schema apply 范围描述一致。 +- [ ] 所有写路径仍需要 `dry_run -> plan_hash -> confirm`,readonly 默认仍阻断写入。 +- [ ] PR 描述列出未修的 HugeGraph Server 原生限制,例如 no-index 查询限制和 paging+filtering 限制,避免被误解为 MCP 功能不可用。 diff --git a/hugegraph-mcp/docs/p0a-integration-checklist.md b/hugegraph-mcp/docs/p0a-integration-checklist.md new file mode 100644 index 000000000..52262135f --- /dev/null +++ b/hugegraph-mcp/docs/p0a-integration-checklist.md @@ -0,0 +1,797 @@ +# P0a Integration Checklist + +This checklist is for the next person running P0a integration validation against a real HugeGraph Server. It expands the six-step path from the P0a design into concrete MCP tool calls and expected responses. + +Use a disposable graph. Do not run this checklist against a production graph. + +## Prerequisites + +### Start HugeGraph Server + +Use the repository's existing Docker path: + +```bash +cd /Users/uleng/Code/hugegraph-ai +cp docker/env.template docker/.env +sed -i.bak "s|PROJECT_PATH=path_to_project|PROJECT_PATH=/Users/uleng/Code/hugegraph-ai|" docker/.env +cd docker +docker compose -f docker-compose-network.yml up -d hugegraph-server +curl -f http://127.0.0.1:8080/versions +``` + +`docker-compose-network.yml` also defines the RAG service, but P0a validation below only needs HugeGraph Server. HugeGraph MCP requires HugeGraph Server >= 1.7.0 because the default graphspace route is `DEFAULT/`. + +### Configure MCP + +Set these variables in the shell or MCP client process that launches `hugegraph-mcp`: + +```bash +cd /Users/uleng/Code/hugegraph-ai/hugegraph-mcp +export HUGEGRAPH_URL=http://127.0.0.1:8080 +export HUGEGRAPH_GRAPH_PATH=DEFAULT/ +export HUGEGRAPH_USER=admin +export HUGEGRAPH_PASSWORD=admin +export HUGEGRAPH_MCP_TOOLSET=v2_core +export HUGEGRAPH_MCP_READONLY=false +export HUGEGRAPH_MCP_ALLOW_AI=false +export HUGEGRAPH_MCP_ADMIN_MODE=false +``` + +Use a fresh ``, for example `p0a_check_20260705`. Create it on the HugeGraph Server side before running this checklist if it does not already exist. If your server only has the default graph, use `DEFAULT/hugegraph` only after confirming it is safe to mutate. + +Start the MCP server with the same environment: + +```bash +uv run hugegraph-mcp +``` + +The request examples below show MCP `tools/call` payloads. Paste the `name` and `arguments` into your MCP client. Replace every `` with one short unique suffix, for example `20260705a`, and keep the same suffix through all steps. Replace `` placeholders with the numeric `expires_at` value from the dry-run response, not a quoted string. + +## Step 1: Inspect Tool Contract + +Safety decision verified: `inspect_graph_tool` must expose the runtime contract version and confirm that this server is running the `v2_core` toolset before any write path is tested. + +Call: + +```json +{ + "name": "inspect_graph_tool", + "arguments": { + "include_raw_schema": false + } +} +``` + +Expected key fields: + +```json +{ + "ok": true, + "data": { + "graph": "", + "graphspace": "DEFAULT", + "hugegraph_server_status": "available", + "readonly": false, + "mcp_tool_contract_version": "2.0", + "toolset": "v2_core" + }, + "meta": { + "readonly": false, + "mcp_tool_contract_version": "2.0", + "toolset": "v2_core" + } +} +``` + +Continue only when `data.toolset` is `v2_core` and `data.readonly` is `false`. If `hugegraph_ai_status` is `unavailable`, ignore it for this P0a checklist. + +## Step 2: Create Minimal Schema Through Dry-Run And Confirm + +Safety decision verified: `apply_schema_tool(mode="apply")` is unlocked only for P0a create operations, and real schema writes must use the `dry_run -> plan_hash -> confirm` chain. The confirm step also verifies by post-reading live schema. + +Dry-run call: + +```json +{ + "name": "apply_schema_tool", + "arguments": { + "mode": "dry_run", + "operations": [ + { + "type": "create_property_key", + "name": "p0a_name_", + "data_type": "TEXT", + "cardinality": "SINGLE" + }, + { + "type": "create_property_key", + "name": "p0a_note_", + "data_type": "TEXT", + "cardinality": "SINGLE" + }, + { + "type": "create_vertex_label", + "name": "p0a_person_", + "properties": ["p0a_name_", "p0a_note_"], + "primary_keys": ["p0a_name_"], + "nullable_keys": ["p0a_note_"] + }, + { + "type": "create_edge_label", + "name": "p0a_knows_", + "source_label": "p0a_person_", + "target_label": "p0a_person_" + } + ] + } +} +``` + +Expected dry-run fields: + +```json +{ + "ok": true, + "data": { + "valid": true, + "confirmable": true, + "plan_hash": "<32 hex chars>", + "plan_context": { + "nonce": "", + "expires_at": 1780000000, + "graph_name": "", + "graphspace": "DEFAULT", + "readonly": false + }, + "mutation_summary": "Schema operations planned: create_edge_label=1, create_property_key=2, create_vertex_label=1" + } +} +``` + +Confirm call. Copy `plan_hash`, `plan_context.nonce`, and `plan_context.expires_at` exactly from the dry-run response: + +```json +{ + "name": "apply_schema_tool", + "arguments": { + "mode": "apply", + "operations": [ + { + "type": "create_property_key", + "name": "p0a_name_", + "data_type": "TEXT", + "cardinality": "SINGLE" + }, + { + "type": "create_property_key", + "name": "p0a_note_", + "data_type": "TEXT", + "cardinality": "SINGLE" + }, + { + "type": "create_vertex_label", + "name": "p0a_person_", + "properties": ["p0a_name_", "p0a_note_"], + "primary_keys": ["p0a_name_"], + "nullable_keys": ["p0a_note_"] + }, + { + "type": "create_edge_label", + "name": "p0a_knows_", + "source_label": "p0a_person_", + "target_label": "p0a_person_" + } + ], + "confirm": true, + "plan_hash": "", + "nonce": "", + "expires_at": + } +} +``` + +Expected confirm fields: + +```json +{ + "ok": true, + "data": { + "status": "applied", + "valid": true, + "applied_operations": [ + {"type": "create_property_key", "name": "p0a_name_"}, + {"type": "create_property_key", "name": "p0a_note_"}, + {"type": "create_vertex_label", "name": "p0a_person_"}, + {"type": "create_edge_label", "name": "p0a_knows_"} + ], + "schema_summary": { + "propertykeys": [ + {"name": "p0a_name_"}, + {"name": "p0a_note_"} + ], + "vertexlabels": [ + {"name": "p0a_person_"} + ], + "edgelabels": [ + {"name": "p0a_knows_"} + ] + } + } +} +``` + +Verify with `inspect_schema_tool`: + +```json +{ + "name": "inspect_schema_tool", + "arguments": { + "include_raw_schema": false, + "include_relations": true, + "include_index_labels": true, + "filter_kind": "vertex_label", + "filter_name": "p0a_person_" + } +} +``` + +Expected key fields: + +```json +{ + "ok": true, + "data": { + "filtered": { + "name": "p0a_person_", + "properties": ["p0a_name_", "p0a_note_"], + "primary_keys": ["p0a_name_"], + "nullable_keys": ["p0a_note_"] + } + } +} +``` + +Do not add `create_index_label` to this step. P0a apply intentionally rejects index create and rebuild work. + +## Step 3: Import Two Vertices And One Edge + +Safety decision verified: `import_graph_data_tool(mode="ingest")` remains the structured graph-data write path, and it must keep the same `dry_run -> plan_hash -> confirm` safety chain before writing data. + +Dry-run call: + +```json +{ + "name": "import_graph_data_tool", + "arguments": { + "mode": "ingest", + "graph_data": { + "vertices": [ + { + "label": "p0a_person_", + "properties": { + "p0a_name_": "Alice" + } + }, + { + "label": "p0a_person_", + "properties": { + "p0a_name_": "Bob" + } + } + ], + "edges": [ + { + "label": "p0a_knows_", + "source_label": "p0a_person_", + "source": { + "p0a_name_": "Alice" + }, + "target_label": "p0a_person_", + "target": { + "p0a_name_": "Bob" + } + } + ] + } + } +} +``` + +Expected dry-run fields: + +```json +{ + "ok": true, + "data": { + "valid": true, + "confirmable": true, + "plan_hash": "<32 hex chars>", + "plan_context": { + "nonce": "", + "expires_at": 1780000000, + "readonly": false + }, + "mutation_summary": { + "create_vertex": 2, + "create_edge": 1 + } + } +} +``` + +Confirm call. Copy `plan_hash`, `nonce`, and `expires_at` exactly from the dry-run response: + +```json +{ + "name": "import_graph_data_tool", + "arguments": { + "mode": "ingest", + "graph_data": { + "vertices": [ + { + "label": "p0a_person_", + "properties": { + "p0a_name_": "Alice" + } + }, + { + "label": "p0a_person_", + "properties": { + "p0a_name_": "Bob" + } + } + ], + "edges": [ + { + "label": "p0a_knows_", + "source_label": "p0a_person_", + "source": { + "p0a_name_": "Alice" + }, + "target_label": "p0a_person_", + "target": { + "p0a_name_": "Bob" + } + } + ] + }, + "dry_run": false, + "confirm": true, + "plan_hash": "", + "nonce": "", + "expires_at": + } +} +``` + +Expected confirm fields: + +```json +{ + "ok": true, + "data": { + "status": "success", + "success": true, + "planned": { + "create_vertex": 2, + "create_edge": 1 + }, + "written": { + "create_vertex": 2, + "create_edge": 1 + }, + "failed_items": [] + } +} +``` + +If this returns `INVALID_GRAPH_DATA` or `SCHEMA_MISMATCH`, verify that Step 2 used the same `` and that both vertex payloads include the primary key property `p0a_name_`. + +## Step 4: Query A Vertex By ID + +Safety decision verified: `query_graph_data_tool` must provide typed bounded reads without falling back to unbounded Gremlin scans. This step proves that exact ID lookup works after import. + +HugeGraph backend vertex IDs may encode the label and primary key together in a format that varies by server version and configuration. Do not assume a specific format; always read the exact ID string back from a bounded query before using it. If your import response does not show the exact ID, first find Alice with a bounded page query: + +```json +{ + "name": "query_graph_data_tool", + "arguments": { + "target": "vertex", + "operation": "page", + "label": "p0a_person_", + "limit": 10 + } +} +``` + +Expected page fields: + +```json +{ + "ok": true, + "data": { + "target": "vertex", + "operation": "page", + "items": [ + { + "id": "", + "label": "p0a_person_", + "properties": { + "p0a_name_": "Alice" + } + } + ], + "count": 2, + "limit": 10 + } +} +``` + +Copy Alice's `items[].id` into ``, then run the exact lookup: + +```json +{ + "name": "query_graph_data_tool", + "arguments": { + "target": "vertex", + "operation": "get_by_id", + "id": "" + } +} +``` + +Expected key fields: + +```json +{ + "ok": true, + "data": { + "target": "vertex", + "operation": "get_by_id", + "items": [ + { + "id": "", + "label": "p0a_person_", + "properties": { + "p0a_name_": "Alice" + } + } + ], + "count": 1 + } +} +``` + +If you use `operation="condition"` instead, HugeGraph may return `NO_INDEX` because P0a intentionally does not create indexes. That is expected for this checklist and is covered in the troubleshooting table. + +## Step 5: Append A Property And Reject A Stale Eliminate Plan + +Safety decision verified: `mutate_graph_properties_tool` treats append and eliminate as write operations with the same dry-run/confirm chain. The stale eliminate confirm must be rejected with `TARGET_CHANGED`, proving the target snapshot digest blocks concurrent changes from being confirmed with an old plan. + +### 5.1 Append A Property + +Dry-run append call: + +```json +{ + "name": "mutate_graph_properties_tool", + "arguments": { + "target": "vertex", + "operation": "append", + "id": "", + "properties": { + "p0a_note_": "first-note" + } + } +} +``` + +Expected dry-run fields: + +```json +{ + "ok": true, + "data": { + "status": "planned", + "confirmable": true, + "risk_level": "medium", + "before": { + "properties": { + "p0a_name_": "Alice" + } + }, + "after": { + "properties": { + "p0a_name_": "Alice", + "p0a_note_": "first-note" + } + }, + "plan_hash": "<32 hex chars>", + "plan_context": { + "nonce": "|ts:", + "expires_at": 1780000000, + "readonly": false + } + } +} +``` + +Confirm append: + +```json +{ + "name": "mutate_graph_properties_tool", + "arguments": { + "target": "vertex", + "operation": "append", + "id": "", + "properties": { + "p0a_note_": "first-note" + }, + "dry_run": false, + "confirm": true, + "plan_hash": "", + "nonce": "", + "expires_at": + } +} +``` + +Expected confirm fields: + +```json +{ + "ok": true, + "data": { + "status": "applied", + "after": { + "properties": { + "p0a_name_": "Alice", + "p0a_note_": "first-note" + } + } + } +} +``` + +### 5.2 Prepare An Eliminate Plan + +Dry-run eliminate call. Do not confirm it yet: + +```json +{ + "name": "mutate_graph_properties_tool", + "arguments": { + "target": "vertex", + "operation": "eliminate", + "id": "", + "properties": { + "p0a_note_": "first-note" + } + } +} +``` + +Expected dry-run fields: + +```json +{ + "ok": true, + "data": { + "status": "planned", + "risk_level": "high", + "before": { + "properties": { + "p0a_name_": "Alice", + "p0a_note_": "first-note" + } + }, + "after": { + "properties": { + "p0a_name_": "Alice" + } + }, + "plan_hash": "", + "plan_context": { + "nonce": "", + "expires_at": 1780000000 + } + } +} +``` + +Save `ELIMINATE_PLAN_HASH`, `ELIMINATE_NONCE`, and `ELIMINATE_EXPIRES_AT`. + +### 5.3 Change The Target Before Confirm + +Use another append dry-run/confirm to modify Alice after the eliminate dry-run: + +```json +{ + "name": "mutate_graph_properties_tool", + "arguments": { + "target": "vertex", + "operation": "append", + "id": "", + "properties": { + "p0a_note_": "changed-before-eliminate-confirm" + } + } +} +``` + +Confirm that second append with the returned `plan_hash`, `nonce`, and `expires_at`: + +```json +{ + "name": "mutate_graph_properties_tool", + "arguments": { + "target": "vertex", + "operation": "append", + "id": "", + "properties": { + "p0a_note_": "changed-before-eliminate-confirm" + }, + "dry_run": false, + "confirm": true, + "plan_hash": "", + "nonce": "", + "expires_at": + } +} +``` + +Expected second append result: + +```json +{ + "ok": true, + "data": { + "status": "applied", + "after": { + "properties": { + "p0a_note_": "changed-before-eliminate-confirm" + } + } + } +} +``` + +### 5.4 Confirm The Old Eliminate Plan + +Now confirm the old eliminate plan saved in 5.2: + +```json +{ + "name": "mutate_graph_properties_tool", + "arguments": { + "target": "vertex", + "operation": "eliminate", + "id": "", + "properties": { + "p0a_note_": "first-note" + }, + "dry_run": false, + "confirm": true, + "plan_hash": "", + "nonce": "", + "expires_at": + } +} +``` + +Expected rejection: + +```json +{ + "ok": false, + "error": { + "type": "TARGET_CHANGED", + "message": "Target changed since dry_run; property mutation was not applied.", + "source": "mutate_graph_properties_tool" + }, + "next_actions": [ + "Call query_graph_data_tool to inspect the current target." + ] +} +``` + +This failure is the expected pass condition for stale-plan protection. If this call succeeds, P0a concurrency protection failed. + +## Step 6: Reject An Unbounded Gremlin Read + +Safety decision verified: `execute_gremlin_read_tool(limit_policy="reject_unbounded")` must reject safe-but-unbounded full graph reads instead of silently appending a limit or executing an unbounded scan. + +Call: + +```json +{ + "name": "execute_gremlin_read_tool", + "arguments": { + "gremlin_query": "g.V()", + "limit_policy": "reject_unbounded" + } +} +``` + +Expected fields: + +```json +{ + "ok": false, + "error": { + "type": "VALIDATION_ERROR", + "message": "Gremlin read query is unbounded and limit_policy='reject_unbounded'." + }, + "warnings": [ + "Unbounded traversal ..." + ] +} +``` + +Confirm that `executed_gremlin` is absent. The query must be rejected before execution. + +Run a bounded read to confirm the read path itself still works: + +```json +{ + "name": "execute_gremlin_read_tool", + "arguments": { + "gremlin_query": "g.V().hasLabel('p0a_person_').limit(10)", + "limit_policy": "reject_unbounded" + } +} +``` + +Expected bounded-read fields: + +```json +{ + "ok": true, + "data": { + "is_read": true, + "limit_policy": "reject_unbounded", + "original_gremlin": "g.V().hasLabel('p0a_person_').limit(10)", + "executed_gremlin": "g.V().hasLabel('p0a_person_').limit(10)", + "total": 2 + } +} +``` + +## Troubleshooting + +| Error type | Where it appears | Meaning | Action | +|---|---|---|---| +| `CONNECTION_FAILED` | `inspect_schema_tool`, `apply_schema_tool`, `import_graph_data_tool`, `mutate_graph_properties_tool`, Gremlin reads | MCP could not reach HugeGraph Server, read schema, or read a target object. | Check `docker compose ps`, `curl http://127.0.0.1:8080/versions`, `HUGEGRAPH_URL`, `HUGEGRAPH_GRAPH_PATH`, `HUGEGRAPH_USER`, and `HUGEGRAPH_PASSWORD`. Retry after the server is healthy. | +| `READONLY_VIOLATION` | Any confirm call for schema/data/property writes | `HUGEGRAPH_MCP_READONLY=true` at confirm time. Dry-run may be preview-only in readonly mode. | Set `HUGEGRAPH_MCP_READONLY=false`, restart or reconfigure the MCP process, rerun dry-run, then confirm with the new `plan_hash`. | +| `PLAN_HASH_MISMATCH` | Confirm calls for schema/data/property writes | The submitted payload, graph target, schema hash, readonly state, principal, `nonce`, or `expires_at` no longer matches the dry-run plan. | Do not edit the payload between dry-run and confirm. Rerun dry-run and copy the new `plan_hash`, `nonce`, and `expires_at`. | +| `PLAN_EXPIRED` | Confirm calls after waiting too long | The dry-run plan expired. Default plan TTL is 10 minutes. | Rerun dry-run and confirm within the returned `expires_at` window. | +| `TARGET_CHANGED` | Step 5 stale eliminate confirm | The vertex or edge was changed after dry-run and before confirm. | Treat this as a successful stale-plan rejection in Step 5. For real work, rerun `query_graph_data_tool`, review current state, then create a new dry-run plan. | +| `NO_INDEX` | `query_graph_data_tool(operation="condition")` or Gremlin `has()` filters | HugeGraph requires an index for that property query, and P0a does not create indexes. | Use `get_by_id` or bounded `page` for this checklist. Index create/rebuild belongs to the P0b workflow. | +| `PARTIAL_APPLY` | `apply_schema_tool(mode="apply")` | At least one schema operation may have been applied before a later operation failed. | Call `inspect_schema_tool`, remove already-applied operations, rerun dry-run for the remaining operations only. Do not blindly retry the original full batch. | +| `INVALID_GRAPH_DATA` or `SCHEMA_MISMATCH` | Import dry-run or property mutation dry-run | Payload does not match live schema: missing primary key, unknown property, wrong endpoint label, duplicate identity, or property not defined on the target label. | Compare the payload with `inspect_schema_tool`. In this checklist, ensure all names use the same `` and that `p0a_note_` was included in the vertex label properties. | +| `VALIDATION_ERROR` | Query parameter validation or Step 6 unbounded Gremlin rejection | Input violates the public tool contract, or the unbounded Gremlin query was rejected by policy. | Fix the input. For Step 6, `VALIDATION_ERROR` is expected for `g.V()` with `reject_unbounded`. | + +## Acceptance Conclusion Template + +Use this checklist as the final run record: + +```text +P0a integration acceptance +Graph path: DEFAULT/ +Run id: +HugeGraph Server URL: +Executed by: +Date: + +[ ] Step 1 passed: inspect_graph_tool returned ok=true, toolset=v2_core, readonly=false. +[ ] Step 2 passed: apply_schema_tool dry-run returned plan_hash and confirm applied property keys, vertex label, and edge label. +[ ] Step 3 passed: import_graph_data_tool dry-run returned plan_hash and confirm wrote 2 vertices + 1 edge. +[ ] Step 4 passed: query_graph_data_tool get_by_id returned Alice by exact vertex id. +[ ] Step 5 passed: append property applied, and stale eliminate confirm returned TARGET_CHANGED. +[ ] Step 6 passed: g.V() with reject_unbounded was rejected before execution, while bounded read succeeded. + +Overall result: PASS / FAIL +Notes: +``` diff --git a/hugegraph-mcp/hugegraph_mcp/__init__.py b/hugegraph-mcp/hugegraph_mcp/__init__.py index fdbd0208b..402359c1a 100644 --- a/hugegraph-mcp/hugegraph_mcp/__init__.py +++ b/hugegraph-mcp/hugegraph_mcp/__init__.py @@ -10,5 +10,3 @@ # 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. - -# HugeGraph MCP package init diff --git a/hugegraph-mcp/hugegraph_mcp/confirmable_workflow.py b/hugegraph-mcp/hugegraph_mcp/confirmable_workflow.py new file mode 100644 index 000000000..552dd7431 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/confirmable_workflow.py @@ -0,0 +1,67 @@ +# 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. + +"""Small helpers for dry-run -> plan_hash -> confirm write workflows.""" + +from typing import Any + +from hugegraph_mcp.envelope import ErrorType, envelope_err + + +def mark_readonly_preview( + payload: dict[str, Any], + *, + warning: str, + next_action: str, +) -> tuple[dict[str, Any], list[str], list[str]]: + payload["confirmable"] = False + payload["readonly_preview_only"] = True + return payload, [warning], [next_action] + + +def confirm_required_error( + *, + message: str, + suggestion: str, + source: str | None = None, +) -> dict[str, Any]: + return envelope_err( + ErrorType.CONFIRM_REQUIRED, + message, + suggestion=suggestion, + source=source, + ) + + +def plan_hash_error( + *, + error_type: ErrorType | None, + details: dict[str, Any], + mismatch_message: str, + expired_message: str | None = None, + suggestion: str, + source: str | None = None, +) -> dict[str, Any]: + resolved_error_type = error_type or ErrorType.PLAN_HASH_MISMATCH + message = ( + expired_message + if resolved_error_type == ErrorType.PLAN_EXPIRED and expired_message + else mismatch_message + ) + return envelope_err( + resolved_error_type, + message, + suggestion=suggestion, + source=source, + details=details, + ) diff --git a/hugegraph-mcp/hugegraph_mcp/envelope.py b/hugegraph-mcp/hugegraph_mcp/envelope.py index 2b1d38412..f96fba868 100644 --- a/hugegraph-mcp/hugegraph_mcp/envelope.py +++ b/hugegraph-mcp/hugegraph_mcp/envelope.py @@ -17,12 +17,26 @@ 前端/Agent 无需猜测返回形状,始终可安全解析。""" from enum import Enum +import json +import re from typing import Any from uuid import uuid4 from hugegraph_mcp.config import MCPConfig +REDACTED_VALUE = "***REDACTED***" +SENSITIVE_KEY_PARTS = ( + "api_key", + "authorization", + "password", + "passwd", + "pwd", + "secret", + "token", +) + + class ErrorType(str, Enum): """标准化错误类型枚举 — 按能力域划分,便于 Agent 分类处理。""" @@ -34,6 +48,8 @@ class ErrorType(str, Enum): CONFIRM_REQUIRED = "CONFIRM_REQUIRED" PLAN_HASH_MISMATCH = "PLAN_HASH_MISMATCH" PLAN_EXPIRED = "PLAN_EXPIRED" + TARGET_CHANGED = "TARGET_CHANGED" + PARTIAL_APPLY = "PARTIAL_APPLY" NOT_FOUND = "NOT_FOUND" NO_INDEX = "NO_INDEX" UNSAFE_GREMLIN = "UNSAFE_GREMLIN" @@ -53,6 +69,79 @@ def generate_request_id() -> str: return f"req-{uuid4().hex[:12]}" +def sanitize_for_response(value: Any) -> Any: + """Redact common secret shapes before returning MCP envelopes.""" + + if isinstance(value, dict): + return { + key: REDACTED_VALUE + if _is_sensitive_key(key) + else sanitize_for_response(item) + for key, item in value.items() + } + if isinstance(value, list): + return [sanitize_for_response(item) for item in value] + if isinstance(value, tuple): + return tuple(sanitize_for_response(item) for item in value) + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + if isinstance(value, str): + return _sanitize_text(value) + return value + + +def _is_sensitive_key(key: Any) -> bool: + key_lower = str(key).lower() + return any(part in key_lower for part in SENSITIVE_KEY_PARTS) + + +def _sanitize_text(value: str) -> str: + if not _may_contain_sensitive_marker(value): + return _redact_url_userinfo(value) + try: + parsed = json.loads(value) + except json.JSONDecodeError: + redacted = re.sub( + r'(?i)("?[a-z0-9_-]*(?:api_key|authorization|password|passwd|pwd|secret|token)[a-z0-9_-]*"?\s*[:=]\s*)"[^"]*"', + rf"\1\"{REDACTED_VALUE}\"", + value, + ) + redacted = re.sub( + r"(?i)(api_key|authorization|password|passwd|pwd|secret|token)=([^&\s]+)", + rf"\1={REDACTED_VALUE}", + redacted, + ) + redacted = re.sub( + r"(?i)((?:api_key|authorization|password|passwd|pwd|secret|token)\s*:\s*)" + r"([^,\"'\n]+)", + rf"\1{REDACTED_VALUE}", + redacted, + ) + redacted = re.sub( + r"(?i)('[a-z0-9_-]*(?:api_key|authorization|password|passwd|pwd|secret|token)" + r"[a-z0-9_-]*'\s*:\s*)'[^']*'", + rf"\1'{REDACTED_VALUE}'", + redacted, + ) + redacted = re.sub( + r"(?i)\b(api_key|authorization|password|passwd|pwd|secret|token)" + r"(\s*[:=]?\s+)([a-z0-9_.\-]{4,})\b", + rf"\1\2{REDACTED_VALUE}", + redacted, + ) + return _redact_url_userinfo(redacted) + return json.dumps(sanitize_for_response(parsed), ensure_ascii=False) + + +def _may_contain_sensitive_marker(value: str) -> bool: + lowered = value.lower() + return any(part in lowered for part in SENSITIVE_KEY_PARTS) or "://" in lowered + + +def _redact_url_userinfo(value: str) -> str: + return re.sub(r"(https?://)([^/@\s]+)@", rf"\1{REDACTED_VALUE}@", value) + + def build_meta( *, duration_ms: float | int | None = None, @@ -136,11 +225,11 @@ def envelope_err( ) error: dict[str, Any] = { "type": error_value, - "message": message, - "suggestion": suggestion, + "message": sanitize_for_response(message), + "suggestion": sanitize_for_response(suggestion), "retryable": retryable, "source": source, - "details": details if details is not None else {}, + "details": sanitize_for_response(details) if details is not None else {}, } envelope_meta = build_meta( diff --git a/hugegraph-mcp/hugegraph_mcp/error_mapping.py b/hugegraph-mcp/hugegraph_mcp/error_mapping.py new file mode 100644 index 000000000..3e61ff16b --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/error_mapping.py @@ -0,0 +1,85 @@ +# 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 HugeGraph exception classification helpers.""" + +from dataclasses import dataclass + +from hugegraph_mcp.envelope import ErrorType + + +@dataclass(frozen=True) +class ErrorClassification: + error_type: ErrorType + retryable: bool + suggestion: str + reason: str + + +NO_INDEX_MARKERS = ( + "noindexexception", + "no index", + "not indexed", + "may not match secondary condition", +) +SCHEMA_MISSING_MARKERS = ( + "property key does not exist", + "propertykey does not exist", + "vertex label does not exist", + "vertexlabel does not exist", + "edge label does not exist", + "edgelabel does not exist", + "schema does not exist", +) +NOT_FOUND_MARKERS = ( + "404", + "notfound", + "not found", + "not exist", + "does not exist", +) + + +def classify_hugegraph_exception(exc: Exception) -> ErrorClassification: + return classify_hugegraph_error_message(str(exc)) + + +def classify_hugegraph_error_message(message: str) -> ErrorClassification: + lowered = message.lower() + if any(marker in lowered for marker in NO_INDEX_MARKERS): + return ErrorClassification( + error_type=ErrorType.NO_INDEX, + retryable=False, + suggestion="Create an index for this condition query or retry with exact id lookup.", + reason="no_index", + ) + if any(marker in lowered for marker in SCHEMA_MISSING_MARKERS): + return ErrorClassification( + error_type=ErrorType.SCHEMA_MISMATCH, + retryable=False, + suggestion="Check the live schema, label, and property key before retrying.", + reason="schema_missing", + ) + if any(marker in lowered for marker in NOT_FOUND_MARKERS): + return ErrorClassification( + error_type=ErrorType.NOT_FOUND, + retryable=False, + suggestion="Verify the id, target type, graph, and graphspace before retrying.", + reason="not_found", + ) + return ErrorClassification( + error_type=ErrorType.SERVER_ERROR, + retryable=True, + suggestion="Check query parameters and HugeGraph Server availability.", + reason="server_error", + ) diff --git a/hugegraph-mcp/hugegraph_mcp/gremlin_tools.py b/hugegraph-mcp/hugegraph_mcp/gremlin_tools.py index 3bbca4f60..e93838dc8 100644 --- a/hugegraph-mcp/hugegraph_mcp/gremlin_tools.py +++ b/hugegraph-mcp/hugegraph_mcp/gremlin_tools.py @@ -61,6 +61,7 @@ def get_write_client(self): "not_found_error": ErrorType.NOT_FOUND, "unknown_error": ErrorType.SERVER_ERROR, } +LIMIT_POLICIES = frozenset({"warn", "reject_unbounded", "auto_append"}) def _get_read_client(): @@ -319,13 +320,28 @@ def _execute_gremlin_with_error_handling( } -def execute_gremlin_read(gremlin_query: str) -> dict[str, Any]: +def execute_gremlin_read( + gremlin_query: str, + *, + limit_policy: str = "warn", +) -> dict[str, Any]: """执行只读 Gremlin 查询。 通过 GremlinPolicy.check_read() 做安全检查, 拒绝写入类和无法确定的查询,只放行明确安全的遍历。 + limit_policy: + - warn: 兼容默认,仅返回 unbounded warning。 + - reject_unbounded: 安全但无界时拒绝执行。 + - auto_append: 对简单无界遍历追加 .limit(100),并返回原始/实际查询。 返回 {data, total, duration_ms, is_read}。 """ + if limit_policy not in LIMIT_POLICIES: + return envelope_err( + ErrorType.VALIDATION_ERROR, + f"Unsupported limit_policy: {limit_policy!r}.", + suggestion="Use one of: warn, reject_unbounded, auto_append.", + details={"limit_policy": limit_policy}, + ) decision = check_gremlin_read(gremlin_query) if not decision.allowed: @@ -336,6 +352,34 @@ def execute_gremlin_read(gremlin_query: str) -> dict[str, Any]: details={"classification": decision.classification}, ) + original_gremlin = gremlin_query + rewrite_reason = None + cost_warnings = gremlin_cost_warnings(gremlin_query) + unbounded = _has_unbounded_warning(cost_warnings) + if limit_policy == "reject_unbounded" and unbounded: + return envelope_err( + ErrorType.VALIDATION_ERROR, + "Gremlin read query is unbounded and limit_policy='reject_unbounded'.", + suggestion="Add .limit(n) or .range(start, end), or use limit_policy='warn'.", + details={"gremlin_query": gremlin_query, "warnings": cost_warnings}, + warnings=cost_warnings, + ) + if limit_policy == "auto_append" and unbounded: + rewritten = _auto_append_limit(gremlin_query) + if rewritten is None: + return envelope_err( + ErrorType.VALIDATION_ERROR, + "Cannot safely auto-append limit to this Gremlin query.", + suggestion=( + "Add an explicit .limit(n) yourself, or use limit_policy='warn'." + ), + details={"gremlin_query": gremlin_query, "warnings": cost_warnings}, + warnings=cost_warnings, + ) + gremlin_query = rewritten + rewrite_reason = "limit_policy='auto_append' added .limit(100) to an unbounded read traversal." + cost_warnings = gremlin_cost_warnings(gremlin_query) + result = _execute_gremlin_with_error_handling( _get_read_client, gremlin_query, "read" ) @@ -348,14 +392,32 @@ def execute_gremlin_read(gremlin_query: str) -> dict[str, Any]: "total": result["count"], "duration_ms": duration_ms, "is_read": True, + "limit_policy": limit_policy, + "original_gremlin": original_gremlin, + "executed_gremlin": gremlin_query, + "rewrite_reason": rewrite_reason, }, duration_ms=duration_ms, - warnings=gremlin_cost_warnings(gremlin_query), + warnings=cost_warnings, ) else: return _gremlin_error_envelope(result) +def _has_unbounded_warning(warnings: list[str]) -> bool: + return any("Unbounded traversal" in warning for warning in warnings) + + +def _auto_append_limit(gremlin_query: str) -> str | None: + stripped = gremlin_query.strip() + if not stripped.endswith(")"): + return None + lowered = stripped.lower() + if any(step in lowered for step in (".group(", ".path(", ".profile(", ".repeat(")): + return None + return f"{stripped}.limit(100)" + + def execute_gremlin_write( gremlin_query: str, *, diff --git a/hugegraph-mcp/hugegraph_mcp/hugegraph_ai_client.py b/hugegraph-mcp/hugegraph_mcp/hugegraph_ai_client.py index afbb61d5c..6b04c1d1a 100644 --- a/hugegraph-mcp/hugegraph_mcp/hugegraph_ai_client.py +++ b/hugegraph-mcp/hugegraph_mcp/hugegraph_ai_client.py @@ -38,7 +38,7 @@ def request( """调用 HugeGraph-AI 并返回标准化信封。 allow_ai=False 时直接拒绝,连接超时/HTTP 错误/JSON 解析失败均返回 - HUGEGRAPH_AI_UNAVAILABLE 信封,不抛异常。 + 结构化错误信封,不抛异常。 """ start = time.perf_counter() @@ -47,10 +47,16 @@ def request( url = _build_url(cfg.ai_url, path) if not cfg.allow_ai: - return _ai_error( - "AI calls are disabled", + return envelope_err( + ErrorType.FEATURE_DISABLED, + "AI calls are disabled by configuration", + suggestion=( + "Set HUGEGRAPH_MCP_ALLOW_AI=true and restart the MCP server, " + "or use tools that do not require HugeGraph-AI." + ), + retryable=False, duration_ms=_duration_ms(start), - details={"method": method, "url": url}, + details={"method": method, "url": url, "reason": "allow_ai_false"}, ) try: diff --git a/hugegraph-mcp/hugegraph_mcp/plan_hash.py b/hugegraph-mcp/hugegraph_mcp/plan_hash.py index 49bd7f4d9..7b76eafc5 100644 --- a/hugegraph-mcp/hugegraph_mcp/plan_hash.py +++ b/hugegraph-mcp/hugegraph_mcp/plan_hash.py @@ -169,7 +169,6 @@ def verify_plan_hash( False, ErrorType.PLAN_HASH_MISMATCH, { - "expected_hash": expected_hash, "provided_hash": submitted_hash, "reason": "Plan context has changed since dry_run.", }, diff --git a/hugegraph-mcp/hugegraph_mcp/server.py b/hugegraph-mcp/hugegraph_mcp/server.py index 2d9c9a67f..b5d2cc356 100644 --- a/hugegraph-mcp/hugegraph_mcp/server.py +++ b/hugegraph-mcp/hugegraph_mcp/server.py @@ -83,18 +83,34 @@ def _is_logs_file(filename) -> bool: 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 READONLY = MCPConfig.from_env().is_readonly() +MCP_TOOL_CONTRACT_VERSION = "2.0" +DEFAULT_TOOLSET = "v2_core" 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 + + +def _register_v2_core_tool(func): + if _active_toolset() == "v1": + return func + return mcp.tool()(func) + + def _align_public_tool_envelope( result: dict[str, Any], *, @@ -142,19 +158,26 @@ def _admin_gate(tool_name: str, *, requires_write: bool = False) -> dict | None: """Return FEATURE_DISABLED envelope if admin mode is not enabled, else None.""" if not _is_admin_mode_enabled(): enable_env = {"admin_mode": "HUGEGRAPH_MCP_ADMIN_MODE"} + required_env = {"HUGEGRAPH_MCP_ADMIN_MODE": "true"} suggestion = f"Set HUGEGRAPH_MCP_ADMIN_MODE=true to enable {tool_name}." 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}." ) return envelope_err( ErrorType.FEATURE_DISABLED, - f"{tool_name} is disabled by default in V1. Enable with HUGEGRAPH_MCP_ADMIN_MODE=true.", + f"{tool_name} is an admin/debug tool and is disabled by default.", suggestion=suggestion, source=tool_name, - details={"tool": tool_name, "enable_env": enable_env}, + details={ + "tool": tool_name, + "toolset": _active_toolset(), + "enable_env": enable_env, + "required_env": required_env, + }, ) if requires_write and MCPConfig.from_env().is_readonly(): @@ -185,13 +208,90 @@ def _admin_gate(tool_name: str, *, requires_write: bool = False) -> dict | None: def inspect_graph_tool(include_raw_schema: bool = False) -> dict: """检视 HugeGraph 服务器状态、schema 摘要、点边计数和 AI 状态。 + Capability: READ. 推荐作为连接后第一个调用的工具。 """ - return _call_public_tool( + result = _call_public_tool( "inspect_graph_tool", inspect_graph, include_raw_schema=include_raw_schema, ) + if result.get("ok") and isinstance(result.get("data"), dict): + result["data"]["mcp_tool_contract_version"] = MCP_TOOL_CONTRACT_VERSION + result["data"]["toolset"] = _active_toolset() + meta = dict(result.get("meta") or {}) + meta["mcp_tool_contract_version"] = MCP_TOOL_CONTRACT_VERSION + meta["toolset"] = _active_toolset() + result["meta"] = meta + return result + + +@_register_v2_core_tool +def inspect_schema_tool( + include_raw_schema: bool = False, + include_relations: bool = True, + include_index_labels: bool = True, + filter_kind: str | None = None, + filter_name: str | None = None, +) -> dict: + """Inspect HugeGraph schema objects and relations. + + Capability: READ. + filter_kind values: property_key, vertex_label, edge_label, index_label. + filter_name requires filter_kind and selects one schema object by name. + include_raw_schema returns the raw HugeGraph schema; include_relations and + include_index_labels control relation/index sections. + """ + return _call_public_tool( + "inspect_schema_tool", + inspect_schema, + include_raw_schema=include_raw_schema, + include_relations=include_relations, + include_index_labels=include_index_labels, + filter_kind=filter_kind, + filter_name=filter_name, + ) + + +@_register_v2_core_tool +def query_graph_data_tool( + target: str, + operation: str, + id: Any = None, + ids: list[Any] | None = None, + label: str | None = None, + properties: dict[str, Any] | None = None, + limit: int | None = None, + page: str | None = None, + vertex_id: Any = None, + direction: str | None = None, +) -> dict: + """Query HugeGraph vertices or edges by typed GraphManager operations. + + Capability: READ. + target values: vertex, edge. + operation values and required fields: + - get_by_id: requires id. + - get_by_ids: requires non-empty ids. + - page: vertex requires label; edge may use label and/or vertex_id+direction. + - condition: exact-match properties only; no Gremlin full-scan fallback. + limit defaults to 100 and rejects values above 500. For edge page/condition, + direction is required when vertex_id is provided. + """ + return _call_public_tool( + "query_graph_data_tool", + query_graph_data, + target=target, + operation=operation, + id=id, + ids=ids, + label=label, + properties=properties, + limit=limit, + page=page, + vertex_id=vertex_id, + direction=direction, + ) # ========== V1 稳定工具 ========== @@ -202,11 +302,14 @@ def generate_gremlin_tool( query: str, execute: bool = False, output_types: list[str] | None = None, + limit_policy: str = "warn", ) -> dict: """V1 稳定工具:自然语言 → Gremlin 生成。 默认不执行(execute=false),返回生成的 Gremlin 查询。 - 设置 execute=true 可执行生成的只读 Gremlin。 + 设置 execute=true 可执行生成的只读 Gremlin。Capability: GENERATE. + limit_policy 仅在 execute=true 时传给只读执行:warn、reject_unbounded、 + auto_append。auto_append 会返回 original_gremlin/executed_gremlin/rewrite_reason。 """ return _call_public_tool( "generate_gremlin_tool", @@ -214,19 +317,27 @@ def generate_gremlin_tool( query=query, execute=execute, output_types=output_types, + limit_policy=limit_policy, ) @mcp.tool() -def execute_gremlin_read_tool(gremlin_query: str) -> dict: +def execute_gremlin_read_tool(gremlin_query: str, limit_policy: str = "warn") -> dict: """V1 稳定工具:执行只读 Gremlin 遍历查询。 + 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. """ return _call_public_tool( "execute_gremlin_read_tool", execute_gremlin_read, gremlin_query, + limit_policy=limit_policy, ) @@ -270,13 +381,22 @@ def apply_schema_tool( operations: list[dict] | None = None, confirm: bool = False, plan_hash: str | None = None, + nonce: str | None = None, + expires_at: float | None = None, ) -> dict: - """V1 稳定工具:schema 校验和预览。 - - 支持 validate 和 dry_run 模式。apply 模式在 V1 中返回 FEATURE_DISABLED。 + """Schema design/validate/dry_run/apply entry point. + + Capability: SCHEMA_WRITE for mode=apply; READ-like validation for other modes. + 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. + Rejects create_index_label, schema append/eliminate, remove/drop. """ start = time.perf_counter() - if mode == "apply": + if mode == "apply" and _active_toolset() == "v1": return envelope_err( ErrorType.FEATURE_DISABLED, "Schema apply is disabled in V1. Use validate or dry_run mode.", @@ -292,6 +412,45 @@ def apply_schema_tool( operations=operations, confirm=confirm, plan_hash=plan_hash, + nonce=nonce, + expires_at=expires_at, + ) + + +@_register_v2_core_tool +def mutate_graph_properties_tool( + target: str, + operation: str, + id: Any, + properties: dict[str, Any], + dry_run: bool = True, + confirm: bool = False, + plan_hash: str | None = None, + nonce: str | None = None, + expires_at: float | None = None, +) -> dict: + """Append or eliminate properties on one vertex or edge. + + 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. + """ + return _call_public_tool( + "mutate_graph_properties_tool", + mutate_graph_properties, + target=target, + operation=operation, + id=id, + properties=properties, + dry_run=dry_run, + confirm=confirm, + plan_hash=plan_hash, + nonce=nonce, + expires_at=expires_at, ) @@ -317,7 +476,7 @@ def import_graph_data_tool( mode="extract": 自然语言文本 → 候选 graph_data mode="ingest": MCP 本地校验+dry_run/confirm+Gremlin 导入 graph_data - mode="table": V1 禁用(返回 FEATURE_DISABLED) + mode="table": 当前 MCP contract 不支持(返回 FEATURE_DISABLED) """ start = time.perf_counter() @@ -361,8 +520,11 @@ def import_graph_data_tool( if mode == "table": return envelope_err( ErrorType.FEATURE_DISABLED, - "Table import is not available in V1.", - suggestion="Use mode='extract' with extract_graph_data_tool instead.", + "Table import is not supported by the current MCP contract.", + suggestion=( + "Use import_graph_data_tool(mode='extract') for text extraction, " + "then import_graph_data_tool(mode='ingest') for validated graph_data." + ), source="import_graph_data_tool", details={"mode": mode, "tool": "import_graph_data_tool"}, duration_ms=(time.perf_counter() - start) * 1000.0, diff --git a/hugegraph-mcp/hugegraph_mcp/tools/generate_gremlin.py b/hugegraph-mcp/hugegraph_mcp/tools/generate_gremlin.py index e5f955be1..2a802575c 100644 --- a/hugegraph-mcp/hugegraph_mcp/tools/generate_gremlin.py +++ b/hugegraph-mcp/hugegraph_mcp/tools/generate_gremlin.py @@ -29,10 +29,12 @@ def generate_gremlin( query: str, execute: bool = False, output_types: list[str] | None = None, + limit_policy: str = "warn", ) -> dict[str, Any]: """将自然语言转为 Gremlin — 默认只生成不执行。 execute=True 时通过 GremlinPolicy 检查安全性:只有 safe 的查询才会执行。 + limit_policy 透传给 execute_gremlin_read,默认 warn 保持兼容。 """ payload: dict[str, Any] = {"query": query} @@ -96,7 +98,7 @@ def generate_gremlin( ) data["executed"] = True - execution_result = execute_gremlin_read(gremlin) + execution_result = execute_gremlin_read(gremlin, limit_policy=limit_policy) if isinstance(execution_result, dict) and execution_result.get("ok") is False: data["execution_result"] = execution_result error = execution_result.get("error") or {} diff --git a/hugegraph-mcp/hugegraph_mcp/tools/inspect_schema.py b/hugegraph-mcp/hugegraph_mcp/tools/inspect_schema.py new file mode 100644 index 000000000..c83f96d2e --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/inspect_schema.py @@ -0,0 +1,292 @@ +# 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. + +"""Schema inspection tool for v2_core.""" + +from typing import Any + +from pyhugegraph.client import PyHugeClient + +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok +from hugegraph_mcp.hugegraph_client import build_hugegraph_client +from hugegraph_mcp.tools.schema_utils import ( + edge_schema_endpoint_label, + primary_key_names, + property_names, + schema_name, + schema_payload, +) + + +FILTER_KINDS = frozenset({"property_key", "vertex_label", "edge_label", "index_label"}) + + +def _schema_manager(): + return build_hugegraph_client( + MCPConfig.from_env(), client_cls=PyHugeClient + ).schema() + + +def inspect_schema( + *, + include_raw_schema: bool = False, + include_relations: bool = True, + include_index_labels: bool = True, + filter_kind: str | None = None, + filter_name: str | None = None, +) -> dict[str, Any]: + """Inspect HugeGraph schema objects and relations. + + Capability: READ. + + filter_kind values: property_key, vertex_label, edge_label, index_label. + filter_name may be used only with filter_kind and must name one schema object. + include_raw_schema controls whether the raw server schema is returned. + """ + + filter_kind = _normalize_optional_str(filter_kind) + filter_name = _normalize_optional_str(filter_name) + + validation_error = _validate_filter(filter_kind, filter_name) + if validation_error is not None: + return validation_error + + try: + manager = _schema_manager() + raw_schema = manager.getSchema() + if raw_schema is None: + return envelope_err( + ErrorType.CONNECTION_FAILED, + "HugeGraph Server returned an empty schema response.", + retryable=True, + source="inspect_schema_tool", + next_actions=[ + "Check HugeGraph Server health and retry inspect_schema_tool.", + ], + ) + relations = _safe_get_relations(manager) if include_relations else [] + except Exception as exc: + return _schema_dependency_error(exc) + + raw_payload = schema_payload(raw_schema) or raw_schema + summary = _build_summary(raw_payload, include_index_labels=include_index_labels) + all_summary = ( + summary + if include_index_labels + else _build_summary(raw_payload, include_index_labels=True) + ) + filtered = _filter_schema(raw_payload, filter_kind, filter_name, all_summary) + if filtered is None: + return envelope_err( + ErrorType.NOT_FOUND, + f"{filter_kind} not found: {filter_name}", + source="inspect_schema_tool", + details={"filter_kind": filter_kind, "filter_name": filter_name}, + next_actions=[ + "Call inspect_schema_tool without filter_name to list available schema objects.", + ], + ) + + data: dict[str, Any] = { + "summary": summary, + "relations": relations, + "index_labels": summary["index_labels"] if include_index_labels else [], + "filtered": filtered, + } + if include_raw_schema: + data["raw_schema"] = raw_payload + + return envelope_ok( + data, + next_actions=[ + "Use query_graph_data_tool to inspect data that uses this schema.", + "Use apply_schema_tool(mode='dry_run') before creating new schema objects.", + ], + ) + + +def _normalize_optional_str(value: str | None) -> str | None: + if value is None: + return None + stripped = str(value).strip() + return stripped or None + + +def _validate_filter( + filter_kind: str | None, + filter_name: str | None, +) -> dict[str, Any] | None: + if filter_kind is None and filter_name is not None: + return envelope_err( + ErrorType.VALIDATION_ERROR, + "filter_name requires filter_kind.", + suggestion="Pass filter_kind together with filter_name.", + source="inspect_schema_tool", + details={"filter_name": filter_name}, + ) + + if filter_kind is not None and filter_kind not in FILTER_KINDS: + return envelope_err( + ErrorType.VALIDATION_ERROR, + f"Unsupported filter_kind: {filter_kind!r}.", + suggestion=( + "Use one of: property_key, vertex_label, edge_label, index_label." + ), + source="inspect_schema_tool", + details={"filter_kind": filter_kind}, + ) + return None + + +def _safe_get_relations(manager) -> list[str]: + try: + relations = manager.getRelations() + except Exception: + return [] + return [str(item) for item in relations or []] + + +def _schema_dependency_error(exc: Exception) -> dict[str, Any]: + return envelope_err( + ErrorType.CONNECTION_FAILED, + f"Cannot inspect HugeGraph schema: {exc!s}", + suggestion="Verify HugeGraph Server URL, graph, graphspace, and credentials.", + retryable=True, + source="inspect_schema_tool", + details={"error": str(exc)}, + next_actions=["Retry inspect_graph_tool to confirm server connectivity."], + ) + + +def _build_summary( + raw_schema: dict[str, Any], + *, + include_index_labels: bool, +) -> dict[str, Any]: + property_keys = [ + _normalize_schema_item(item) for item in _items(raw_schema, "propertykeys") + ] + vertex_labels = [ + _normalize_vertex_label(item) for item in _items(raw_schema, "vertexlabels") + ] + edge_labels = [ + _normalize_edge_label(item) for item in _items(raw_schema, "edgelabels") + ] + index_labels = ( + [_normalize_schema_item(item) for item in _items(raw_schema, "indexlabels")] + if include_index_labels + else [] + ) + return { + "property_key_count": len(property_keys), + "vertex_label_count": len(vertex_labels), + "edge_label_count": len(edge_labels), + "index_label_count": len(_items(raw_schema, "indexlabels")), + "property_keys": property_keys, + "vertex_labels": vertex_labels, + "edge_labels": edge_labels, + "index_labels": index_labels, + } + + +def _items(raw_schema: dict[str, Any], key: str) -> list[Any]: + values = raw_schema.get(key) + return values if isinstance(values, list) else [] + + +def _filter_schema( + raw_schema: dict[str, Any], + filter_kind: str | None, + filter_name: str | None, + summary: dict[str, Any], +) -> dict[str, Any] | list[Any]: + if filter_kind is None: + return { + "property_keys": summary["property_keys"], + "vertex_labels": summary["vertex_labels"], + "edge_labels": summary["edge_labels"], + "index_labels": summary["index_labels"], + } + + key = { + "property_key": "propertykeys", + "vertex_label": "vertexlabels", + "edge_label": "edgelabels", + "index_label": "indexlabels", + }[filter_kind] + normalized = [ + _normalize_kind_item(filter_kind, item) for item in _items(raw_schema, key) + ] + if filter_name is None: + return normalized + for item in normalized: + if item.get("name") == filter_name: + return item + return None + + +def _normalize_kind_item(filter_kind: str, item: Any) -> dict[str, Any]: + if filter_kind == "vertex_label": + return _normalize_vertex_label(item) + if filter_kind == "edge_label": + return _normalize_edge_label(item) + return _normalize_schema_item(item) + + +def _normalize_vertex_label(item: Any) -> dict[str, Any]: + normalized = _normalize_schema_item(item) + if isinstance(item, dict): + normalized["properties"] = sorted(property_names(item.get("properties"))) + normalized["primary_keys"] = primary_key_names(item) + nullable_keys = item.get("nullable_keys") or item.get("nullableKeys") + normalized["nullable_keys"] = _names(nullable_keys) + return normalized + + +def _normalize_edge_label(item: Any) -> dict[str, Any]: + normalized = _normalize_schema_item(item) + if isinstance(item, dict): + normalized["source_label"] = edge_schema_endpoint_label(item, "source") + normalized["target_label"] = edge_schema_endpoint_label(item, "target") + normalized["properties"] = sorted(property_names(item.get("properties"))) + nullable_keys = item.get("nullable_keys") or item.get("nullableKeys") + normalized["nullable_keys"] = _names(nullable_keys) + return normalized + + +def _normalize_schema_item(item: Any) -> dict[str, Any]: + if isinstance(item, dict): + return dict(item) + result: dict[str, Any] = {} + for name in ( + "name", + "dataType", + "cardinality", + "id", + "baseType", + "baseValue", + "indexType", + "fields", + ): + if hasattr(item, name): + result[name] = getattr(item, name) + if not result: + result["value"] = str(item) + return result + + +def _names(values: Any) -> list[str]: + if not isinstance(values, list): + return [] + return sorted(name for value in values if (name := schema_name(value))) diff --git a/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py b/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py index 2668a56b9..27354d1b7 100644 --- a/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py +++ b/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py @@ -22,6 +22,11 @@ from hugegraph_mcp import gremlin_tools, schema_tools from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.confirmable_workflow import ( + confirm_required_error, + mark_readonly_preview, + plan_hash_error, +) from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok from hugegraph_mcp.guard import Capability, guard from hugegraph_mcp.plan_hash import ( @@ -189,16 +194,19 @@ def manage_graph_data( warnings = list(dry_run_result.get("warnings", [])) next_actions: list[str] = [] if MCPConfig.from_env().is_readonly(): - dry_run_result["confirmable"] = False - dry_run_result["readonly_preview_only"] = True - warnings.append( - "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_actions.append( - "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) return envelope_ok( dry_run_result, warnings=warnings, @@ -212,9 +220,8 @@ def manage_graph_data( return violation if not confirm: - return envelope_err( - ErrorType.CONFIRM_REQUIRED, - "Graph data changes require confirm=True after a dry_run.", + 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.", ) @@ -233,16 +240,12 @@ def manage_graph_data( extra_context={"extra_hash_context": extra_hash_context or {}}, ) if not valid: - message = ( - "Graph data change plan has expired." - if error_type == ErrorType.PLAN_EXPIRED - else "Provided plan_hash does not match the current graph data change plan." - ) - return envelope_err( - error_type or ErrorType.PLAN_HASH_MISMATCH, - message, - suggestion="Run dry_run=True again and use the returned plan_hash.", + return plan_hash_error( + error_type=error_type, details=details, + mismatch_message="Provided plan_hash does not match the current graph data change plan.", + expired_message="Graph data change plan has expired.", + suggestion="Run dry_run=True again and use the returned plan_hash.", ) execute_result = execute_graph_change_plan(plan, live_schema=live_schema) diff --git a/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py b/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py index 4f0c8076b..4680aaa86 100644 --- a/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py +++ b/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py @@ -11,20 +11,31 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Schema 管理统一入口 — design / validate / dry_run 三种模式。 +"""Schema 管理统一入口 — design / validate / dry_run / apply 模式。 -V1 仅提供 schema 设计、校验和 dry-run 预览。公共 apply 工具保留 -FEATURE_DISABLED 响应,实际 schema 写入留到后续版本。 +v2_core 解锁最窄 schema apply:create_property_key、create_vertex_label、 +create_edge_label。index/rebuild、schema remove/drop、schema append/eliminate +仍不在 P0a 范围内。 """ -import hashlib -import json from copy import deepcopy from typing import Any +from pyhugegraph.client import PyHugeClient + from hugegraph_mcp import schema_tools from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.confirmable_workflow import confirm_required_error, plan_hash_error from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok +from hugegraph_mcp.guard import Capability, guard +from hugegraph_mcp.hugegraph_client import build_hugegraph_client +from hugegraph_mcp.plan_hash import ( + PlanContext, + build_plan_context, + compute_payload_digest, + compute_plan_hash, + verify_plan_hash, +) from hugegraph_mcp.tools.live_schema import current_live_schema from hugegraph_mcp.tools.schema_utils import normalized_schema_summary @@ -37,6 +48,13 @@ "create_index_label", } ) +P0A_APPLY_OPERATION_TYPES = frozenset( + { + "create_property_key", + "create_vertex_label", + "create_edge_label", + } +) REQUIRED_FIELDS = { "create_property_key": ("name", "data_type"), @@ -45,6 +63,88 @@ "create_index_label": ("name", "base_type", "base_label"), } +IDENTIFIER_FIELDS = { + "create_property_key": ("name",), + "create_vertex_label": ("name",), + "create_edge_label": ("name", "source_label", "target_label"), + "create_index_label": ("name", "base_label"), +} + +PROPERTY_KEY_DATA_TYPES = frozenset( + { + "TEXT", + "INT", + "INTEGER", + "LONG", + "DOUBLE", + "FLOAT", + "BOOLEAN", + "BOOL", + "DATE", + "BYTE", + "BLOB", + "OBJECT", + } +) +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"} +) +EDGE_LABEL_FREQUENCIES = frozenset({"SINGLE", "MULTIPLE"}) + +PROPERTY_KEY_DATA_TYPE_METHODS = { + "TEXT": "asText", + "INT": "asInt", + "INTEGER": "asInt", + "LONG": "asLong", + "DOUBLE": "asDouble", + "FLOAT": "asFloat", + "BOOLEAN": "asBool", + "BOOL": "asBool", + "DATE": "asDate", + "BYTE": "asByte", + "BLOB": "asBlob", + "OBJECT": "asObject", +} +PROPERTY_KEY_CARDINALITY_METHODS = { + "SINGLE": "valueSingle", + "SET": "valueSet", + "LIST": "valueList", +} +PROPERTY_KEY_AGGREGATE_METHODS = { + "OLD": "calcOld", + "SUM": "calcSum", + "MIN": "calcMin", + "MAX": "calcMax", +} +PROPERTY_KEY_DIRECT_AGGREGATE_TYPES = frozenset({"NONE", "SET", "LIST"}) +PROPERTY_KEY_AGGREGATE_CARDINALITIES = { + "NONE": frozenset({"SINGLE", "SET", "LIST"}), + "OLD": frozenset({"SINGLE"}), + "SUM": frozenset({"SINGLE"}), + "MIN": frozenset({"SINGLE"}), + "MAX": frozenset({"SINGLE"}), + "SET": frozenset({"SET"}), + "LIST": frozenset({"LIST"}), +} +PROPERTY_KEY_DATA_TYPE_CANONICAL = { + "INTEGER": "INT", + "BOOL": "BOOLEAN", +} +VERTEX_LABEL_ID_STRATEGY_METHODS = { + "PRIMARY_KEY": "usePrimaryKeyId", + "CUSTOMIZE_STRING": "useCustomizeStringId", + "CUSTOMIZE_NUMBER": "useCustomizeNumberId", + "AUTOMATIC": "useAutomaticId", +} +EDGE_LABEL_FREQUENCY_METHODS = { + "SINGLE": "singleTime", + "MULTIPLE": "multiTimes", +} + ValidationError = dict[str, Any] @@ -158,6 +258,30 @@ def _validate_property_references( ) return + invalid_names = _invalid_schema_names(values) + if invalid_names: + errors.append( + _validation_error( + idx, + operation, + f"{field} must contain non-empty string names", + f"Use only non-empty schema property key names for {field}.", + ) + ) + return + + duplicate_names = _duplicate_names(values) + if duplicate_names: + errors.append( + _validation_error( + idx, + operation, + f"{field} contains duplicate name(s): {', '.join(duplicate_names)}", + f"Remove duplicate property key names from {field}.", + ) + ) + return + missing_properties = [name for name in values if name not in property_keys] if missing_properties: errors.append( @@ -170,13 +294,296 @@ def _validate_property_references( ) +def _validate_property_subset_references( + *, + idx: int, + operation: dict[str, Any], + field: str, + property_keys: set[str], + errors: list[ValidationError], +) -> None: + values = operation.get(field) + if values is None: + return + if not isinstance(values, list): + errors.append( + _validation_error( + idx, + operation, + f"{field} must be a list", + f"Use an array of existing property key names for {field}.", + ) + ) + return + + if not values: + errors.append( + _validation_error( + idx, + operation, + f"{field} must be a non-empty list", + f"Use one or more existing property key names for {field}.", + ) + ) + return + + invalid_names = _invalid_schema_names(values) + if invalid_names: + errors.append( + _validation_error( + idx, + operation, + f"{field} must contain non-empty string names", + f"Use only non-empty schema property key names for {field}.", + ) + ) + return + + duplicate_names = _duplicate_names(values) + if duplicate_names: + errors.append( + _validation_error( + idx, + operation, + f"{field} contains duplicate name(s): {', '.join(duplicate_names)}", + f"Remove duplicate property key names from {field}.", + ) + ) + return + + missing_properties = [name for name in values if name not in property_keys] + if missing_properties: + errors.append( + _validation_error( + idx, + operation, + f"{field} references undefined property key(s): {', '.join(missing_properties)}", + "Create these property keys first and rerun validation after they exist in the live schema.", + ) + ) + + properties = operation.get("properties") or [] + if not isinstance(properties, list): + return + + missing_from_label = [name for name in values if name not in properties] + if missing_from_label: + errors.append( + _validation_error( + idx, + operation, + f"{field} must be included in properties: {', '.join(missing_from_label)}", + f"Add each {field} entry to properties before using it in {field}.", + ) + ) + + +def _validate_enum_field( + *, + idx: int, + operation: dict[str, Any], + field: str, + allowed_values: frozenset[str], + errors: list[ValidationError], + required: bool, + default: str | None = None, +) -> str | None: + raw_value = operation.get(field) + if raw_value in (None, ""): + if default is not None: + return default + if not required: + return None + + if not isinstance(raw_value, str): + errors.append( + _validation_error( + idx, + operation, + f"{field} must be a string, got {type(raw_value).__name__}", + f"Use one of: {', '.join(sorted(allowed_values))} for {field}.", + ) + ) + return None + + normalized = raw_value.upper() + if normalized not in allowed_values: + errors.append( + _validation_error( + idx, + operation, + f"unsupported {field}: {raw_value!r}", + f"Use one of: {', '.join(sorted(allowed_values))} for {field}.", + ) + ) + return None + + return _canonical_enum_value(field, normalized) + + +def _validate_property_key_aggregate_cardinality( + *, + idx: int, + operation: dict[str, Any], + cardinality: str | None, + aggregate_type: str | None, + errors: list[ValidationError], +) -> None: + if aggregate_type is None: + return + allowed_cardinalities = PROPERTY_KEY_AGGREGATE_CARDINALITIES.get(aggregate_type) + if allowed_cardinalities is None or cardinality in allowed_cardinalities: + return + errors.append( + _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}." + ), + ) + ) + + +def _validate_identifier_field( + *, + idx: int, + operation: dict[str, Any], + field: str, + errors: list[ValidationError], +) -> bool: + value = operation.get(field) + if not isinstance(value, str) or not value.strip(): + errors.append( + _validation_error( + idx, + operation, + f"{field} must be a non-empty string, got {value!r}", + f"Provide {field} as a non-empty string identifier.", + ) + ) + return False + return True + + +def _validate_vertex_primary_keys( + *, + idx: int, + operation: dict[str, Any], + id_strategy: str | None, + property_keys: set[str], + errors: list[ValidationError], +) -> None: + if id_strategy is None: + return + if id_strategy != "PRIMARY_KEY": + return + + primary_keys = operation.get("primary_keys") + if not primary_keys: + errors.append( + _validation_error( + idx, + operation, + "primary_keys is required when id_strategy is PRIMARY_KEY", + "Add non-empty primary_keys or set id_strategy to AUTOMATIC/CUSTOMIZE_STRING/CUSTOMIZE_NUMBER.", + ) + ) + return + + if not isinstance(primary_keys, list): + errors.append( + _validation_error( + idx, + operation, + "primary_keys must be a list", + "Use an array of property key names for primary_keys.", + ) + ) + return + + invalid_names = _invalid_schema_names(primary_keys) + if invalid_names: + errors.append( + _validation_error( + idx, + operation, + "primary_keys must contain non-empty string names", + "Use only non-empty property key names for primary_keys.", + ) + ) + return + + duplicate_names = _duplicate_names(primary_keys) + if duplicate_names: + errors.append( + _validation_error( + idx, + operation, + f"primary_keys contains duplicate name(s): {', '.join(duplicate_names)}", + "Remove duplicate names from primary_keys.", + ) + ) + return + + properties = operation.get("properties") or [] + if not isinstance(properties, list): + return + + missing_from_properties = [name for name in primary_keys if name not in properties] + if missing_from_properties: + errors.append( + _validation_error( + idx, + operation, + "primary_keys must be included in properties: " + + ", ".join(missing_from_properties), + "Add each primary key to properties before using it as a primary key.", + ) + ) + + missing_property_keys = [name for name in primary_keys if name not in property_keys] + if missing_property_keys: + errors.append( + _validation_error( + idx, + operation, + "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.", + ) + ) + + +def _invalid_schema_names(values: list[Any]) -> list[Any]: + return [value for value in values if not isinstance(value, str) or not value] + + +def _duplicate_names(values: list[str]) -> list[str]: + seen: set[str] = set() + duplicates: list[str] = [] + for value in values: + if value in seen and value not in duplicates: + duplicates.append(value) + seen.add(value) + return duplicates + + def _validation_warnings(operations: list[dict[str, Any]]) -> list[str]: warnings: list[str] = [] for idx, operation in enumerate(operations): if not isinstance(operation, dict): continue - if operation.get("type") == "create_vertex_label" and not operation.get( - "primary_keys" + id_strategy = str(operation.get("id_strategy", "PRIMARY_KEY")).upper() + if ( + operation.get("type") == "create_vertex_label" + and id_strategy != "PRIMARY_KEY" + and not operation.get("primary_keys") ): warnings.append( f"operation {idx} (create_vertex_label) has no primary_keys definition" @@ -213,15 +620,15 @@ def validate_schema_operations( live_vertex_labels = _schema_items(live_schema, "vertexlabels") live_edge_labels = _schema_items(live_schema, "edgelabels") live_index_labels = _schema_items(live_schema, "indexlabels") - # 同一批 schema 操作允许前面的 create 被后续操作引用,例如先创建 - # property key,再创建使用它的 vertex label。planned_creates 用来模拟 - # 批内依赖,避免合法 migration 被误判为引用不存在。 planned_creates, duplicate_errors = _collect_planned_creates(operations) errors.extend(duplicate_errors) - property_keys = live_property_keys | planned_creates["property_keys"] - vertex_labels = live_vertex_labels | planned_creates["vertex_labels"] - edge_labels = live_edge_labels | planned_creates["edge_labels"] + # Validation mirrors apply order: an operation may reference live schema and + # earlier creates in the same batch, but not later creates that would fail at + # apply time and potentially leave a partial schema behind. + available_property_keys = set(live_property_keys) + available_vertex_labels = set(live_vertex_labels) + available_edge_labels = set(live_edge_labels) for idx, operation in enumerate(operations): if not isinstance(operation, dict): @@ -274,15 +681,60 @@ def validate_schema_operations( ): continue + identifier_ok = all( + _validate_identifier_field( + idx=idx, operation=operation, field=field, errors=errors + ) + for field in IDENTIFIER_FIELDS.get(op_type, ()) + ) + if not identifier_ok: + continue + name = operation.get("name") - if op_type == "create_property_key" and name in live_property_keys: - errors.append( - _validation_error( - idx, - operation, - f"property key already exists: {name}", - "Use a new property key name or remove this create_property_key operation.", + if op_type == "create_property_key": + if name in live_property_keys: + errors.append( + _validation_error( + idx, + operation, + f"property key already exists: {name}", + "Use a new property key name or remove this create_property_key operation.", + ) ) + _validate_enum_field( + idx=idx, + operation=operation, + field="data_type", + allowed_values=PROPERTY_KEY_DATA_TYPES, + errors=errors, + required=True, + default="TEXT", + ) + cardinality = _validate_enum_field( + idx=idx, + operation=operation, + field="cardinality", + allowed_values=PROPERTY_KEY_CARDINALITIES, + errors=errors, + required=False, + default="SINGLE", + ) + aggregate_type = None + if operation.get("aggregate_type") not in (None, ""): + aggregate_type = _validate_enum_field( + idx=idx, + operation=operation, + field="aggregate_type", + allowed_values=PROPERTY_KEY_AGGREGATE_TYPES, + errors=errors, + required=False, + ) + _validate_property_key_aggregate_cardinality( + idx=idx, + operation=operation, + cardinality=cardinality, + aggregate_type=aggregate_type, + errors=errors, ) elif op_type == "create_vertex_label": if name in live_vertex_labels: @@ -294,11 +746,34 @@ def validate_schema_operations( "Use a new vertex label name or remove this create_vertex_label operation.", ) ) + id_strategy = _validate_enum_field( + idx=idx, + operation=operation, + field="id_strategy", + allowed_values=VERTEX_LABEL_ID_STRATEGIES, + errors=errors, + required=False, + default="PRIMARY_KEY", + ) _validate_property_references( idx=idx, operation=operation, field="properties", - property_keys=property_keys, + property_keys=available_property_keys, + errors=errors, + ) + _validate_vertex_primary_keys( + idx=idx, + operation=operation, + id_strategy=id_strategy, + property_keys=available_property_keys, + errors=errors, + ) + _validate_property_subset_references( + idx=idx, + operation=operation, + field="nullable_keys", + property_keys=available_property_keys, errors=errors, ) elif op_type == "create_edge_label": @@ -313,7 +788,7 @@ def validate_schema_operations( ) for field in ("source_label", "target_label"): label = operation[field] - if label not in vertex_labels: + if label not in available_vertex_labels: errors.append( _validation_error( idx, @@ -322,11 +797,34 @@ def validate_schema_operations( "Create the referenced vertex label first and rerun validation after it exists in the live schema.", ) ) + if operation.get("frequency") not in (None, ""): + _validate_enum_field( + idx=idx, + operation=operation, + field="frequency", + allowed_values=EDGE_LABEL_FREQUENCIES, + errors=errors, + required=False, + ) _validate_property_references( idx=idx, operation=operation, field="properties", - property_keys=property_keys, + property_keys=available_property_keys, + errors=errors, + ) + _validate_property_subset_references( + idx=idx, + operation=operation, + field="nullable_keys", + property_keys=available_property_keys, + errors=errors, + ) + _validate_property_subset_references( + idx=idx, + operation=operation, + field="sort_keys", + property_keys=available_property_keys, errors=errors, ) elif op_type == "create_index_label": @@ -342,7 +840,7 @@ def validate_schema_operations( base_type = str(operation.get("base_type", "")).upper() base_label = operation["base_label"] if base_type == "VERTEX": - if base_label not in vertex_labels: + if base_label not in available_vertex_labels: errors.append( _validation_error( idx, @@ -352,7 +850,7 @@ def validate_schema_operations( ) ) elif base_type == "EDGE": - if base_label not in edge_labels: + if base_label not in available_edge_labels: errors.append( _validation_error( idx, @@ -374,10 +872,23 @@ def validate_schema_operations( idx=idx, operation=operation, field="fields", - property_keys=property_keys, + property_keys=available_property_keys, errors=errors, ) + 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"] + ): + available_vertex_labels.add(name) + elif op_type == "create_edge_label" and name in planned_creates["edge_labels"]: + available_edge_labels.add(name) + return { "valid": not bool(errors), "errors": errors, @@ -385,27 +896,60 @@ def validate_schema_operations( } -def _current_plan_context( - operations: list[dict[str, Any]], live_schema: dict[str, Any] | None = None -) -> dict[str, Any]: - cfg = MCPConfig.from_env() +def _schema_summary(live_schema: dict[str, Any] | None) -> dict[str, Any] | None: + return normalized_schema_summary(live_schema) + + +def _schema_hash(live_schema: dict[str, Any] | None) -> str | None: + summary = _schema_summary(live_schema) + return compute_payload_digest(summary) if summary else None + + +def _schema_payload_digest(operations: list[dict[str, Any]]) -> str: + return compute_payload_digest({"operations": deepcopy(operations)}) + + +def _build_schema_plan_context( + *, + operations: list[dict[str, Any]], + live_schema: dict[str, Any] | None, + nonce: str | None, +) -> PlanContext: live_schema = current_live_schema(live_schema) - # plan_hash 只绑定写入语义相关的 schema 摘要,不绑定 id/status/create_time - # 等元数据,减少 dry-run 到 apply 之间的无关字段抖动。 - return { - "operations": deepcopy(operations), - "graph": cfg.graph, - "graphspace": cfg.graphspace, - "schema_summary": normalized_schema_summary(live_schema), - } + context, _ = build_plan_context( + tool_name="apply_schema_tool", + mode="apply", + payload_digest=_schema_payload_digest(operations), + schema_hash=_schema_hash(live_schema), + nonce=nonce, + ) + return context def calculate_plan_hash( operations: list[dict[str, Any]], live_schema: dict[str, Any] | None = None ) -> str: - payload = _current_plan_context(operations, live_schema) - encoded = json.dumps(payload, sort_keys=True, default=str) - return hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:32] + """Compatibility wrapper backed by the unified target-bound PlanContext.""" + + return compute_plan_hash( + _build_schema_plan_context( + operations=operations, + live_schema=live_schema, + nonce="compat", + ) + ) + + +def _plan_context_payload(plan_context: PlanContext) -> dict[str, Any]: + return { + "nonce": plan_context.nonce, + "expires_at": plan_context.expires_at, + "graph_url": plan_context.graph_url, + "graph_name": plan_context.graph_name, + "graphspace": plan_context.graphspace, + "principal": plan_context.principal, + "readonly": plan_context.readonly, + } def _risk_warnings( @@ -446,6 +990,28 @@ def _risk_warnings( return warnings +def _validate_apply_scope(operations: list[dict[str, Any]]) -> list[ValidationError]: + errors: list[ValidationError] = [] + for idx, operation in enumerate(operations): + if not isinstance(operation, dict): + continue + op_type = _operation_type(operation) + if op_type not in P0A_APPLY_OPERATION_TYPES: + errors.append( + _validation_error( + idx, + operation, + f"schema apply operation is outside P0a scope: {op_type}", + ( + "P0a apply supports only create_property_key, " + "create_vertex_label, and create_edge_label. " + "Index/rebuild and destructive schema changes are out of scope." + ), + ) + ) + return errors + + def _mutation_summary(operations: list[dict[str, Any]]) -> str: counts: dict[str, int] = {} for operation in operations: @@ -476,16 +1042,33 @@ def _safe_fetch_live_schema() -> tuple[dict[str, Any] | None, dict[str, Any] | N def dry_run_schema_operations( - operations: list[dict[str, Any]], live_schema: dict[str, Any] | None = None + operations: list[dict[str, Any]], + live_schema: dict[str, Any] | None = None, + nonce: str | None = None, ) -> dict[str, Any]: live_schema = current_live_schema(live_schema) validation = validate_schema_operations(operations, live_schema) if not validation["valid"]: return validation + apply_scope_errors = _validate_apply_scope(operations) + if apply_scope_errors: + return { + "valid": False, + "errors": apply_scope_errors, + "warnings": validation.get("warnings", []), + } + + plan_context = _build_schema_plan_context( + operations=operations, + live_schema=live_schema, + nonce=nonce, + ) return { "valid": True, - "plan_hash": calculate_plan_hash(operations, live_schema), + "plan_hash": compute_plan_hash(plan_context), + "plan_context": _plan_context_payload(plan_context), + "confirmable": True, "mutation_summary": _mutation_summary(operations), "warnings": validation.get("warnings", []) + _risk_warnings(operations, live_schema), @@ -504,23 +1087,362 @@ def _design_from_operations(operations: list[dict[str, Any]]) -> dict[str, Any]: ) +def _schema_manager(): + return build_hugegraph_client( + MCPConfig.from_env(), client_cls=PyHugeClient + ).schema() + + +def apply_schema_operations( + operations: list[dict[str, Any]], + *, + 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.""" + + 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: + 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 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 + + 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), + } + + +def _apply_one_operation(manager, operation: dict[str, Any]) -> None: + op_type = _operation_type(operation) + if op_type == "create_property_key": + builder = manager.propertyKey(operation["name"]) + _apply_property_key_options(builder, operation) + builder.create() + return + if op_type == "create_vertex_label": + builder = manager.vertexLabel(operation["name"]) + _apply_vertex_label_options(builder, operation) + builder.create() + return + if op_type == "create_edge_label": + builder = manager.edgeLabel(operation["name"]) + _apply_edge_label_options(builder, operation) + builder.create() + return + raise ValueError(f"Unsupported P0a schema apply operation: {op_type}") + + +def _apply_property_key_options(builder, operation: dict[str, Any]) -> None: + data_type = str(operation.get("data_type", "TEXT")).upper() + method_name = PROPERTY_KEY_DATA_TYPE_METHODS.get(data_type) + if method_name is None: + raise ValueError(f"Unsupported property key data_type: {data_type}") + getattr(builder, method_name)() + + cardinality = str(operation.get("cardinality", "SINGLE")).upper() + method_name = PROPERTY_KEY_CARDINALITY_METHODS.get(cardinality) + if method_name is None: + raise ValueError(f"Unsupported property key cardinality: {cardinality}") + getattr(builder, method_name)() + + aggregate_type = operation.get("aggregate_type") + if aggregate_type: + normalized = str(aggregate_type).upper() + if normalized in PROPERTY_KEY_DIRECT_AGGREGATE_TYPES: + if not hasattr(builder, "add_parameter"): + raise ValueError( + f"Property key aggregate_type {normalized} requires a builder " + "that supports direct schema parameters." + ) + builder.add_parameter("aggregate_type", normalized) + return + + method_name = PROPERTY_KEY_AGGREGATE_METHODS.get(normalized) + if method_name is None: + raise ValueError( + f"Unsupported property key aggregate_type: {aggregate_type}" + ) + getattr(builder, method_name)() + + +def _apply_vertex_label_options(builder, operation: dict[str, Any]) -> None: + id_strategy = str(operation.get("id_strategy", "PRIMARY_KEY")).upper() + method_name = VERTEX_LABEL_ID_STRATEGY_METHODS.get(id_strategy) + if method_name is None: + raise ValueError(f"Unsupported vertex label id_strategy: {id_strategy}") + getattr(builder, method_name)() + + if operation.get("properties"): + builder.properties(*operation["properties"]) + if operation.get("primary_keys"): + builder.primaryKeys(*operation["primary_keys"]) + if operation.get("nullable_keys"): + builder.nullableKeys(*operation["nullable_keys"]) + + +def _apply_edge_label_options(builder, operation: dict[str, Any]) -> None: + builder.link(operation["source_label"], operation["target_label"]) + if operation.get("properties"): + builder.properties(*operation["properties"]) + if operation.get("nullable_keys"): + builder.nullableKeys(*operation["nullable_keys"]) + if operation.get("sort_keys"): + builder.sortKeys(*operation["sort_keys"]) + + frequency = operation.get("frequency") + if frequency: + normalized = str(frequency).upper() + method_name = EDGE_LABEL_FREQUENCY_METHODS.get(normalized) + if method_name is None: + raise ValueError(f"Unsupported edge label frequency: {frequency}") + getattr(builder, method_name)() + + +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 + ) + if not isinstance(schema, dict): + schema = live_schema if isinstance(live_schema, dict) else {} + collection = { + "create_property_key": "propertykeys", + "create_vertex_label": "vertexlabels", + "create_edge_label": "edgelabels", + }.get(_operation_type(operation)) + if collection is None: + return False + + observed = _find_schema_item(schema, collection, operation.get("name")) + if observed is None: + return False + return _operation_fields_match(operation, observed) + + +def _find_schema_item( + schema: dict[str, Any], collection: str, name: Any +) -> dict[str, Any] | None: + if not isinstance(name, str): + return None + for item in schema.get(collection, []): + if isinstance(item, dict) and item.get("name") == name: + return item + return None + + +def _field_value(item: dict[str, Any], *names: str) -> Any: + for name in names: + if name in item: + return item[name] + return None + + +def _canonical_enum_value(field: str, value: str) -> str: + if field == "data_type": + return PROPERTY_KEY_DATA_TYPE_CANONICAL.get(value, value) + return value + + +def _normalize_enum_value(value: Any) -> str | None: + if not isinstance(value, str): + return None + return value.upper() + + +def _normalize_name_list(value: Any) -> list[str] | None: + if not isinstance(value, list): + return None + names: list[str] = [] + for item in value: + if isinstance(item, str): + names.append(item) + elif isinstance(item, dict) and isinstance(item.get("name"), str): + names.append(item["name"]) + else: + return None + return names + + +def _list_field_matches( + observed: dict[str, Any], operation: dict[str, Any], field: str +) -> bool: + if field not in operation: + return True + observed_values = _normalize_name_list( + _field_value(observed, field, _camel_case_schema_field(field)) + ) + if observed_values is None: + return False + return observed_values == operation[field] + + +def _enum_field_matches( + observed: dict[str, Any], + operation: dict[str, Any], + field: str, + *, + default: str | None = None, +) -> bool: + expected = operation.get(field, default) + if expected is None: + return True + observed_value = _normalize_enum_value( + _field_value(observed, field, _camel_case_schema_field(field)) + ) + if observed_value is None: + return False + return _canonical_enum_value(field, observed_value) == _canonical_enum_value( + field, str(expected).upper() + ) + + +def _string_field_matches( + observed: dict[str, Any], operation: dict[str, Any], field: str +) -> bool: + if field not in operation: + return True + return ( + _field_value(observed, field, _camel_case_schema_field(field)) + == operation[field] + ) + + +def _camel_case_schema_field(field: str) -> str: + parts = field.split("_") + return parts[0] + "".join(part.title() for part in parts[1:]) + + +def _operation_fields_match( + operation: dict[str, Any], observed: dict[str, Any] +) -> bool: + op_type = _operation_type(operation) + if op_type == "create_property_key": + return ( + _enum_field_matches(observed, operation, "data_type") + and _enum_field_matches( + observed, operation, "cardinality", default="SINGLE" + ) + and _enum_field_matches(observed, operation, "aggregate_type") + ) + + if op_type == "create_vertex_label": + return ( + _enum_field_matches( + observed, operation, "id_strategy", default="PRIMARY_KEY" + ) + and _list_field_matches(observed, operation, "properties") + and _list_field_matches(observed, operation, "primary_keys") + and _list_field_matches(observed, operation, "nullable_keys") + ) + + if op_type == "create_edge_label": + return ( + _string_field_matches(observed, operation, "source_label") + and _string_field_matches(observed, operation, "target_label") + and _list_field_matches(observed, operation, "properties") + and _list_field_matches(observed, operation, "nullable_keys") + and _list_field_matches(observed, operation, "sort_keys") + and _enum_field_matches(observed, operation, "frequency") + ) + + return False + + +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.", + ] + + def manage_schema( mode: str, operations: list[dict[str, Any]] | None = None, confirm: bool = False, plan_hash: str | None = None, + nonce: str | None = None, + expires_at: float | None = None, ) -> dict[str, Any]: - """统一 schema 管理入口 — 三种模式。 + """统一 schema 管理入口。 - design: 获取分步 schema 设计引导 - validate: 基于 live schema 校验操作合法性 - - dry_run: 校验 + 生成 plan_hash + 风险警告 + - dry_run: 校验 + 生成 target-bound plan_hash + 风险警告 + - apply: P0a create_property_key/create_vertex_label/create_edge_label Args: - mode: 操作模式 ("design" / "validate" / "dry_run") + mode: 操作模式 ("design" / "validate" / "dry_run" / "apply") operations: schema 操作列表 - confirm: reserved for future use (no-op) - plan_hash: reserved for future use (no-op) + confirm: apply requires True after dry_run + plan_hash: dry_run returned plan_hash + nonce: dry_run returned plan_context.nonce + expires_at: dry_run returned plan_context.expires_at """ operations = operations or [] @@ -537,10 +1459,88 @@ def manage_schema( live_schema, error = _safe_fetch_live_schema() if error: return error - return envelope_ok(dry_run_schema_operations(operations, live_schema)) + result = dry_run_schema_operations(operations, live_schema, nonce=nonce) + if result.get("valid") and MCPConfig.from_env().is_readonly(): + result["confirmable"] = False + result["readonly_preview_only"] = True + return envelope_ok( + result, + warnings=list(result.get("warnings", [])) + + [ + "This dry-run was generated while HUGEGRAPH_MCP_READONLY=true. " + "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." + ], + ) + return envelope_ok( + result, + next_actions=[ + "Review schema preview, then call apply_schema_tool(mode='apply', confirm=true, plan_hash, nonce, expires_at)." + ] + if result.get("valid") + else ["Fix validation errors and rerun apply_schema_tool(mode='dry_run')."], + ) + + if mode == "apply": + live_schema, error = _safe_fetch_live_schema() + if error: + return error + dry_run_result = dry_run_schema_operations(operations, live_schema, nonce=nonce) + if not dry_run_result.get("valid"): + return envelope_err( + ErrorType.SCHEMA_MISMATCH, + "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')." + ], + ) + violation = guard(Capability.SCHEMA_WRITE) + if violation is not None: + return violation + if not confirm: + 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." + ), + ) + valid, error_type, details = verify_plan_hash( + submitted_hash=plan_hash, + tool_name="apply_schema_tool", + mode="apply", + payload_digest=_schema_payload_digest(operations), + schema_hash=_schema_hash(live_schema), + nonce=nonce, + expires_at=expires_at, + ) + if not valid: + return plan_hash_error( + error_type=error_type, + details=details, + mismatch_message="Provided plan_hash does not match the current schema apply plan.", + suggestion="Run mode='dry_run' again and use the returned plan_hash.", + ) + + apply_result = apply_schema_operations(operations, live_schema=live_schema) + if apply_result.get("status") in {"failed", "partial"}: + return envelope_err( + ErrorType.PARTIAL_APPLY, + "Schema apply did not complete all operations.", + details=apply_result, + next_actions=apply_result.get("recovery_suggestions", []), + ) + return envelope_ok( + apply_result, + next_actions=["Call inspect_schema_tool to verify the applied schema."], + ) return envelope_err( - ErrorType.SCHEMA_MISMATCH, + ErrorType.VALIDATION_ERROR, f"Unsupported manage_schema mode: {mode}", - suggestion="Use one of: design, validate, dry_run.", + suggestion="Use one of: design, validate, dry_run, apply.", ) diff --git a/hugegraph-mcp/hugegraph_mcp/tools/mutate_graph_properties.py b/hugegraph-mcp/hugegraph_mcp/tools/mutate_graph_properties.py new file mode 100644 index 000000000..80f02532c --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/mutate_graph_properties.py @@ -0,0 +1,674 @@ +# 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. + +"""Controlled vertex/edge property append/eliminate tool for v2_core.""" + +from typing import Any +from uuid import uuid4 + +from pyhugegraph.client import PyHugeClient + +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.confirmable_workflow import ( + confirm_required_error, + mark_readonly_preview, + plan_hash_error, +) +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 +from hugegraph_mcp.hugegraph_client import build_hugegraph_client +from hugegraph_mcp.plan_hash import ( + build_plan_context, + compute_payload_digest, + compute_plan_hash, + verify_plan_hash, +) +from hugegraph_mcp.tools.live_schema import current_live_schema +from hugegraph_mcp.tools.schema_utils import ( + normalized_schema_summary, + property_names, + schema_payload, +) + + +TARGETS = frozenset({"vertex", "edge"}) +OPERATIONS = frozenset({"append", "eliminate"}) + + +def _graph_manager(): + return build_hugegraph_client(MCPConfig.from_env(), client_cls=PyHugeClient).graph() + + +def mutate_graph_properties( + *, + target: str, + operation: str, + id: Any, + properties: dict[str, Any], + dry_run: bool = True, + confirm: bool = False, + plan_hash: str | None = None, + nonce: str | None = None, + expires_at: float | None = None, +) -> dict[str, Any]: + """Append or eliminate properties on one vertex or edge. + + Capability: DATA_WRITE. + + target values: vertex, edge. + operation values: append, eliminate. Both operations require: + dry_run=True first -> review plan_hash/plan_context -> dry_run=False, + confirm=True, same plan_hash, nonce, and expires_at. + + 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. + """ + + validation_error = _validate_inputs( + target=target, + operation=operation, + id=id, + properties=properties, + ) + if validation_error is not None: + return validation_error + + live_schema, target_item, read_error = _read_schema_and_target(target=target, id=id) + if read_error is not None: + return read_error + + schema_error = _validate_properties_against_schema( + target=target, + target_item=target_item, + properties=properties, + live_schema=live_schema, + ) + if schema_error is not None: + return schema_error + + preview = _preview_mutation( + target=target, + operation=operation, + id=id, + properties=properties, + before=target_item, + ) + nonce = _nonce_with_snapshot(nonce, preview["target_snapshot_digest"]) + plan_context = _build_mutation_plan_context( + target=target, + operation=operation, + id=id, + properties=properties, + live_schema=live_schema, + target_snapshot_digest=preview["target_snapshot_digest"], + nonce=nonce, + ) + plan_hash_value = compute_plan_hash(plan_context) + payload = { + "target": target, + "operation": operation, + "id": id, + "properties": properties, + "mutation_summary": preview["mutation_summary"], + "risk_level": "high" if operation == "eliminate" else "medium", + "before": preview["before"], + "after": preview["after"], + "plan_hash": plan_hash_value, + "plan_context": _plan_context_payload(plan_context), + "status": "planned", + "confirmable": True, + } + + 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.", + ] + if MCPConfig.from_env().is_readonly(): + payload, warnings, next_actions = mark_readonly_preview( + payload, + warning=( + "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.", + ) + return envelope_ok(payload, warnings=warnings, next_actions=next_actions) + + violation = guard(Capability.DATA_WRITE) + if violation is not None: + return violation + + if not confirm: + 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." + ), + 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_plan_hash( + 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={ + "target": target, + "operation": operation, + "target_snapshot_digest": preview["target_snapshot_digest"], + }, + ) + 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"], + payload=payload, + ) + + +def _validate_inputs( + *, + target: str, + operation: str, + id: Any, + properties: dict[str, Any], +) -> dict[str, Any] | None: + if target not in TARGETS: + return _validation_error( + f"Unsupported target: {target!r}.", + "Use target='vertex' or target='edge'.", + {"target": target}, + ) + if operation not in OPERATIONS: + return _validation_error( + f"Unsupported operation: {operation!r}.", + "Use operation='append' or operation='eliminate'.", + {"operation": operation}, + ) + if id is None or (isinstance(id, str) and id.strip() == ""): + return _validation_error( + "id is required for property mutation.", + "Pass the exact vertex or edge id from query_graph_data_tool.", + {"id": id}, + ) + if not isinstance(properties, dict) or not properties: + return _validation_error( + "properties must be a non-empty object.", + "Pass the property keys and values to append or eliminate.", + {"properties_type": type(properties).__name__}, + ) + blank_keys = [key for key in properties if not isinstance(key, str) or not key] + if blank_keys: + return _validation_error( + "properties contains blank or non-string keys.", + "Use only named schema property keys.", + {"invalid_keys": blank_keys}, + ) + return None + + +def _read_schema_and_target( + *, + target: str, + id: Any, +) -> tuple[dict[str, Any] | None, dict[str, Any] | None, dict[str, Any] | None]: + try: + live_schema = current_live_schema() + except Exception as exc: + return ( + None, + None, + envelope_err( + ErrorType.CONNECTION_FAILED, + "Cannot read live schema before property mutation.", + suggestion="Ensure HugeGraph Server is running and retry.", + retryable=True, + source="mutate_graph_properties_tool", + details={"stage": "schema_fetch", "error": str(exc)}, + ), + ) + + try: + manager = _graph_manager() + raw_item = ( + manager.getVertexById(id) if target == "vertex" else manager.getEdgeById(id) + ) + except Exception as exc: + classification = classify_hugegraph_exception(exc) + return ( + live_schema, + None, + envelope_err( + classification.error_type, + f"Cannot read target {target}: {exc!s}", + suggestion=classification.suggestion, + retryable=classification.retryable, + source="mutate_graph_properties_tool", + details={ + "stage": "target_fetch", + "target": target, + "id": id, + "error": str(exc), + "reason": classification.reason, + }, + ), + ) + + item = _plain_item(raw_item) + if item is None: + return ( + live_schema, + None, + envelope_err( + ErrorType.NOT_FOUND, + f"Target {target} not found: {id}", + suggestion="Call query_graph_data_tool to verify the target id.", + source="mutate_graph_properties_tool", + details={"target": target, "id": id}, + ), + ) + return live_schema, item, None + + +def _validate_properties_against_schema( + *, + target: str, + target_item: dict[str, Any], + properties: dict[str, Any], + live_schema: dict[str, Any] | None, +) -> dict[str, Any] | None: + raw_schema = schema_payload(live_schema) + if raw_schema is None: + return envelope_err( + ErrorType.SCHEMA_MISMATCH, + "Live schema response is not a schema object.", + source="mutate_graph_properties_tool", + ) + + label = target_item.get("label") + if not label: + return envelope_err( + ErrorType.SCHEMA_MISMATCH, + "Target item has no label; cannot validate property keys.", + source="mutate_graph_properties_tool", + details={"target_item": target_item}, + ) + collection = "vertexlabels" if target == "vertex" else "edgelabels" + label_schema = _find_label_schema(raw_schema.get(collection), label) + if label_schema is None: + return envelope_err( + ErrorType.SCHEMA_MISMATCH, + f"Target label is not present in live schema: {label}", + source="mutate_graph_properties_tool", + details={"label": label, "target": target}, + ) + allowed = property_names(label_schema.get("properties")) + unknown = sorted(set(properties) - allowed) + if unknown: + return envelope_err( + ErrorType.SCHEMA_MISMATCH, + "Property mutation references keys not defined on the target label.", + suggestion="Use only properties defined on the target vertex or edge label.", + source="mutate_graph_properties_tool", + details={"label": label, "unknown_properties": unknown}, + ) + return None + + +def _preview_mutation( + *, + target: str, + operation: str, + id: Any, + properties: dict[str, Any], + before: dict[str, Any], +) -> dict[str, Any]: + before_properties = dict(before.get("properties") or {}) + after_properties = _apply_property_preview(before_properties, operation, properties) + after = dict(before) + after["properties"] = after_properties + return { + "before": before, + "after": after, + "target_snapshot_digest": compute_payload_digest(before), + "mutation_summary": { + "target": target, + "operation": operation, + "id": id, + "property_keys": sorted(properties), + }, + } + + +def _apply_property_preview( + before: dict[str, Any], + operation: str, + properties: dict[str, Any], +) -> dict[str, Any]: + after = dict(before) + if operation == "append": + after.update(properties) + return after + for key in properties: + after.pop(key, None) + 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], + 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: + 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: + 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): + 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, + operation: str, + id: Any, + properties: dict[str, Any], + live_schema: dict[str, Any] | None, + target_snapshot_digest: str, + nonce: str | None, +): + context, _ = build_plan_context( + tool_name="mutate_graph_properties_tool", + mode="mutate", + payload_digest=_payload_digest(target, operation, id, properties), + schema_hash=_schema_hash(live_schema), + nonce=nonce, + extra_context={ + "target": target, + "operation": operation, + "target_snapshot_digest": target_snapshot_digest, + }, + ) + return context + + +def _nonce_with_snapshot(nonce: str | None, snapshot_digest: str) -> str: + base = str(nonce).strip() if nonce else uuid4().hex[:12] + if "|ts:" in base: + return base + 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, + id: Any, + properties: dict[str, Any], +) -> str: + return compute_payload_digest( + { + "target": target, + "operation": operation, + "id": id, + "properties": properties, + } + ) + + +def _schema_hash(live_schema: dict[str, Any] | None) -> str | None: + summary = normalized_schema_summary(live_schema) + return compute_payload_digest(summary) if summary else None + + +def _plan_context_payload(plan_context) -> dict[str, Any]: + return { + "nonce": plan_context.nonce, + "expires_at": plan_context.expires_at, + "graph_url": plan_context.graph_url, + "graph_name": plan_context.graph_name, + "graphspace": plan_context.graphspace, + "principal": plan_context.principal, + "readonly": plan_context.readonly, + } + + +def _find_label_schema(labels: Any, label_name: str) -> dict[str, Any] | None: + if not isinstance(labels, list): + return None + for item in labels: + if isinstance(item, dict) and item.get("name") == label_name: + return item + return None + + +def _properties_match(post_read: dict[str, Any], planned_after: dict[str, Any]) -> bool: + return (post_read.get("properties") or {}) == ( + planned_after.get("properties") or {} + ) + + +def _plain_item(item: Any) -> dict[str, Any] | None: + if item is None: + return None + if isinstance(item, dict): + return dict(item) + result: dict[str, Any] = {} + for name in ( + "id", + "label", + "type", + "properties", + "outV", + "outVLabel", + "inV", + "inVLabel", + ): + if hasattr(item, name): + result[name] = getattr(item, name) + return result or {"value": item} + + +def _validation_error( + message: str, + suggestion: str, + details: dict[str, Any], +) -> dict[str, Any]: + return envelope_err( + ErrorType.VALIDATION_ERROR, + message, + suggestion=suggestion, + source="mutate_graph_properties_tool", + details=details, + ) diff --git a/hugegraph-mcp/hugegraph_mcp/tools/query_graph_data.py b/hugegraph-mcp/hugegraph_mcp/tools/query_graph_data.py new file mode 100644 index 000000000..9862c0593 --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/tools/query_graph_data.py @@ -0,0 +1,473 @@ +# 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. + +"""Typed graph data query tool for v2_core.""" + +import json +from typing import Any + +from pyhugegraph.client import PyHugeClient +from pyhugegraph.utils.id_format import format_vertex_id + +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok +from hugegraph_mcp.error_mapping import classify_hugegraph_exception +from hugegraph_mcp.hugegraph_client import build_hugegraph_client + + +TARGETS = frozenset({"vertex", "edge"}) +OPERATIONS = frozenset({"get_by_id", "get_by_ids", "page", "condition"}) +DEFAULT_LIMIT = 100 +MAX_LIMIT = 500 +VALID_DIRECTIONS = frozenset({"OUT", "IN", "BOTH"}) + + +def _graph_manager(): + return build_hugegraph_client(MCPConfig.from_env(), client_cls=PyHugeClient).graph() + + +def query_graph_data( + *, + target: str, + operation: str, + id: Any = None, + ids: list[Any] | None = None, + label: str | None = None, + properties: dict[str, Any] | None = None, + limit: int | None = None, + page: str | None = None, + vertex_id: Any = None, + direction: str | None = None, +) -> dict[str, Any]: + """Query vertices or edges through HugeGraph GraphManager. + + Capability: READ. + + target values: vertex, edge. + operation values: + - get_by_id: requires id. + - get_by_ids: requires non-empty ids, duplicates are ignored with a warning. + - page: vertex requires label; edge may use label and/or vertex_id+direction. + - condition: exact-match properties only; no full graph Gremlin fallback. + + limit defaults to 100 and rejects values above 500. + Edge page/condition with vertex_id must also pass direction=OUT|IN|BOTH. + """ + + warnings: list[str] = [] + validation_error = _validate_inputs( + target=target, + operation=operation, + id=id, + ids=ids, + label=label, + properties=properties, + limit=limit, + vertex_id=vertex_id, + direction=direction, + ) + if validation_error is not None: + return validation_error + + bounded_limit = DEFAULT_LIMIT if limit is None else int(limit) + normalized_direction = _normalize_direction(direction) + + try: + normalized_ids = ( + _normalize_ids(ids, warnings, target=target) + if operation == "get_by_ids" + else [] + ) + manager = _graph_manager() + result, next_page = _execute_query( + manager=manager, + target=target, + operation=operation, + id=id, + ids=normalized_ids, + label=label, + properties=properties, + limit=bounded_limit, + page=page, + vertex_id=vertex_id, + direction=normalized_direction, + ) + except Exception as exc: + return _query_error(exc) + + items = _normalize_items(result) + return envelope_ok( + { + "target": target, + "operation": operation, + "items": items, + "count": len(items), + "page": page, + "next_page": next_page, + "limit": bounded_limit, + }, + warnings=warnings, + next_actions=[ + "Use mutate_graph_properties_tool dry_run before changing returned items.", + "If HugeGraph reports no index for condition queries, create indexes in the P0b index workflow.", + ], + ) + + +def _validate_inputs( + *, + target: str, + operation: str, + id: Any, + ids: list[Any] | None, + label: str | None, + properties: dict[str, Any] | None, + limit: int | None, + vertex_id: Any, + direction: str | None, +) -> dict[str, Any] | None: + if target not in TARGETS: + return _validation_error( + f"Unsupported target: {target!r}.", + "Use target='vertex' or target='edge'.", + {"target": target}, + ) + if operation not in OPERATIONS: + return _validation_error( + f"Unsupported operation: {operation!r}.", + "Use one of: get_by_id, get_by_ids, page, condition.", + {"operation": operation}, + ) + if operation == "get_by_id" and _is_blank(id): + return _validation_error( + "id is required for operation='get_by_id'.", + "Pass the exact vertex or edge id.", + {"operation": operation}, + ) + if target == "vertex" and operation == "get_by_id": + id_error = _validate_vertex_id(id, field="id") + if id_error is not None: + return id_error + if operation == "get_by_ids": + if not isinstance(ids, list) or not ids: + return _validation_error( + "ids must be a non-empty list for operation='get_by_ids'.", + "Pass one or more exact vertex or edge ids.", + {"operation": operation}, + ) + if any(_is_blank(item) for item in ids): + return _validation_error( + "ids cannot contain empty values.", + "Remove null or empty ids before querying.", + {"ids": ids}, + ) + if len(ids) > MAX_LIMIT: + return _validation_error( + f"ids length exceeds maximum {MAX_LIMIT}.", + "Split the request into smaller batches.", + {"ids_length": len(ids), "max": MAX_LIMIT}, + ) + if target == "vertex": + for index, item in enumerate(ids): + id_error = _validate_vertex_id(item, field=f"ids[{index}]") + if id_error is not None: + return id_error + if operation == "page" and target == "vertex" and _is_blank(label): + return _validation_error( + "label is required for vertex page queries.", + "Pass a vertex label or use operation='condition'.", + {"target": target, "operation": operation}, + ) + if properties is not None and not isinstance(properties, dict): + return _validation_error( + "properties must be an object when provided.", + "Pass exact-match property filters as a JSON object.", + {"properties_type": type(properties).__name__}, + ) + if operation == "condition" and not properties: + return _validation_error( + "properties is required for operation='condition'.", + "Pass exact-match property filters, or use operation='page' for bounded scans.", + {"operation": operation, "properties": properties}, + ) + if operation in {"page", "condition"}: + limit_error = _validate_limit(limit) + if limit_error is not None: + return limit_error + if target == "edge" and operation in {"page", "condition"}: + if vertex_id is not None and direction is None: + return _validation_error( + "direction is required when querying edges by vertex_id.", + "Pass direction='OUT', 'IN', or 'BOTH'.", + {"vertex_id": vertex_id, "direction": direction}, + ) + if vertex_id is not None and _normalize_direction(direction) is None: + return _validation_error( + f"Unsupported direction: {direction!r}.", + "Pass direction='OUT', 'IN', or 'BOTH'.", + {"vertex_id": vertex_id, "direction": direction}, + ) + if vertex_id is None and direction is not None: + return _validation_error( + "direction requires vertex_id for edge queries.", + "Pass vertex_id together with direction, or omit direction.", + {"direction": direction}, + ) + return None + + +def _validate_vertex_id(value: Any, *, field: str) -> dict[str, Any] | None: + try: + format_vertex_id(value) + except (TypeError, ValueError) as exc: + return _validation_error( + f"Invalid vertex id in {field}: {exc!s}", + "Pass a HugeGraph vertex id as a string, UUID, or Java signed 64-bit integer.", + { + "field": field, + "id_type": type(value).__name__, + "reason": str(exc), + }, + ) + return None + + +def _validate_limit(limit: int | None) -> dict[str, Any] | None: + if limit is None: + return None + try: + parsed = int(limit) + except (TypeError, ValueError): + return _validation_error( + "limit must be an integer.", + f"Pass an integer from 1 to {MAX_LIMIT}.", + {"limit": limit}, + ) + if parsed < 1: + return _validation_error( + "limit must be at least 1.", + f"Pass an integer from 1 to {MAX_LIMIT}.", + {"limit": limit}, + ) + if parsed > MAX_LIMIT: + return _validation_error( + f"limit exceeds maximum {MAX_LIMIT}.", + "Reduce limit or use page to continue reading.", + {"limit": limit, "max": MAX_LIMIT}, + ) + return None + + +def _execute_query( + *, + manager, + target: str, + operation: str, + id: Any, + ids: list[Any], + label: str | None, + properties: dict[str, Any] | None, + limit: int, + page: str | None, + vertex_id: Any, + direction: str | None, +) -> tuple[Any, str | None]: + if target == "vertex": + return _execute_vertex_query( + manager=manager, + operation=operation, + id=id, + ids=ids, + label=label, + properties=properties, + limit=limit, + page=page, + ) + return _execute_edge_query( + manager=manager, + operation=operation, + id=id, + ids=ids, + label=label, + properties=properties, + limit=limit, + page=page, + vertex_id=vertex_id, + direction=direction, + ) + + +def _execute_vertex_query( + *, + manager, + operation: str, + id: Any, + ids: list[Any], + label: str | None, + properties: dict[str, Any] | None, + limit: int, + page: str | None, +) -> tuple[Any, str | None]: + if operation == "get_by_id": + return manager.getVertexById(id), None + if operation == "get_by_ids": + return manager.getVerticesById(ids), None + if operation == "page": + items, next_page = manager.getVertexByPage( + label=label, + limit=limit, + page=page, + properties=properties, + ) + return items, next_page + get_by_condition = getattr( + manager, + "getVertexByConditionWithPage", + manager.getVertexByCondition, + ) + result = get_by_condition( + label=label or "", + limit=limit, + page=page, + properties=properties, + ) + if isinstance(result, tuple) and len(result) == 2: + items, next_page = result + else: + items, next_page = result, None + return items, next_page + + +def _execute_edge_query( + *, + manager, + operation: str, + id: Any, + ids: list[Any], + label: str | None, + properties: dict[str, Any] | None, + limit: int, + page: str | None, + vertex_id: Any, + direction: str | None, +) -> tuple[Any, str | None]: + if operation == "get_by_id": + return manager.getEdgeById(id), None + if operation == "get_by_ids": + return manager.getEdgesById(ids), None + items, next_page = manager.getEdgeByPage( + label=label, + vertex_id=vertex_id, + direction=direction, + limit=limit, + page=page, + properties=properties, + ) + return items, next_page + + +def _query_error(exc: Exception) -> dict[str, Any]: + classification = classify_hugegraph_exception(exc) + next_actions = [ + "Retry with exact id lookup if possible.", + "For no-index condition queries, create an index in the P0b index workflow.", + ] + return envelope_err( + classification.error_type, + f"HugeGraph graph data query failed: {exc!s}", + suggestion=classification.suggestion, + retryable=classification.retryable, + source="query_graph_data_tool", + details={"error": str(exc), "reason": classification.reason}, + next_actions=next_actions, + ) + + +def _validation_error( + message: str, + suggestion: str, + details: dict[str, Any], +) -> dict[str, Any]: + return envelope_err( + ErrorType.VALIDATION_ERROR, + message, + suggestion=suggestion, + source="query_graph_data_tool", + details=details, + ) + + +def _normalize_items(value: Any) -> list[dict[str, Any]]: + if value is None: + return [] + if isinstance(value, list): + return [_plain_item(item) for item in value if item is not None] + return [_plain_item(value)] + + +def _plain_item(item: Any) -> dict[str, Any]: + if isinstance(item, dict): + return dict(item) + result: dict[str, Any] = {} + for name in ( + "id", + "label", + "type", + "properties", + "outV", + "outVLabel", + "inV", + "inVLabel", + ): + if hasattr(item, name): + result[name] = getattr(item, name) + if not result: + result["value"] = item + return result + + +def _normalize_ids( + ids: list[Any] | None, warnings: list[str], *, target: str +) -> list[Any]: + seen: set[str] = set() + normalized: list[Any] = [] + duplicate_count = 0 + for item in ids or []: + key = _id_dedupe_key(item, target=target) + if key in seen: + duplicate_count += 1 + continue + seen.add(key) + normalized.append(item) + if duplicate_count: + warnings.append(f"Ignored {duplicate_count} duplicate id value(s).") + return normalized + + +def _id_dedupe_key(item: Any, *, target: str) -> str: + return json.dumps( + {"target": target, "type": type(item).__name__, "value": item}, + sort_keys=True, + default=str, + ) + + +def _normalize_direction(direction: str | None) -> str | None: + if direction is None: + return None + upper = str(direction).strip().upper() + return upper if upper in VALID_DIRECTIONS else None + + +def _is_blank(value: Any) -> bool: + return value is None or (isinstance(value, str) and value.strip() == "") diff --git a/hugegraph-mcp/tests/integration/test_real_write_path.py b/hugegraph-mcp/tests/integration/test_real_write_path.py index a8ef15605..1c2d4a8b5 100644 --- a/hugegraph-mcp/tests/integration/test_real_write_path.py +++ b/hugegraph-mcp/tests/integration/test_real_write_path.py @@ -15,6 +15,7 @@ from __future__ import annotations +import time from uuid import uuid4 import pytest @@ -367,6 +368,107 @@ def test_public_delete_edge_and_vertices_real_graph_state_matches( 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_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) + + assert result["ok"] is True + edge_id = _edge_id(hugegraph_client, names) + assert ">" in edge_id + + query_result = server.query_graph_data_tool( + target="edge", + operation="get_by_id", + id=edge_id, + ) + + assert query_result["ok"] is True + assert query_result["data"]["items"][0]["id"] == edge_id + + dry_run = server.mutate_graph_properties_tool( + target="edge", + operation="append", + 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="append", + 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 ( + 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"] + + def test_partial_write_returns_error_envelope_and_real_graph_state_matches( hugegraph_client, ): @@ -539,7 +641,7 @@ def _ensure_custom_id_schema( ).useCustomizeStringId().nullableKeys(names.name_key).ifNotExist().create() schema.edgeLabel(names.edge_label).sourceLabel(names.vertex_label).targetLabel( names.vertex_label - ).ifNotExist().create() + ).properties(names.name_key).nullableKeys(names.name_key).ifNotExist().create() index = ( schema.indexLabel(names.name_index).onV(names.vertex_label).by(names.name_key) ) @@ -547,6 +649,7 @@ def _ensure_custom_id_schema( index.unique().ifNotExist().create() else: index.secondary().ifNotExist().create() + _wait_for_schema_visibility(client, names, custom_id=True) def _ensure_primary_key_schema(client, names: _Names) -> None: @@ -557,7 +660,8 @@ def _ensure_primary_key_schema(client, names: _Names) -> None: ).ifNotExist().create() schema.edgeLabel(names.edge_label).sourceLabel(names.vertex_label).targetLabel( names.vertex_label - ).ifNotExist().create() + ).properties(names.name_key).nullableKeys(names.name_key).ifNotExist().create() + _wait_for_schema_visibility(client, names) def _exec(client, query: str): @@ -568,6 +672,71 @@ def _count(client, query: str) -> int: return int(_extract_count(_exec(client, f"{query}.count()")) or 0) +def _edge_id(client, names: _Names) -> str: + data = _exec(client, f"g.E().hasLabel({_g(names.edge_label)}).id()") + edge_id = _extract_count(data) + assert isinstance(edge_id, str) + return edge_id + + +def _wait_for_schema_visibility( + client, names: _Names, *, custom_id: bool = False +) -> None: + deadline = time.monotonic() + 5.0 + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + schema = client.schema().getSchema() + 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) + } + 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) + } + if ( + names.name_key in property_keys + and names.vertex_label in vertex_labels + and names.edge_label in edge_labels + ): + _write_and_drop_schema_probe(client, names, custom_id=custom_id) + return + except Exception as exc: + last_error = exc + time.sleep(0.1) + pytest.fail( + "Timed out waiting for HugeGraph schema visibility: " + f"{names.vertex_label}, last_error={last_error}" + ) + + +def _write_and_drop_schema_probe( + client, + names: _Names, + *, + custom_id: bool, +) -> None: + probe_value = f"__schema_probe_{uuid4().hex}" + add_vertex = ( + f"g.addV({_g(names.vertex_label)}).property(T.id,{_g(probe_value)})" + f".property({_g(names.name_key)},{_g(probe_value)}).next()" + if custom_id + else f"g.addV({_g(names.vertex_label)}).property({_g(names.name_key)},{_g(probe_value)}).next()" + ) + _exec(client, "v = " + add_vertex + "; g.V(v.id()).drop().iterate(); true") + + def _extract_count(data): if isinstance(data, dict) and "data" in data: return _extract_count(data["data"]) diff --git a/hugegraph-mcp/tests/test_envelope.py b/hugegraph-mcp/tests/test_envelope.py index d0871d735..91ad29adc 100644 --- a/hugegraph-mcp/tests/test_envelope.py +++ b/hugegraph-mcp/tests/test_envelope.py @@ -74,7 +74,7 @@ def test_envelope_err_defaults(): def test_envelope_err_all_error_types(): - assert len(ErrorType) == 21 + assert len(ErrorType) == 23 assert ErrorType.VALIDATION_ERROR.value == "VALIDATION_ERROR" assert ErrorType.PLAN_EXPIRED.value == "PLAN_EXPIRED" assert ErrorType.NOT_FOUND.value == "NOT_FOUND" diff --git a/hugegraph-mcp/tests/test_error_handling.py b/hugegraph-mcp/tests/test_error_handling.py index 34b4a7ce6..3d0c1b43e 100644 --- a/hugegraph-mcp/tests/test_error_handling.py +++ b/hugegraph-mcp/tests/test_error_handling.py @@ -16,6 +16,18 @@ from unittest.mock import Mock, patch import requests +from hugegraph_mcp.confirmable_workflow import ( + confirm_required_error, + mark_readonly_preview, + plan_hash_error, +) +from hugegraph_mcp.envelope import ( + REDACTED_VALUE, + ErrorType, + envelope_err, + sanitize_for_response, +) +from hugegraph_mcp.error_mapping import classify_hugegraph_error_message from hugegraph_mcp.gremlin_tools import execute_gremlin_read, execute_gremlin_write @@ -250,6 +262,180 @@ def test_no_index_exception_is_classified_as_no_index(): assert result["error"]["retryable"] is False +def test_hugegraph_error_mapping_recognizes_no_index_message_variants(): + messages = [ + "NoIndexException: no index", + "The property key 'name' is not indexed", + "may not match secondary condition without index", + ] + + for message in messages: + classification = classify_hugegraph_error_message(message) + assert classification.error_type == "NO_INDEX" + assert classification.retryable is False + assert classification.reason == "no_index" + + +def test_hugegraph_error_mapping_recognizes_schema_missing_messages(): + messages = [ + "Property key does not exist: age", + "Edge label does not exist: knows", + ] + + for message in messages: + classification = classify_hugegraph_error_message(message) + assert classification.error_type == "SCHEMA_MISMATCH" + assert classification.retryable is False + assert classification.reason == "schema_missing" + assert "live schema" in classification.suggestion + assert "label" in classification.suggestion + assert "property key" in classification.suggestion + + +def test_hugegraph_error_mapping_recognizes_not_found_message_variants(): + messages = [ + "404 Not Found", + "Vertex does not exist: 1", + "edge not found", + ] + + for message in messages: + classification = classify_hugegraph_error_message(message) + assert classification.error_type == "NOT_FOUND" + assert classification.retryable is False + assert classification.reason == "not_found" + + +def test_hugegraph_error_mapping_keeps_no_index_before_schema_missing(): + messages = [ + "NoIndexException: property key does not exist: age", + "The property key 'age' is not indexed", + ] + + for message in messages: + classification = classify_hugegraph_error_message(message) + assert classification.error_type == "NO_INDEX" + assert classification.retryable is False + assert classification.reason == "no_index" + + +def test_confirmable_workflow_helpers_preserve_standard_envelopes(): + payload, warnings, next_actions = mark_readonly_preview( + {"confirmable": True}, + warning="readonly warning", + next_action="rerun dry_run", + ) + assert payload["confirmable"] is False + assert payload["readonly_preview_only"] is True + assert warnings == ["readonly warning"] + assert next_actions == ["rerun dry_run"] + + confirm_error = confirm_required_error( + message="confirm required", + suggestion="run dry_run", + source="test_tool", + ) + assert confirm_error["error"]["type"] == "CONFIRM_REQUIRED" + assert confirm_error["error"]["source"] == "test_tool" + + expired_error = plan_hash_error( + error_type=ErrorType.PLAN_EXPIRED, + details={"reason": "expired"}, + mismatch_message="mismatch", + expired_message="expired", + suggestion="rerun", + ) + assert expired_error["error"]["type"] == "PLAN_EXPIRED" + assert expired_error["error"]["message"] == "expired" + + +def test_error_envelope_redacts_sensitive_values(): + result = envelope_err( + ErrorType.SERVER_ERROR, + "failed password=secret token=abc http://user:pass@example.com/path", + suggestion="Check Authorization header", + details={ + "password": "secret", + "nested": {"access_token": "abc"}, + "url": "http://user:pass@example.com/path?token=abc", + }, + ) + + rendered = str(result) + assert "secret" not in rendered + assert "token=abc" not in rendered + assert "user:pass@" not in rendered + assert result["error"]["details"]["password"] == "***REDACTED***" + assert result["error"]["details"]["nested"]["access_token"] == "***REDACTED***" + + +def test_sanitize_redacts_http_header_format(): + message = "Authorization: Bearer abc123token" + + redacted = sanitize_for_response(message) + + assert "abc123token" not in redacted + assert redacted == f"Authorization: {REDACTED_VALUE}" + + +def test_sanitize_redacts_python_dict_repr_format(): + message = "{'Authorization': 'Bearer abc123', 'token': 'xyz'}" + + redacted = sanitize_for_response(message) + + assert "abc123" not in redacted + assert "xyz" not in redacted + assert redacted == ( + "{'Authorization': '***REDACTED***', 'token': '***REDACTED***'}" + ) + + +def test_sanitize_redacts_bare_text_format(): + message = "request failed, token abc123 rejected" + + redacted = sanitize_for_response(message) + + assert "abc123" not in redacted + assert "request failed" in redacted + assert "rejected" in redacted + assert redacted == f"request failed, token {REDACTED_VALUE} rejected" + + +def test_sanitize_preserves_existing_quoted_json_format(): + message = '{"api_key": "sk-xxx"}' + + redacted = sanitize_for_response(message) + + assert "sk-xxx" not in redacted + assert redacted == '{"api_key": "***REDACTED***"}' + + +def test_sanitize_preserves_existing_query_string_format(): + message = "token=abc123&other=1" + + redacted = sanitize_for_response(message) + + assert "abc123" not in redacted + assert redacted == f"token={REDACTED_VALUE}&other=1" + + +def test_sanitize_does_not_redact_unrelated_text(): + message = "vertex label already exists: person" + + redacted = sanitize_for_response(message) + + assert redacted == message + + +def test_sanitize_bare_text_does_not_over_match_short_values(): + message = "the token is invalid" + + redacted = sanitize_for_response(message) + + assert redacted == message + assert "token is invalid" in redacted + + def test_successful_execution_preserves_format(): """Test that successful execution maintains the expected format.""" with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: diff --git a/hugegraph-mcp/tests/test_generate_gremlin.py b/hugegraph-mcp/tests/test_generate_gremlin.py index b20cfd73d..3501170ea 100644 --- a/hugegraph-mcp/tests/test_generate_gremlin.py +++ b/hugegraph-mcp/tests/test_generate_gremlin.py @@ -14,6 +14,7 @@ from unittest.mock import Mock from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok +from hugegraph_mcp import gremlin_tools from hugegraph_mcp.tools import generate_gremlin as generate_gremlin_module @@ -29,6 +30,11 @@ def _ai_ok(gremlin: str, **extra) -> dict: return envelope_ok(data) +class FakeGremlinClient: + def exec(self, query: str): + return {"data": [{"query": query}], "meta": {}} + + def test_generate_gremlin_default_no_execute(monkeypatch): post = Mock(return_value=_ai_ok("g.V().count()")) execute_read = Mock() @@ -108,7 +114,7 @@ def test_generate_gremlin_safe_execute(monkeypatch): assert result["data"]["executed"] is True assert result["data"]["execution_result"] == execution_data assert result["data"]["execution_meta"] == execution_result["meta"] - execute_read.assert_called_once_with("g.V().limit(2)") + execute_read.assert_called_once_with("g.V().limit(2)", limit_policy="warn") def test_generate_gremlin_unwraps_execution_envelope(monkeypatch): @@ -128,6 +134,59 @@ def test_generate_gremlin_unwraps_execution_envelope(monkeypatch): assert "ok" not in result["data"]["execution_result"] +def test_generate_gremlin_passes_limit_policy(monkeypatch): + post = Mock(return_value=_ai_ok("g.V()")) + execution_result = envelope_err( + ErrorType.VALIDATION_ERROR, + "unbounded", + ) + execute_read = Mock(return_value=execution_result) + monkeypatch.setattr(generate_gremlin_module, "post", post) + monkeypatch.setattr(generate_gremlin_module, "execute_gremlin_read", execute_read) + + result = generate_gremlin_module.generate_gremlin( + "show vertices", + execute=True, + limit_policy="reject_unbounded", + ) + + assert result["ok"] is False + execute_read.assert_called_once_with("g.V()", limit_policy="reject_unbounded") + + +def test_generate_gremlin_reject_unbounded_limit_policy(monkeypatch): + post = Mock(return_value=_ai_ok("g.V()")) + monkeypatch.setattr(generate_gremlin_module, "post", post) + + result = generate_gremlin_module.generate_gremlin( + "show vertices", + execute=True, + limit_policy="reject_unbounded", + ) + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert "unbounded" in result["error"]["message"] + + +def test_generate_gremlin_auto_append_limit_policy(monkeypatch): + post = Mock(return_value=_ai_ok("g.V()")) + monkeypatch.setattr(generate_gremlin_module, "post", post) + monkeypatch.setattr(gremlin_tools, "_get_read_client", lambda: FakeGremlinClient()) + + result = generate_gremlin_module.generate_gremlin( + "show vertices", + execute=True, + limit_policy="auto_append", + ) + + assert result["ok"] is True + execution = result["data"]["execution_result"] + assert execution["original_gremlin"] == "g.V()" + assert execution["executed_gremlin"] == "g.V().limit(100)" + assert execution["rewrite_reason"] + + def test_generate_gremlin_propagates_execute_failure(monkeypatch): post = Mock(return_value=_ai_ok("g.V().has('name','Alice')")) execution_error = envelope_err( diff --git a/hugegraph-mcp/tests/test_hugegraph_ai_client.py b/hugegraph-mcp/tests/test_hugegraph_ai_client.py index e99bf943e..e87f44603 100644 --- a/hugegraph-mcp/tests/test_hugegraph_ai_client.py +++ b/hugegraph-mcp/tests/test_hugegraph_ai_client.py @@ -172,8 +172,11 @@ def test_request_allow_ai_disabled(monkeypatch): result = request("GET", "/health", cfg=_cfg(allow_ai=False)) assert result["ok"] is False - assert result["error"]["type"] == "HUGEGRAPH_AI_UNAVAILABLE" - assert result["error"]["message"] == "AI calls are disabled" + assert result["error"]["type"] == "FEATURE_DISABLED" + assert result["error"]["message"] == "AI calls are disabled by configuration" + assert "HUGEGRAPH_MCP_ALLOW_AI=true" in result["error"]["suggestion"] + assert result["error"]["retryable"] is False + assert result["error"]["details"]["reason"] == "allow_ai_false" http_request.assert_not_called() diff --git a/hugegraph-mcp/tests/test_import_graph_data_tool.py b/hugegraph-mcp/tests/test_import_graph_data_tool.py index f06fea6e4..433d0c9f6 100644 --- a/hugegraph-mcp/tests/test_import_graph_data_tool.py +++ b/hugegraph-mcp/tests/test_import_graph_data_tool.py @@ -172,4 +172,11 @@ def test_import_graph_data_tool_table_returns_feature_disabled(): assert result["ok"] is False assert result["error"]["type"] == "FEATURE_DISABLED" assert result["error"]["source"] == "import_graph_data_tool" + assert ( + result["error"]["message"] + == "Table import is not supported by the current MCP contract." + ) + assert "import_graph_data_tool(mode='extract')" in result["error"]["suggestion"] + assert "import_graph_data_tool(mode='ingest')" in result["error"]["suggestion"] + assert "V1" not in result["error"]["message"] assert "duration_ms" in result["meta"] diff --git a/hugegraph-mcp/tests/test_inspect_schema_tool.py b/hugegraph-mcp/tests/test_inspect_schema_tool.py new file mode 100644 index 000000000..38055c745 --- /dev/null +++ b/hugegraph-mcp/tests/test_inspect_schema_tool.py @@ -0,0 +1,119 @@ +# 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. + +from hugegraph_mcp.tools import inspect_schema as inspect_schema_module + + +def _raw_schema(): + return { + "propertykeys": [{"name": "name", "data_type": "TEXT"}], + "vertexlabels": [ + { + "name": "person", + "properties": ["name"], + "primary_keys": ["name"], + "nullable_keys": [], + } + ], + "edgelabels": [ + { + "name": "knows", + "source_label": "person", + "target_label": "person", + "properties": ["since"], + } + ], + "indexlabels": [{"name": "personByName", "base_label": "person"}], + } + + +class FakeSchemaManager: + def getSchema(self): + return _raw_schema() + + def getRelations(self): + return ["person--knows-->person"] + + +def test_inspect_schema_summary(monkeypatch): + monkeypatch.setattr( + inspect_schema_module, + "_schema_manager", + lambda: FakeSchemaManager(), + ) + + result = inspect_schema_module.inspect_schema(include_raw_schema=True) + + assert result["ok"] is True + assert result["data"]["summary"]["property_key_count"] == 1 + assert result["data"]["summary"]["vertex_label_count"] == 1 + assert result["data"]["relations"] == ["person--knows-->person"] + assert result["data"]["raw_schema"] == _raw_schema() + + +def test_inspect_schema_filter_one_vertex_label(monkeypatch): + monkeypatch.setattr( + inspect_schema_module, + "_schema_manager", + lambda: FakeSchemaManager(), + ) + + result = inspect_schema_module.inspect_schema( + filter_kind="vertex_label", + filter_name="person", + ) + + assert result["ok"] is True + assert result["data"]["filtered"]["name"] == "person" + assert result["data"]["filtered"]["primary_keys"] == ["name"] + + +def test_inspect_schema_rejects_filter_name_without_kind(): + result = inspect_schema_module.inspect_schema(filter_name="person") + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + + +def test_inspect_schema_not_found(monkeypatch): + monkeypatch.setattr( + inspect_schema_module, + "_schema_manager", + lambda: FakeSchemaManager(), + ) + + result = inspect_schema_module.inspect_schema( + filter_kind="edge_label", + filter_name="missing", + ) + + assert result["ok"] is False + assert result["error"]["type"] == "NOT_FOUND" + + +def test_inspect_schema_connection_error(monkeypatch): + class BrokenManager: + def getSchema(self): + raise ConnectionError("down") + + monkeypatch.setattr( + inspect_schema_module, + "_schema_manager", + lambda: BrokenManager(), + ) + + result = inspect_schema_module.inspect_schema() + + assert result["ok"] is False + assert result["error"]["type"] == "CONNECTION_FAILED" + assert result["error"]["retryable"] is True diff --git a/hugegraph-mcp/tests/test_manage_schema.py b/hugegraph-mcp/tests/test_manage_schema.py index e740282d3..b4eb8dcaf 100644 --- a/hugegraph-mcp/tests/test_manage_schema.py +++ b/hugegraph-mcp/tests/test_manage_schema.py @@ -49,21 +49,96 @@ def _schema( } -def _property_key(name="age"): - return {"type": "create_property_key", "name": name, "data_type": "INT"} +def _property_key(name="age", data_type="INT"): + return {"type": "create_property_key", "name": name, "data_type": data_type} + + +class RecordingPropertyBuilder: + def __init__(self, name, state=None): + self.name = name + self.state = state + self.data_type = None + self.cardinality = None + self.aggregate_type = None + + def add_parameter(self, key, value): + setattr(self, key, value) + + def asInt(self): + self.data_type = "INT" + return self + + def asBool(self): + self.data_type = "BOOLEAN" + return self + + def valueSingle(self): + self.cardinality = "SINGLE" + return self + + def valueSet(self): + self.cardinality = "SET" + return self + + def valueList(self): + self.cardinality = "LIST" + return self + + def calcSum(self): + self.aggregate_type = "SUM" + return self + + def create(self): + if self.state is not None: + property_key = { + "name": self.name, + "data_type": self.data_type, + "cardinality": self.cardinality, + } + if self.aggregate_type is not None: + property_key["aggregate_type"] = self.aggregate_type + self.state["schema"]["propertykeys"].append(property_key) + return "ok" + + +class RecordingPropertyManager: + def __init__(self, state=None): + self.state = state + self.builders = [] + + def propertyKey(self, name): + builder = RecordingPropertyBuilder(name, self.state) + self.builders.append(builder) + return builder -def _vertex_label(name="person", properties=None, primary_keys=None): +def _vertex_label( + name="person", + properties=None, + primary_keys=None, + id_strategy=None, + nullable_keys=None, +): operation = {"type": "create_vertex_label", "name": name} if properties is not None: operation["properties"] = properties if primary_keys is not None: operation["primary_keys"] = primary_keys + if id_strategy is not None: + operation["id_strategy"] = id_strategy + if nullable_keys is not None: + operation["nullable_keys"] = nullable_keys return operation def _edge_label( - name="knows", source_label="person", target_label="person", properties=None + name="knows", + source_label="person", + target_label="person", + properties=None, + nullable_keys=None, + sort_keys=None, + frequency=None, ): operation = { "type": "create_edge_label", @@ -73,6 +148,12 @@ def _edge_label( } if properties is not None: operation["properties"] = properties + if nullable_keys is not None: + operation["nullable_keys"] = nullable_keys + if sort_keys is not None: + operation["sort_keys"] = sort_keys + if frequency is not None: + operation["frequency"] = frequency return operation @@ -106,6 +187,12 @@ def _live_edge(name, source_label="person", target_label="software"): } +def _assert_dry_run_invalid(result): + assert result["ok"] is True + assert result["data"]["valid"] is False + assert "plan_hash" not in result["data"] + + def test_manage_schema_design(): result = manage_schema( mode="design", @@ -281,6 +368,127 @@ def test_manage_schema_validate_rejects_duplicate_vertex_label(monkeypatch): assert error["reason"] == "vertex label already exists: person" +def test_manage_schema_validate_rejects_primary_key_label_without_primary_keys( + monkeypatch, +): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(propertykeys=[_live_pk("name")]), + ) + + result = manage_schema( + mode="validate", + operations=[_vertex_label("person", properties=["name"])], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + assert result["data"]["errors"][0]["reason"] == ( + "primary_keys is required when id_strategy is PRIMARY_KEY" + ) + + +def test_manage_schema_validate_rejects_primary_key_not_in_properties(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(propertykeys=[_live_pk("name"), _live_pk("age")]), + ) + + result = manage_schema( + mode="validate", + operations=[_vertex_label("person", properties=["name"], primary_keys=["age"])], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + assert result["data"]["errors"][0]["reason"] == ( + "primary_keys must be included in properties: age" + ) + + +def test_manage_schema_validate_rejects_unknown_primary_key_property(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(propertykeys=[_live_pk("name")]), + ) + + result = manage_schema( + mode="validate", + operations=[ + _vertex_label("person", properties=["name", "age"], primary_keys=["age"]) + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + assert any( + error["reason"] == "primary_keys references undefined property key(s): age" + for error in result["data"]["errors"] + ) + + +def test_manage_schema_validate_rejects_non_string_primary_key(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(propertykeys=[_live_pk("name")]), + ) + + result = manage_schema( + mode="validate", + operations=[_vertex_label("person", properties=["name"], primary_keys=[1])], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + assert result["data"]["errors"][0]["reason"] == ( + "primary_keys must contain non-empty string names" + ) + + +def test_manage_schema_validate_rejects_duplicate_primary_key(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(propertykeys=[_live_pk("name")]), + ) + + result = manage_schema( + mode="validate", + operations=[ + _vertex_label("person", properties=["name"], primary_keys=["name", "name"]) + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + assert result["data"]["errors"][0]["reason"] == ( + "primary_keys contains duplicate name(s): name" + ) + + +def test_manage_schema_validate_accepts_automatic_id_without_primary_keys(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(propertykeys=[_live_pk("name")]), + ) + + result = manage_schema( + mode="validate", + operations=[ + _vertex_label("person", properties=["name"], id_strategy="AUTOMATIC") + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is True + assert result["data"]["errors"] == [] + + def test_manage_schema_validate_accepts_semantically_valid_operations(monkeypatch): monkeypatch.setattr( manage_schema_module.schema_tools, @@ -328,6 +536,14 @@ def raise_connection_error(): assert result["error"]["details"]["stage"] == "schema_fetch" +def test_manage_schema_rejects_unknown_mode_as_validation_error(): + result = manage_schema(mode="typo", operations=[]) + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert "Unsupported manage_schema mode" in result["error"]["message"] + + def test_same_batch_pk_to_vertex_label(monkeypatch): monkeypatch.setattr( manage_schema_module.schema_tools, "get_live_schema", _empty_schema @@ -354,8 +570,9 @@ def test_same_batch_vertex_to_edge_label(monkeypatch): result = manage_schema( mode="validate", operations=[ - _vertex_label("person"), - _vertex_label("software"), + _property_key("name"), + _vertex_label("person", properties=["name"], primary_keys=["name"]), + _vertex_label("software", properties=["name"], primary_keys=["name"]), _edge_label("created", source_label="person", target_label="software"), ], ) @@ -365,6 +582,29 @@ def test_same_batch_vertex_to_edge_label(monkeypatch): assert result["data"]["errors"] == [] +def test_same_batch_later_vertex_label_is_not_available_to_earlier_edge(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="dry_run", + operations=[ + _property_key("name"), + _vertex_label("person", properties=["name"], primary_keys=["name"]), + _edge_label("created", source_label="person", target_label="software"), + _vertex_label("software", properties=["name"], primary_keys=["name"]), + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is False + assert "plan_hash" not in result["data"] + assert result["data"]["errors"][0]["reason"] == ( + "target_label references undefined vertex label: software" + ) + + def test_same_batch_label_to_index(monkeypatch): monkeypatch.setattr( manage_schema_module.schema_tools, "get_live_schema", _empty_schema @@ -461,7 +701,8 @@ def test_same_batch_edge_missing_endpoint(monkeypatch): result = manage_schema( mode="validate", operations=[ - _vertex_label("person"), + _property_key("name"), + _vertex_label("person", properties=["name"], primary_keys=["name"]), _edge_label("created", source_label="person", target_label="software"), ], ) @@ -469,7 +710,7 @@ def test_same_batch_edge_missing_endpoint(monkeypatch): assert result["ok"] is True assert result["data"]["valid"] is False error = result["data"]["errors"][0] - assert error["operation_index"] == 1 + assert error["operation_index"] == 2 assert error["reason"] == "target_label references undefined vertex label: software" @@ -510,106 +751,741 @@ def test_manage_schema_dry_run_invalid_schema_has_no_plan_hash(monkeypatch): assert "plan_hash" not in result["data"] -def test_manage_schema_dry_run_same_ops_same_hash(monkeypatch): +def test_dry_run_rejects_invalid_id_strategy_enum(monkeypatch): monkeypatch.setattr( - manage_schema_module.schema_tools, "get_live_schema", _empty_schema + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(propertykeys=[_live_pk("name")]), ) - first = manage_schema(mode="dry_run", operations=[_property_key()]) - second = manage_schema(mode="dry_run", operations=[_property_key()]) + result = manage_schema( + mode="dry_run", + operations=[ + _vertex_label( + "person", + properties=["name"], + primary_keys=["name"], + id_strategy="FOO", + ) + ], + ) - assert first["data"]["plan_hash"] == second["data"]["plan_hash"] + _assert_dry_run_invalid(result) + assert result["data"]["errors"][0]["reason"] == "unsupported id_strategy: 'FOO'" -def test_manage_schema_dry_run_different_ops_different_hash(monkeypatch): +def test_dry_run_rejects_id_strategy_with_trailing_whitespace(monkeypatch): monkeypatch.setattr( - manage_schema_module.schema_tools, "get_live_schema", _empty_schema + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(propertykeys=[_live_pk("name")]), ) - first = manage_schema(mode="dry_run", operations=[_property_key("age")]) - second = manage_schema(mode="dry_run", operations=[_property_key("score")]) + result = manage_schema( + mode="dry_run", + operations=[ + _vertex_label( + "person", + properties=["name"], + primary_keys=["name"], + id_strategy="PRIMARY_KEY ", + ) + ], + ) - assert first["data"]["plan_hash"] != second["data"]["plan_hash"] + _assert_dry_run_invalid(result) + assert "unsupported id_strategy" in result["data"]["errors"][0]["reason"] -def test_manage_schema_plan_hash_schema_field_order_same_hash(): - operations = [_property_key()] - schema = _schema( - propertykeys=[ - {"name": "name", "data_type": "TEXT"}, - {"name": "age", "data_type": "INT"}, - ], - vertexlabels=[ - { - "name": "person", - "properties": [{"name": "name"}, {"name": "age"}], - "primary_keys": ["name"], - }, - ], - edgelabels=[ - {"name": "knows", "source_label": "person", "target_label": "person"}, - ], +def test_dry_run_rejects_non_string_id_strategy(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(propertykeys=[_live_pk("name")]), ) - reordered_schema = _schema( - propertykeys=[ - {"name": "age", "data_type": "INT"}, - {"name": "name", "data_type": "TEXT"}, - ], - vertexlabels=[ - { - "name": "person", - "properties": [{"name": "age"}, {"name": "name"}], - "primaryKeys": ["name"], - }, - ], - edgelabels=[ - {"name": "knows", "sourceLabel": "person", "targetLabel": "person"}, + + result = manage_schema( + mode="dry_run", + operations=[ + _vertex_label( + "person", + properties=["name"], + primary_keys=["name"], + id_strategy=123, + ) ], ) - first = manage_schema_module.calculate_plan_hash(operations, schema) - second = manage_schema_module.calculate_plan_hash(operations, reordered_schema) + _assert_dry_run_invalid(result) + assert result["data"]["errors"][0]["reason"] == ( + "id_strategy must be a string, got int" + ) - assert first == second +def test_dry_run_accepts_all_valid_id_strategies(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) -def test_manage_schema_plan_hash_schema_primary_key_change_different_hash(): - operations = [_property_key()] - schema = _schema( - vertexlabels=[ - {"name": "person", "properties": ["name", "age"], "primary_keys": ["name"]}, - ], + for id_strategy in manage_schema_module.VERTEX_LABEL_ID_STRATEGIES: + operation = _vertex_label( + f"person_{id_strategy.lower()}", + properties=["name"], + id_strategy=id_strategy, + ) + if id_strategy == "PRIMARY_KEY": + operation["primary_keys"] = ["name"] + result = manage_schema( + mode="dry_run", + operations=[_property_key("name"), operation], + nonce=f"id-{id_strategy}", + ) + + assert result["ok"] is True + assert result["data"]["valid"] is True + assert "plan_hash" in result["data"] + + +def test_dry_run_rejects_invalid_data_type_enum(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema ) - changed_schema = _schema( - vertexlabels=[ - {"name": "person", "properties": ["name", "age"], "primary_keys": ["age"]}, + + result = manage_schema( + mode="dry_run", + operations=[ + {"type": "create_property_key", "name": "age", "data_type": "FOOBAR"} ], ) - first = manage_schema_module.calculate_plan_hash(operations, schema) - second = manage_schema_module.calculate_plan_hash(operations, changed_schema) + _assert_dry_run_invalid(result) + assert result["data"]["errors"][0]["reason"] == "unsupported data_type: 'FOOBAR'" - assert first != second +def test_dry_run_rejects_non_string_data_type(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) -def test_manage_schema_plan_hash_schema_metadata_ignored_same_hash(): - operations = [_property_key()] - schema = _schema( - propertykeys=[{"name": "name", "data_type": "TEXT"}], - vertexlabels=[ - {"name": "person", "properties": ["name"], "primary_keys": ["name"]} - ], + result = manage_schema( + mode="dry_run", + operations=[{"type": "create_property_key", "name": "age", "data_type": 123}], ) - schema_with_metadata = _schema( - propertykeys=[ + + _assert_dry_run_invalid(result) + assert ( + result["data"]["errors"][0]["reason"] == "data_type must be a string, got int" + ) + + +def test_dry_run_rejects_invalid_cardinality_enum(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="dry_run", + operations=[ { - "id": 1, - "name": "name", + "type": "create_property_key", + "name": "tags", "data_type": "TEXT", - "user_data": {"x": "y"}, + "cardinality": "MANY", } ], - vertexlabels=[ + ) + + _assert_dry_run_invalid(result) + assert result["data"]["errors"][0]["reason"] == "unsupported cardinality: 'MANY'" + + +def test_dry_run_rejects_invalid_aggregate_type_when_provided(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + invalid = manage_schema( + mode="dry_run", + operations=[ + { + "type": "create_property_key", + "name": "score", + "data_type": "INT", + "aggregate_type": "AVG", + } + ], + ) + valid = manage_schema( + mode="dry_run", + operations=[ + {"type": "create_property_key", "name": "score", "data_type": "INT"} + ], + ) + + _assert_dry_run_invalid(invalid) + assert invalid["data"]["errors"][0]["reason"] == ( + "unsupported aggregate_type: 'AVG'" + ) + assert valid["ok"] is True + assert valid["data"]["valid"] is True + assert "plan_hash" in valid["data"] + + +def test_dry_run_accepts_hugegraph_1_7_aggregate_types(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + for aggregate_type in ("NONE", "OLD", "SUM", "MIN", "MAX", "SET", "LIST"): + cardinality = aggregate_type if aggregate_type in {"SET", "LIST"} else "SINGLE" + result = manage_schema( + mode="dry_run", + operations=[ + { + "type": "create_property_key", + "name": f"score_{aggregate_type.lower()}", + "data_type": "INT", + "cardinality": cardinality, + "aggregate_type": aggregate_type, + } + ], + nonce=f"aggregate-{aggregate_type}", + ) + + assert result["ok"] is True + assert result["data"]["valid"] is True + assert "plan_hash" in result["data"] + + +def test_dry_run_rejects_invalid_aggregate_cardinality_combination(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="dry_run", + operations=[ + { + "type": "create_property_key", + "name": "tags", + "data_type": "TEXT", + "cardinality": "SINGLE", + "aggregate_type": "SET", + } + ], + ) + + _assert_dry_run_invalid(result) + assert ( + result["data"]["errors"][0]["reason"] + == "aggregate_type 'SET' is not allowed with cardinality 'SINGLE'" + ) + + +def test_dry_run_accepts_set_aggregate_with_set_cardinality(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="dry_run", + operations=[ + { + "type": "create_property_key", + "name": "tags", + "data_type": "TEXT", + "cardinality": "SET", + "aggregate_type": "SET", + } + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is True + + +def test_dry_run_rejects_invalid_edge_frequency_when_provided(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(vertexlabels=[_live_vertex("person")]), + ) + + invalid = manage_schema( + mode="dry_run", + operations=[ + { + "type": "create_edge_label", + "name": "knows", + "source_label": "person", + "target_label": "person", + "frequency": "ONCE", + } + ], + ) + valid = manage_schema( + mode="dry_run", + operations=[_edge_label("knows", source_label="person", target_label="person")], + ) + + _assert_dry_run_invalid(invalid) + assert invalid["data"]["errors"][0]["reason"] == "unsupported frequency: 'ONCE'" + assert valid["ok"] is True + assert valid["data"]["valid"] is True + assert "plan_hash" in valid["data"] + + +def test_dry_run_rejects_nullable_and_sort_key_contract_violations(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema( + propertykeys=[ + _live_pk("name"), + _live_pk("nickname"), + _live_pk("rank"), + _live_pk("since"), + ], + vertexlabels=[_live_vertex("person")], + ), + ) + + cases = [ + ( + _vertex_label( + "person_v", + properties=["name"], + primary_keys=["name"], + nullable_keys="nickname", + ), + "nullable_keys must be a list", + ), + ( + _vertex_label( + "person_v", + properties=["name"], + primary_keys=["name"], + nullable_keys=[], + ), + "nullable_keys must be a non-empty list", + ), + ( + _vertex_label( + "person_v", + properties=["name"], + primary_keys=["name"], + nullable_keys=[""], + ), + "nullable_keys must contain non-empty string names", + ), + ( + _vertex_label( + "person_v", + properties=["name", "nickname"], + primary_keys=["name"], + nullable_keys=["nickname", "nickname"], + ), + "nullable_keys contains duplicate name(s): nickname", + ), + ( + _vertex_label( + "person_v", + properties=["name"], + primary_keys=["name"], + nullable_keys=["nickname"], + ), + "nullable_keys must be included in properties: nickname", + ), + ( + _edge_label( + "knows", + source_label="person", + target_label="person", + properties=["since"], + sort_keys=["rank"], + ), + "sort_keys must be included in properties: rank", + ), + ] + + for operation, reason in cases: + result = manage_schema(mode="dry_run", operations=[operation]) + + _assert_dry_run_invalid(result) + assert any(error["reason"] == reason for error in result["data"]["errors"]) + + +def test_dry_run_accepts_nullable_and_sort_keys_subset_of_properties(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema( + propertykeys=[_live_pk("name"), _live_pk("nickname"), _live_pk("since")], + vertexlabels=[_live_vertex("person")], + ), + ) + + result = manage_schema( + mode="dry_run", + operations=[ + _vertex_label( + "person_v", + properties=["name", "nickname"], + primary_keys=["name"], + nullable_keys=["nickname"], + ), + _edge_label( + "knows", + source_label="person", + target_label="person", + properties=["since"], + sort_keys=["since"], + frequency="MULTIPLE", + ), + ], + ) + + assert result["ok"] is True + assert result["data"]["valid"] is True + assert "plan_hash" in result["data"] + + +def test_dry_run_rejects_non_string_name(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema( + propertykeys=[_live_pk("name")], + vertexlabels=[_live_vertex("person")], + edgelabels=[ + _live_edge("knows", source_label="person", target_label="person") + ], + ), + ) + operations = [ + {"type": "create_property_key", "name": 123, "data_type": "TEXT"}, + {"type": "create_vertex_label", "name": 123}, + { + "type": "create_edge_label", + "name": 123, + "source_label": "person", + "target_label": "person", + }, + { + "type": "create_index_label", + "name": 123, + "base_type": "VERTEX", + "base_label": "person", + }, + ] + + for operation in operations: + result = manage_schema(mode="dry_run", operations=[operation]) + + _assert_dry_run_invalid(result) + assert result["data"]["errors"][0]["reason"].startswith( + "name must be a non-empty string" + ) + + +def test_dry_run_rejects_blank_name(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="dry_run", + operations=[ + {"type": "create_property_key", "name": " ", "data_type": "TEXT"} + ], + ) + + _assert_dry_run_invalid(result) + assert result["data"]["errors"][0]["reason"] == ( + "name must be a non-empty string, got ' '" + ) + + +def test_dry_run_rejects_non_string_source_or_target_label(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(vertexlabels=[_live_vertex("person")]), + ) + + invalid_source = manage_schema( + mode="dry_run", + operations=[ + { + "type": "create_edge_label", + "name": "knows", + "source_label": 123, + "target_label": "person", + } + ], + ) + blank_target = manage_schema( + mode="dry_run", + operations=[ + { + "type": "create_edge_label", + "name": "knows", + "source_label": "person", + "target_label": " ", + } + ], + ) + + _assert_dry_run_invalid(invalid_source) + _assert_dry_run_invalid(blank_target) + assert invalid_source["data"]["errors"][0]["reason"].startswith( + "source_label must be a non-empty string" + ) + assert blank_target["data"]["errors"][0]["reason"].startswith( + "target_label must be a non-empty string" + ) + + +def test_dry_run_rejects_non_string_base_label(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(vertexlabels=[_live_vertex("person")]), + ) + + result = manage_schema( + mode="dry_run", + operations=[ + { + "type": "create_index_label", + "name": "personByName", + "base_type": "VERTEX", + "base_label": 123, + } + ], + ) + + _assert_dry_run_invalid(result) + assert result["data"]["errors"][0]["reason"] == ( + "base_label must be a non-empty string, got 123" + ) + + +def test_dry_run_accepts_valid_property_key_enums(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + for data_type in manage_schema_module.PROPERTY_KEY_DATA_TYPES: + result = manage_schema( + mode="dry_run", + operations=[_property_key(f"pk_{data_type.lower()}", data_type)], + nonce=f"data-type-{data_type}", + ) + + assert result["ok"] is True + assert result["data"]["valid"] is True + assert "plan_hash" in result["data"] + + for cardinality in manage_schema_module.PROPERTY_KEY_CARDINALITIES: + result = manage_schema( + mode="dry_run", + operations=[ + { + "type": "create_property_key", + "name": f"pk_{cardinality.lower()}", + "data_type": "TEXT", + "cardinality": cardinality, + } + ], + nonce=f"cardinality-{cardinality}", + ) + + assert result["ok"] is True + assert result["data"]["valid"] is True + assert "plan_hash" in result["data"] + + +def test_dry_run_accepts_valid_edge_frequencies(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(vertexlabels=[_live_vertex("person")]), + ) + + for frequency in manage_schema_module.EDGE_LABEL_FREQUENCIES: + result = manage_schema( + mode="dry_run", + operations=[ + { + "type": "create_edge_label", + "name": f"knows_{frequency.lower()}", + "source_label": "person", + "target_label": "person", + "frequency": frequency, + } + ], + nonce=f"frequency-{frequency}", + ) + + assert result["ok"] is True + assert result["data"]["valid"] is True + assert "plan_hash" in result["data"] + + +def test_enum_tables_stay_in_sync_with_apply_methods(): + assert manage_schema_module.PROPERTY_KEY_DATA_TYPES == frozenset( + manage_schema_module.PROPERTY_KEY_DATA_TYPE_METHODS + ) + assert manage_schema_module.PROPERTY_KEY_CARDINALITIES == frozenset( + manage_schema_module.PROPERTY_KEY_CARDINALITY_METHODS + ) + assert manage_schema_module.PROPERTY_KEY_AGGREGATE_TYPES == ( + frozenset(manage_schema_module.PROPERTY_KEY_AGGREGATE_METHODS) + | manage_schema_module.PROPERTY_KEY_DIRECT_AGGREGATE_TYPES + ) + assert manage_schema_module.VERTEX_LABEL_ID_STRATEGIES == frozenset( + manage_schema_module.VERTEX_LABEL_ID_STRATEGY_METHODS + ) + assert manage_schema_module.EDGE_LABEL_FREQUENCIES == frozenset( + manage_schema_module.EDGE_LABEL_FREQUENCY_METHODS + ) + + +def test_invalid_enum_input_never_reaches_apply_stage_error(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="dry_run", + operations=[ + _property_key("name"), + _vertex_label( + "person", + properties=["name"], + primary_keys=["name"], + id_strategy="FOO", + ), + ], + ) + + _assert_dry_run_invalid(result) + assert result["data"]["errors"][0]["operation_index"] == 1 + assert "unsupported id_strategy" in result["data"]["errors"][0]["reason"] + + +def test_manage_schema_dry_run_same_ops_same_hash(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + first = manage_schema(mode="dry_run", operations=[_property_key()], nonce="same") + second = manage_schema(mode="dry_run", operations=[_property_key()], nonce="same") + + assert first["data"]["plan_hash"] == second["data"]["plan_hash"] + + +def test_manage_schema_dry_run_without_nonce_gets_fresh_hash(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + first = manage_schema(mode="dry_run", operations=[_property_key()]) + second = manage_schema(mode="dry_run", operations=[_property_key()]) + + assert first["data"]["plan_hash"] != second["data"]["plan_hash"] + + +def test_manage_schema_dry_run_different_ops_different_hash(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + first = manage_schema(mode="dry_run", operations=[_property_key("age")]) + second = manage_schema(mode="dry_run", operations=[_property_key("score")]) + + assert first["data"]["plan_hash"] != second["data"]["plan_hash"] + + +def test_manage_schema_plan_hash_schema_field_order_same_hash(): + operations = [_property_key()] + schema = _schema( + propertykeys=[ + {"name": "name", "data_type": "TEXT"}, + {"name": "age", "data_type": "INT"}, + ], + vertexlabels=[ + { + "name": "person", + "properties": [{"name": "name"}, {"name": "age"}], + "primary_keys": ["name"], + }, + ], + edgelabels=[ + {"name": "knows", "source_label": "person", "target_label": "person"}, + ], + ) + reordered_schema = _schema( + propertykeys=[ + {"name": "age", "data_type": "INT"}, + {"name": "name", "data_type": "TEXT"}, + ], + vertexlabels=[ + { + "name": "person", + "properties": [{"name": "age"}, {"name": "name"}], + "primaryKeys": ["name"], + }, + ], + edgelabels=[ + {"name": "knows", "sourceLabel": "person", "targetLabel": "person"}, + ], + ) + + first = manage_schema_module.calculate_plan_hash(operations, schema) + second = manage_schema_module.calculate_plan_hash(operations, reordered_schema) + + assert first == second + + +def test_manage_schema_plan_hash_schema_primary_key_change_different_hash(): + operations = [_property_key()] + schema = _schema( + vertexlabels=[ + {"name": "person", "properties": ["name", "age"], "primary_keys": ["name"]}, + ], + ) + changed_schema = _schema( + vertexlabels=[ + {"name": "person", "properties": ["name", "age"], "primary_keys": ["age"]}, + ], + ) + + first = manage_schema_module.calculate_plan_hash(operations, schema) + second = manage_schema_module.calculate_plan_hash(operations, changed_schema) + + assert first != second + + +def test_manage_schema_plan_hash_schema_metadata_ignored_same_hash(): + operations = [_property_key()] + schema = _schema( + propertykeys=[{"name": "name", "data_type": "TEXT"}], + vertexlabels=[ + {"name": "person", "properties": ["name"], "primary_keys": ["name"]} + ], + ) + schema_with_metadata = _schema( + propertykeys=[ + { + "id": 1, + "name": "name", + "data_type": "TEXT", + "user_data": {"x": "y"}, + } + ], + vertexlabels=[ { "id": 99, "name": "person", @@ -638,14 +1514,357 @@ def test_manage_schema_plan_hash_operation_order_different_hash(): assert first != second -def test_manage_schema_apply_is_not_an_internal_v1_mode(): +def test_manage_schema_dry_run_rejects_index_apply_scope(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema( + propertykeys=[_live_pk("age")], + vertexlabels=[_live_vertex("person", properties=["age"])], + ), + ) + + result = manage_schema(mode="dry_run", operations=[_index_label(fields=["age"])]) + + assert result["ok"] is True + assert result["data"]["valid"] is False + assert "outside P0a scope" in result["data"]["errors"][0]["reason"] + + +def test_manage_schema_apply_happy_path(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + state = _empty_schema() + + def live_schema(): + return state + + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", live_schema + ) + monkeypatch.setattr( + manage_schema_module, "_schema_manager", lambda: RecordingPropertyManager(state) + ) + + dry_run = manage_schema( + mode="dry_run", + operations=[_property_key()], + nonce="schema-happy", + ) + context = dry_run["data"]["plan_context"] + result = manage_schema( + mode="apply", + operations=[_property_key()], + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=context["nonce"], + expires_at=context["expires_at"], + ) + + assert result["ok"] is True + assert result["data"]["status"] == "applied" + assert result["data"]["applied_operations"] == [_property_key()] + + +def test_manage_schema_apply_canonicalizes_property_key_post_read_enums(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + state = _empty_schema() + + def live_schema(): + return state + + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", live_schema + ) + monkeypatch.setattr( + manage_schema_module, "_schema_manager", lambda: RecordingPropertyManager(state) + ) + + operations = [ + _property_key("age", "INTEGER"), + _property_key("active", "BOOL"), + ] + dry_run = manage_schema( + mode="dry_run", + operations=operations, + nonce="schema-canonical-enums", + ) + context = dry_run["data"]["plan_context"] + result = manage_schema( + mode="apply", + operations=operations, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=context["nonce"], + expires_at=context["expires_at"], + ) + + assert result["ok"] is True + assert result["data"]["status"] == "applied" + assert state["schema"]["propertykeys"] == [ + {"name": "age", "data_type": "INT", "cardinality": "SINGLE"}, + {"name": "active", "data_type": "BOOLEAN", "cardinality": "SINGLE"}, + ] + + +def test_apply_property_key_options_sets_hugegraph_1_7_aggregate_types(): + for aggregate_type in ("NONE", "SET", "LIST"): + builder = RecordingPropertyBuilder(f"score_{aggregate_type.lower()}") + cardinality = aggregate_type if aggregate_type in {"SET", "LIST"} else "SINGLE" + + manage_schema_module._apply_property_key_options( + builder, + { + "type": "create_property_key", + "name": builder.name, + "data_type": "INT", + "cardinality": cardinality, + "aggregate_type": aggregate_type, + }, + ) + + assert builder.data_type == "INT" + assert builder.cardinality == cardinality + assert builder.aggregate_type == aggregate_type + + +def test_manage_schema_apply_fails_when_post_read_fields_do_not_match(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + state = _empty_schema() + + def live_schema(): + return state + + class PropertyBuilder: + def __init__(self, name): + self.name = name + self.data_type = None + self.cardinality = None + + def asInt(self): + self.data_type = "INT" + return self + + def valueSingle(self): + self.cardinality = "SINGLE" + return self + + def calcSum(self): + return self + + def create(self): + state["schema"]["propertykeys"].append( + { + "name": self.name, + "data_type": self.data_type, + "cardinality": self.cardinality, + "aggregate_type": "MAX", + } + ) + return "ok" + + class FakeManager: + def propertyKey(self, name): + return PropertyBuilder(name) + + operation = { + "type": "create_property_key", + "name": "age", + "data_type": "INT", + "aggregate_type": "SUM", + } + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", live_schema + ) + monkeypatch.setattr(manage_schema_module, "_schema_manager", lambda: FakeManager()) + + dry_run = manage_schema( + mode="dry_run", + operations=[operation], + nonce="schema-post-read", + ) + context = dry_run["data"]["plan_context"] + result = manage_schema( + mode="apply", + operations=[operation], + 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"] == "PARTIAL_APPLY" + assert result["error"]["details"]["status"] == "failed" + assert result["error"]["details"]["failed_operation"] == operation + assert "post-read schema" in result["error"]["details"]["error"] + + +def test_operation_observed_checks_vertex_and_edge_declared_fields(): + schema = _schema( + vertexlabels=[ + { + "name": "person", + "id_strategy": "PRIMARY_KEY", + "properties": ["name", "nickname"], + "primary_keys": ["name"], + "nullable_keys": ["nickname"], + } + ], + edgelabels=[ + { + "name": "knows", + "source_label": "person", + "target_label": "person", + "properties": ["since", "rank"], + "nullable_keys": ["rank"], + "sort_keys": ["since"], + "frequency": "MULTIPLE", + } + ], + ) + + vertex_operation = _vertex_label( + "person", + properties=["name", "nickname"], + primary_keys=["name"], + nullable_keys=["nickname"], + ) + edge_operation = _edge_label( + "knows", + source_label="person", + target_label="person", + properties=["since", "rank"], + nullable_keys=["rank"], + sort_keys=["since"], + frequency="MULTIPLE", + ) + mismatched_edge = dict(edge_operation, sort_keys=["rank"]) + + assert manage_schema_module._operation_observed(vertex_operation, schema) + assert manage_schema_module._operation_observed(edge_operation, schema) + assert not manage_schema_module._operation_observed(mismatched_edge, schema) + + +def test_manage_schema_apply_partial_failure(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + state = _empty_schema() + + def live_schema(): + return state + + class PropertyBuilder: + def __init__(self, name): + self.name = name + self.data_type = None + self.cardinality = None + + def asInt(self): + self.data_type = "INT" + return self + + def valueSingle(self): + self.cardinality = "SINGLE" + return self + + def create(self): + state["schema"]["propertykeys"].append( + { + "name": self.name, + "data_type": self.data_type, + "cardinality": self.cardinality, + } + ) + return "ok" + + class VertexBuilder: + def __init__(self, name): + self.name = name + + def usePrimaryKeyId(self): + return self + + def properties(self, *args): + return self + + def primaryKeys(self, *args): + return self + + def create(self): + raise RuntimeError("builder failed") + + class FakeManager: + def propertyKey(self, name): + return PropertyBuilder(name) + + def vertexLabel(self, name): + return VertexBuilder(name) + + operations = [ + _property_key(), + _vertex_label("person", properties=["age"], primary_keys=["age"]), + ] + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", live_schema + ) + monkeypatch.setattr(manage_schema_module, "_schema_manager", lambda: FakeManager()) + + dry_run = manage_schema(mode="dry_run", operations=operations, nonce="partial") + context = dry_run["data"]["plan_context"] + result = manage_schema( + mode="apply", + operations=operations, + 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"] == "PARTIAL_APPLY" + assert result["error"]["details"]["status"] == "partial" + assert result["error"]["details"]["applied_operations"] == [_property_key()] + assert "inspect_schema_tool" in result["next_actions"][0] + + +def test_manage_schema_apply_hash_mismatch(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + result = manage_schema( + mode="apply", + operations=[_property_key()], + confirm=True, + plan_hash="bad", + nonce="schema-hash", + expires_at=9999999999, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "PLAN_HASH_MISMATCH" + + +def test_manage_schema_apply_readonly_blocks_after_valid_plan(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + + dry_run = manage_schema( + mode="dry_run", + operations=[_property_key()], + nonce="readonly", + ) + context = dry_run["data"]["plan_context"] result = manage_schema( mode="apply", operations=[_property_key()], confirm=True, - plan_hash="0000000000000000", + plan_hash=dry_run["data"]["plan_hash"], + nonce=context["nonce"], + expires_at=context["expires_at"], ) assert result["ok"] is False - assert result["error"]["type"] == "SCHEMA_MISMATCH" - assert "Unsupported manage_schema mode: apply" in result["error"]["message"] + assert result["error"]["type"] == "READONLY_VIOLATION" diff --git a/hugegraph-mcp/tests/test_mutate_graph_properties_tool.py b/hugegraph-mcp/tests/test_mutate_graph_properties_tool.py new file mode 100644 index 000000000..a5d0b3114 --- /dev/null +++ b/hugegraph-mcp/tests/test_mutate_graph_properties_tool.py @@ -0,0 +1,443 @@ +# 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. + +from hugegraph_mcp.tools import mutate_graph_properties as mutate_module + + +def _schema(): + return { + "schema": { + "propertykeys": [ + {"name": "name", "data_type": "TEXT"}, + {"name": "age", "data_type": "INT"}, + ], + "vertexlabels": [ + { + "name": "person", + "properties": ["name", "age"], + "primary_keys": ["name"], + } + ], + "edgelabels": [ + { + "name": "knows", + "source_label": "person", + "target_label": "person", + "properties": ["age"], + } + ], + "indexlabels": [], + }, + "readonly": False, + } + + +class FakeGraphManager: + def __init__(self, vertex=None, changed_vertex=None): + self.vertex = vertex or { + "id": "1:alice", + "label": "person", + "type": "vertex", + "properties": {"name": "Alice"}, + } + self.changed_vertex = changed_vertex + self.read_count = 0 + self.append_calls = [] + self.eliminate_calls = [] + + def getVertexById(self, vertex_id): + self.read_count += 1 + if self.changed_vertex is not None and self.read_count >= 2: + return self.changed_vertex + return self.vertex + + def getEdgeById(self, edge_id): + return { + "id": edge_id, + "label": "knows", + "type": "edge", + "properties": {"age": 1}, + } + + def appendVertex(self, vertex_id, properties): + self.append_calls.append((vertex_id, properties)) + self.vertex = { + **self.vertex, + "properties": {**self.vertex["properties"], **properties}, + } + return self.vertex + + def eliminateVertex(self, vertex_id, properties): + self.eliminate_calls.append((vertex_id, properties)) + self.vertex = { + **self.vertex, + "properties": { + key: value + for key, value in self.vertex["properties"].items() + if key not in properties + }, + } + return self.vertex + + +class MissingTargetManager: + def getVertexById(self, vertex_id): + raise RuntimeError("404 Not Found: vertex does not exist") + + def getEdgeById(self, edge_id): + raise RuntimeError("404 Not Found: edge does not exist") + + +class PostReadFailureManager(FakeGraphManager): + def __init__(self): + super().__init__() + self.post_read_error = RuntimeError( + "post read failed: Authorization: Bearer abc123 " + "token=xyz http://user:pass@example.com" + ) + + def getVertexById(self, vertex_id): + if self.append_calls: + raise self.post_read_error + return super().getVertexById(vertex_id) + + +class PostReadMissingManager(FakeGraphManager): + def getVertexById(self, vertex_id): + if self.append_calls: + return None + return super().getVertexById(vertex_id) + + +class PostReadMismatchManager(FakeGraphManager): + def getVertexById(self, vertex_id): + if self.append_calls: + return { + **self.vertex, + "properties": {"name": "Alice", "age": 31}, + } + return super().getVertexById(vertex_id) + + +class ExecutionFailureManager(FakeGraphManager): + def appendVertex(self, vertex_id, properties): + self.append_calls.append((vertex_id, properties)) + raise RuntimeError("404 Not Found: vertex does not exist") + + +def _patch_schema(monkeypatch): + monkeypatch.setattr(mutate_module, "current_live_schema", lambda: _schema()) + + +def test_mutate_dry_run_returns_snapshot_bound_plan(monkeypatch): + _patch_schema(monkeypatch) + manager = FakeGraphManager() + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + ) + + assert result["ok"] is True + assert result["data"]["status"] == "planned" + assert result["data"]["before"]["properties"] == {"name": "Alice"} + assert result["data"]["after"]["properties"] == {"name": "Alice", "age": 30} + assert result["data"]["plan_hash"] + assert "|ts:" in result["data"]["plan_context"]["nonce"] + + +def test_mutate_rejects_unknown_property(monkeypatch): + _patch_schema(monkeypatch) + manager = FakeGraphManager() + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"missing": "x"}, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "SCHEMA_MISMATCH" + assert result["error"]["details"]["unknown_properties"] == ["missing"] + + +def test_mutate_missing_vertex_returns_not_found(monkeypatch): + _patch_schema(monkeypatch) + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: MissingTargetManager()) + + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:missing", + properties={"age": 30}, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "NOT_FOUND" + assert result["error"]["retryable"] is False + assert result["error"]["details"]["reason"] == "not_found" + + +def test_mutate_missing_edge_returns_not_found(monkeypatch): + _patch_schema(monkeypatch) + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: MissingTargetManager()) + + result = mutate_module.mutate_graph_properties( + target="edge", + operation="append", + id="S1:alice>11>knows>S2:bob", + properties={"age": 1}, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "NOT_FOUND" + assert result["error"]["retryable"] is False + assert result["error"]["details"]["reason"] == "not_found" + + +def test_mutate_confirm_applies_after_valid_plan(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + _patch_schema(monkeypatch) + manager = FakeGraphManager() + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + dry_run = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + ) + context = dry_run["data"]["plan_context"] + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=context["nonce"], + expires_at=context["expires_at"], + ) + + assert result["ok"] is True + assert result["data"]["status"] == "applied" + assert manager.append_calls == [("1:alice", {"age": 30})] + + +def test_mutate_confirm_maps_execution_errors_with_hugegraph_classifier(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + _patch_schema(monkeypatch) + manager = ExecutionFailureManager() + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + dry_run = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + ) + context = dry_run["data"]["plan_context"] + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + 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"] == "NOT_FOUND" + assert result["error"]["retryable"] is False + assert result["error"]["details"]["stage"] == "mutation_execute" + assert result["error"]["details"]["reason"] == "not_found" + + +def test_mutate_confirm_sanitizes_post_read_error(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + _patch_schema(monkeypatch) + manager = PostReadFailureManager() + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + dry_run = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + ) + context = dry_run["data"]["plan_context"] + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + 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"] == "PARTIAL_APPLY" + assert result["error"]["details"]["stage"] == "post_write_verification" + assert result["error"]["details"]["status"] == "unknown" + response_text = str(result) + assert "abc123" not in response_text + assert "token=xyz" not in response_text + assert "user:pass" not in response_text + assert result["warnings"] == [ + "Mutation returned from HugeGraph, but post-read verification failed." + ] + assert result["next_actions"] == [ + "Call query_graph_data_tool to verify the target state." + ] + + +def test_mutate_confirm_returns_error_when_post_read_is_missing(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + _patch_schema(monkeypatch) + manager = PostReadMissingManager() + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + dry_run = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + ) + context = dry_run["data"]["plan_context"] + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + 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"] == "PARTIAL_APPLY" + assert result["error"]["details"]["stage"] == "post_write_verification" + assert result["error"]["details"]["post_read"] is None + assert result["warnings"] == [ + "Mutation returned from HugeGraph, but the target was not found on post-read." + ] + assert result["next_actions"] == [ + "Call query_graph_data_tool to verify whether the target still exists." + ] + + +def test_mutate_confirm_returns_error_when_post_read_mismatches_plan(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + _patch_schema(monkeypatch) + manager = PostReadMismatchManager() + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + dry_run = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + ) + context = dry_run["data"]["plan_context"] + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + 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"] == "PARTIAL_APPLY" + assert result["error"]["details"]["stage"] == "post_write_verification" + assert result["error"]["details"]["planned_after"]["properties"]["age"] == 30 + assert result["error"]["details"]["post_read"]["properties"]["age"] == 31 + assert result["warnings"] == ["Post-read state did not match the planned preview."] + + +def test_mutate_confirm_detects_target_changed(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + _patch_schema(monkeypatch) + manager = FakeGraphManager( + changed_vertex={ + "id": "1:alice", + "label": "person", + "type": "vertex", + "properties": {"name": "Alice", "age": 99}, + } + ) + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + dry_run = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + ) + context = dry_run["data"]["plan_context"] + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + 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"] == "TARGET_CHANGED" + assert manager.append_calls == [] + + +def test_mutate_confirm_requires_non_readonly(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + _patch_schema(monkeypatch) + manager = FakeGraphManager() + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + dry_run=False, + confirm=True, + plan_hash="bad", + nonce="nonce", + expires_at=9999999999, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "READONLY_VIOLATION" + assert manager.append_calls == [] diff --git a/hugegraph-mcp/tests/test_plan_hash.py b/hugegraph-mcp/tests/test_plan_hash.py index 26a497141..025dfad6f 100644 --- a/hugegraph-mcp/tests/test_plan_hash.py +++ b/hugegraph-mcp/tests/test_plan_hash.py @@ -220,6 +220,8 @@ def test_verify_plan_hash_rejects_mismatched_hash(monkeypatch): assert valid is False assert error_type == ErrorType.PLAN_HASH_MISMATCH + assert "expected_hash" not in details + assert details["provided_hash"] == "wrong_hash" def test_verify_plan_hash_rejects_mismatched_tool_name(monkeypatch): diff --git a/hugegraph-mcp/tests/test_query_graph_data_tool.py b/hugegraph-mcp/tests/test_query_graph_data_tool.py new file mode 100644 index 000000000..4b36e674f --- /dev/null +++ b/hugegraph-mcp/tests/test_query_graph_data_tool.py @@ -0,0 +1,352 @@ +# 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. + +import pytest + +from hugegraph_mcp.tools import query_graph_data as query_module + + +class Vertex: + id = "1:alice" + label = "person" + type = "vertex" + properties = {"name": "Alice"} + + +class Edge: + id = "edge-1" + label = "knows" + type = "edge" + outV = "1:alice" + outVLabel = "person" + inV = "1:bob" + inVLabel = "person" + properties = {"since": 2024} + + +class FakeGraphManager: + def __init__(self): + self.calls = [] + + def getVertexById(self, vertex_id): + self.calls.append(("getVertexById", vertex_id)) + return Vertex() + + def getVerticesById(self, vertex_ids): + self.calls.append(("getVerticesById", vertex_ids)) + return [Vertex()] + + def getVertexByPage(self, label, limit, page=None, properties=None): + self.calls.append(("getVertexByPage", label, limit, page, properties)) + return [Vertex()], "next-page" + + def getVertexByCondition(self, label="", limit=0, page=None, properties=None): + self.calls.append(("getVertexByCondition", label, limit, page, properties)) + return [Vertex()] + + def getVertexByConditionWithPage( + self, label="", limit=0, page=None, properties=None + ): + self.calls.append( + ("getVertexByConditionWithPage", label, limit, page, properties) + ) + return [Vertex()], "condition-next" + + def getEdgeById(self, edge_id): + self.calls.append(("getEdgeById", edge_id)) + return Edge() + + def getEdgesById(self, edge_ids): + self.calls.append(("getEdgesById", edge_ids)) + return [Edge()] + + def getEdgeByPage( + self, + label=None, + vertex_id=None, + direction=None, + limit=0, + page=None, + properties=None, + ): + self.calls.append( + ("getEdgeByPage", label, vertex_id, direction, limit, page, properties) + ) + return [Edge()], "edge-next" + + +def test_query_vertex_by_id(monkeypatch): + manager = FakeGraphManager() + monkeypatch.setattr(query_module, "_graph_manager", lambda: manager) + + result = query_module.query_graph_data( + target="vertex", + operation="get_by_id", + id="1:alice", + ) + + assert result["ok"] is True + assert result["data"]["count"] == 1 + assert result["data"]["items"][0]["id"] == "1:alice" + assert manager.calls == [("getVertexById", "1:alice")] + + +def test_query_get_by_ids_deduplicates(monkeypatch): + manager = FakeGraphManager() + monkeypatch.setattr(query_module, "_graph_manager", lambda: manager) + + result = query_module.query_graph_data( + target="edge", + operation="get_by_ids", + ids=["edge-1", "edge-1"], + ) + + assert result["ok"] is True + assert result["warnings"] == ["Ignored 1 duplicate id value(s)."] + assert manager.calls == [("getEdgesById", ["edge-1"])] + + +def test_get_by_ids_with_mixed_key_type_dict_returns_validation_error(): + result = query_module.query_graph_data( + target="vertex", + operation="get_by_ids", + ids=[{1: "a", "1": "b"}], + ) + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert result["error"]["retryable"] is False + assert result["error"]["details"]["field"] == "ids[0]" + + +def test_get_by_ids_dedupe_still_works_for_normal_inputs(monkeypatch): + manager = FakeGraphManager() + monkeypatch.setattr(query_module, "_graph_manager", lambda: manager) + + result = query_module.query_graph_data( + target="vertex", + operation="get_by_ids", + ids=["1", "1", 1, 1, "2", "2"], + ) + + assert result["ok"] is True + assert result["warnings"] == ["Ignored 3 duplicate id value(s)."] + assert manager.calls == [("getVerticesById", ["1", 1, "2"])] + + +def test_query_get_by_ids_keeps_type_distinct_vertex_ids(monkeypatch): + manager = FakeGraphManager() + monkeypatch.setattr(query_module, "_graph_manager", lambda: manager) + + result = query_module.query_graph_data( + target="vertex", + operation="get_by_ids", + ids=[1, "1", 1], + ) + + assert result["ok"] is True + assert result["warnings"] == ["Ignored 1 duplicate id value(s)."] + assert manager.calls == [("getVerticesById", [1, "1"])] + + +def test_query_vertex_page_requires_label(): + result = query_module.query_graph_data(target="vertex", operation="page") + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert "label is required" in result["error"]["message"] + + +@pytest.mark.parametrize( + "vertex_id", + [True, ["1"], {"id": "1"}, 1.5, 2**63, -(2**63) - 1], +) +def test_query_vertex_by_id_rejects_invalid_vertex_id_types(vertex_id): + result = query_module.query_graph_data( + target="vertex", + operation="get_by_id", + id=vertex_id, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert result["error"]["retryable"] is False + assert result["error"]["details"]["field"] == "id" + + +@pytest.mark.parametrize( + "vertex_id", + [False, ["1"], {"id": "1"}, 1.5, 2**63, -(2**63) - 1], +) +def test_query_vertex_get_by_ids_rejects_each_invalid_vertex_id(vertex_id): + result = query_module.query_graph_data( + target="vertex", + operation="get_by_ids", + ids=["1", vertex_id], + ) + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert result["error"]["retryable"] is False + assert result["error"]["details"]["field"] == "ids[1]" + + +def test_query_edge_id_is_not_validated_as_vertex_id(monkeypatch): + manager = FakeGraphManager() + monkeypatch.setattr(query_module, "_graph_manager", lambda: manager) + edge_id = "S1:alice>11>knows>S2:bob" + + result = query_module.query_graph_data( + target="edge", + operation="get_by_id", + id=edge_id, + ) + + assert result["ok"] is True + assert manager.calls == [("getEdgeById", edge_id)] + + +def test_query_rejects_limit_over_500(): + result = query_module.query_graph_data( + target="vertex", + operation="condition", + properties={"name": "Alice"}, + limit=501, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert "limit exceeds" in result["error"]["message"] + + +def test_query_condition_requires_properties(): + result = query_module.query_graph_data(target="vertex", operation="condition") + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert "properties is required" in result["error"]["message"] + + +def test_query_edge_page_requires_direction_with_vertex_id(): + result = query_module.query_graph_data( + target="edge", + operation="page", + vertex_id="1:alice", + ) + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert "direction is required" in result["error"]["message"] + + +def test_query_edge_page(monkeypatch): + manager = FakeGraphManager() + monkeypatch.setattr(query_module, "_graph_manager", lambda: manager) + + result = query_module.query_graph_data( + target="edge", + operation="page", + label="knows", + vertex_id="1:alice", + direction="out", + limit=5, + page="p1", + ) + + assert result["ok"] is True + assert result["data"]["next_page"] == "edge-next" + assert manager.calls == [ + ("getEdgeByPage", "knows", "1:alice", "OUT", 5, "p1", None) + ] + + +def test_query_vertex_condition_returns_next_page(monkeypatch): + manager = FakeGraphManager() + monkeypatch.setattr(query_module, "_graph_manager", lambda: manager) + + result = query_module.query_graph_data( + target="vertex", + operation="condition", + label="person", + properties={"name": "Alice"}, + limit=5, + page="p1", + ) + + assert result["ok"] is True + assert result["data"]["next_page"] == "condition-next" + assert manager.calls == [ + ("getVertexByConditionWithPage", "person", 5, "p1", {"name": "Alice"}) + ] + + +def test_query_vertex_condition_supports_legacy_client_without_page(monkeypatch): + class LegacyManager: + def getVertexByCondition(self, label="", limit=0, page=None, properties=None): + return [Vertex()] + + monkeypatch.setattr(query_module, "_graph_manager", lambda: LegacyManager()) + + result = query_module.query_graph_data( + target="vertex", + operation="condition", + label="person", + properties={"name": "Alice"}, + limit=5, + page="p1", + ) + + assert result["ok"] is True + assert result["data"]["count"] == 1 + assert result["data"]["next_page"] is None + + +def test_query_no_index_returns_no_index(monkeypatch): + class BrokenManager: + def getVertexByCondition(self, **kwargs): + raise RuntimeError("NoIndexException: no index") + + monkeypatch.setattr(query_module, "_graph_manager", lambda: BrokenManager()) + + result = query_module.query_graph_data( + target="vertex", + operation="condition", + label="person", + properties={"name": "Alice"}, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "NO_INDEX" + assert any("P0b" in action for action in result["next_actions"]) + + +def test_query_not_indexed_message_returns_no_index(monkeypatch): + class BrokenManager: + def getVertexByCondition(self, **kwargs): + raise RuntimeError( + "The property key 'name' is not indexed and may not match secondary condition" + ) + + monkeypatch.setattr(query_module, "_graph_manager", lambda: BrokenManager()) + + result = query_module.query_graph_data( + target="vertex", + operation="condition", + label="person", + properties={"name": "Alice"}, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "NO_INDEX" + assert result["error"]["retryable"] is False + assert result["error"]["details"]["reason"] == "no_index" diff --git a/hugegraph-mcp/tests/test_readonly_mode.py b/hugegraph-mcp/tests/test_readonly_mode.py index 4a18b09b1..ac705156c 100644 --- a/hugegraph-mcp/tests/test_readonly_mode.py +++ b/hugegraph-mcp/tests/test_readonly_mode.py @@ -63,61 +63,76 @@ def test_readonly_env_parsing(): def test_write_tools_available_when_not_readonly(monkeypatch): """Test that all V1 tools are registered when not in readonly mode.""" monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + monkeypatch.setenv("HUGEGRAPH_MCP_TOOLSET", "v1") import hugegraph_mcp.server - importlib.reload(hugegraph_mcp.server) - tools = get_registered_tools() - # V1 stable tools - assert "inspect_graph_tool" in tools - assert "generate_gremlin_tool" in tools - assert "execute_gremlin_read_tool" in tools - assert "extract_graph_data_tool" in tools - assert "design_schema_tool" in tools - assert "apply_schema_tool" in tools - assert "import_graph_data_tool" in tools - assert "delete_graph_data_tool" in tools - assert "query_graph_tool" not in tools - assert "manage_schema_tool" not in tools - assert "manage_graph_data_tool" not in tools - # Admin-gated debug tools - assert "execute_gremlin_write_tool" in tools - assert "refresh_vid_embeddings_tool" in tools - assert len(tools) == 10 + try: + importlib.reload(hugegraph_mcp.server) + tools = get_registered_tools() + # V1 stable tools + assert "inspect_graph_tool" in tools + assert "generate_gremlin_tool" in tools + assert "execute_gremlin_read_tool" in tools + assert "extract_graph_data_tool" in tools + assert "design_schema_tool" in tools + assert "apply_schema_tool" in tools + assert "import_graph_data_tool" in tools + assert "delete_graph_data_tool" in tools + assert "query_graph_tool" not in tools + assert "manage_schema_tool" not in tools + assert "manage_graph_data_tool" not in tools + # Admin-gated debug tools + assert "execute_gremlin_write_tool" in tools + assert "refresh_vid_embeddings_tool" in tools + assert len(tools) == 10 + finally: + monkeypatch.delenv("HUGEGRAPH_MCP_TOOLSET", raising=False) + importlib.reload(hugegraph_mcp.server) def test_write_tools_disabled_when_readonly(monkeypatch): """Test that all V1 tools remain registered in readonly mode.""" monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + monkeypatch.setenv("HUGEGRAPH_MCP_TOOLSET", "v1") import hugegraph_mcp.server - importlib.reload(hugegraph_mcp.server) - tools = get_registered_tools() - # All tools are registered; readonly blocks execution, not registration - assert "inspect_graph_tool" in tools - assert "generate_gremlin_tool" in tools - assert "execute_gremlin_read_tool" in tools - assert "extract_graph_data_tool" in tools - assert "design_schema_tool" in tools - assert "apply_schema_tool" in tools - assert "import_graph_data_tool" in tools - assert "delete_graph_data_tool" in tools - assert "query_graph_tool" not in tools - assert "manage_schema_tool" not in tools - assert "manage_graph_data_tool" not in tools - assert "execute_gremlin_write_tool" in tools - assert "refresh_vid_embeddings_tool" in tools - assert len(tools) == 10 + try: + importlib.reload(hugegraph_mcp.server) + tools = get_registered_tools() + # All tools are registered; readonly blocks execution, not registration + assert "inspect_graph_tool" in tools + assert "generate_gremlin_tool" in tools + assert "execute_gremlin_read_tool" in tools + assert "extract_graph_data_tool" in tools + assert "design_schema_tool" in tools + assert "apply_schema_tool" in tools + assert "import_graph_data_tool" in tools + assert "delete_graph_data_tool" in tools + assert "query_graph_tool" not in tools + assert "manage_schema_tool" not in tools + assert "manage_graph_data_tool" not in tools + assert "execute_gremlin_write_tool" in tools + assert "refresh_vid_embeddings_tool" in tools + assert len(tools) == 10 + finally: + monkeypatch.delenv("HUGEGRAPH_MCP_TOOLSET", raising=False) + importlib.reload(hugegraph_mcp.server) def test_readonly_mode_default(monkeypatch): """Test that default mode is readonly when env is not set (V1 safe default).""" monkeypatch.delenv("HUGEGRAPH_MCP_READONLY", raising=False) + monkeypatch.setenv("HUGEGRAPH_MCP_TOOLSET", "v1") import hugegraph_mcp.server - importlib.reload(hugegraph_mcp.server) - assert hugegraph_mcp.server.READONLY is True - tools = get_registered_tools() - assert len(tools) == 10 + try: + importlib.reload(hugegraph_mcp.server) + assert hugegraph_mcp.server.READONLY is True + tools = get_registered_tools() + assert len(tools) == 10 + finally: + monkeypatch.delenv("HUGEGRAPH_MCP_TOOLSET", raising=False) + importlib.reload(hugegraph_mcp.server) diff --git a/hugegraph-mcp/tests/test_v1_stable_tools.py b/hugegraph-mcp/tests/test_v1_stable_tools.py index f74c7a0b0..fd98e0957 100644 --- a/hugegraph-mcp/tests/test_v1_stable_tools.py +++ b/hugegraph-mcp/tests/test_v1_stable_tools.py @@ -39,23 +39,56 @@ def _assert_v1_envelope_shape(result): assert "duration_ms" in result["meta"] -def test_public_tool_contract_lists_only_v1_tools(): +V1_TOOL_NAMES = { + "inspect_graph_tool", + "generate_gremlin_tool", + "execute_gremlin_read_tool", + "extract_graph_data_tool", + "design_schema_tool", + "apply_schema_tool", + "import_graph_data_tool", + "delete_graph_data_tool", + "refresh_vid_embeddings_tool", + "execute_gremlin_write_tool", +} + +V2_CORE_TOOL_NAMES = V1_TOOL_NAMES | { + "inspect_schema_tool", + "query_graph_data_tool", + "mutate_graph_properties_tool", +} + + +def test_public_tool_contract_lists_v2_core_tools_by_default(monkeypatch): + monkeypatch.delenv("HUGEGRAPH_MCP_TOOLSET", raising=False) + async def _tool_names(): tools = await _list_mcp_tools() return {tool.name for tool in tools} - assert asyncio.run(_tool_names()) == { - "inspect_graph_tool", - "generate_gremlin_tool", - "execute_gremlin_read_tool", - "extract_graph_data_tool", - "design_schema_tool", - "apply_schema_tool", - "import_graph_data_tool", - "delete_graph_data_tool", - "refresh_vid_embeddings_tool", - "execute_gremlin_write_tool", - } + assert asyncio.run(_tool_names()) == V2_CORE_TOOL_NAMES + + +def test_public_tool_contract_can_reload_as_v1(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_TOOLSET", "v1") + import importlib + + import hugegraph_mcp.server as server_module + + importlib.reload(server_module) + + async def _tool_names(): + list_tools = getattr(server_module.mcp, "_mcp_list_tools", None) + if list_tools is None: + list_tools = getattr(server_module.mcp, "_list_tools") + tools = await list_tools() + return {tool.name for tool in tools} + + try: + assert asyncio.run(_tool_names()) == V1_TOOL_NAMES + finally: + monkeypatch.delenv("HUGEGRAPH_MCP_TOOLSET", raising=False) + importlib.reload(server_module) def test_public_tool_argument_models_do_not_emit_schema_shadow_warning(): @@ -93,9 +126,24 @@ def test_generate_gremlin_tool_routes_to_generate_gremlin(monkeypatch): query="count vertices", execute=True, output_types=["vertex"], + limit_policy="warn", ) +def test_inspect_graph_tool_adds_contract_fields(monkeypatch): + expected = envelope_ok({"graph": "hugegraph"}) + mock = Mock(return_value=expected) + monkeypatch.setattr(server, "inspect_graph", mock) + + result = server.inspect_graph_tool() + + _assert_v1_envelope_shape(result) + assert result["data"]["mcp_tool_contract_version"] == "2.0" + assert result["data"]["toolset"] == "v2_core" + assert result["meta"]["mcp_tool_contract_version"] == "2.0" + assert result["meta"]["toolset"] == "v2_core" + + def test_execute_gremlin_read_tool_routes_to_execute_gremlin_read(monkeypatch): expected = envelope_ok({"data": [1, 2, 3]}) mock = Mock(return_value=expected) @@ -106,7 +154,7 @@ def test_execute_gremlin_read_tool_routes_to_execute_gremlin_read(monkeypatch): _assert_v1_envelope_shape(result) assert result["ok"] is True assert result["data"] == expected["data"] - mock.assert_called_once_with("g.V().limit(3)") + mock.assert_called_once_with("g.V().limit(3)", limit_policy="warn") def test_extract_graph_data_tool_routes_to_extract_graph_data(monkeypatch): @@ -160,6 +208,8 @@ def test_apply_schema_tool_validate_routes_to_manage_schema(monkeypatch): operations=[{"op": "add_vertex_label"}], confirm=False, plan_hash=None, + nonce=None, + expires_at=None, ) @@ -180,17 +230,53 @@ def test_apply_schema_tool_dry_run_routes_to_manage_schema(monkeypatch): operations=[{"op": "add_vertex_label"}], confirm=False, plan_hash=None, + nonce=None, + expires_at=None, + ) + + +def test_apply_schema_tool_apply_routes_in_v2_core(monkeypatch): + expected = envelope_ok({"status": "planned"}) + mock = Mock(return_value=expected) + monkeypatch.setattr(server, "manage_schema", mock) + + result = server.apply_schema_tool( + mode="apply", + operations=[{"type": "create_property_key", "name": "age"}], + confirm=True, + plan_hash="hash", + nonce="nonce", + expires_at=9999999999, + ) + + _assert_v1_envelope_shape(result) + assert result["ok"] is True + mock.assert_called_once_with( + mode="apply", + operations=[{"type": "create_property_key", "name": "age"}], + confirm=True, + plan_hash="hash", + nonce="nonce", + expires_at=9999999999, ) -def test_apply_schema_tool_apply_returns_feature_disabled(): - result = server.apply_schema_tool(mode="apply", operations=[{"op": "test"}]) +def test_apply_schema_tool_apply_returns_feature_disabled_in_v1(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_TOOLSET", "v1") + import importlib + + import hugegraph_mcp.server as server_module + + importlib.reload(server_module) + result = server_module.apply_schema_tool(mode="apply", operations=[{"op": "test"}]) _assert_v1_envelope_shape(result) assert result["ok"] is False assert result["error"]["type"] == "FEATURE_DISABLED" assert result["error"]["source"] == "apply_schema_tool" assert "apply" in result["error"]["message"].lower() + monkeypatch.delenv("HUGEGRAPH_MCP_TOOLSET", raising=False) + importlib.reload(server_module) def test_delete_graph_data_tool_routes_to_manage_graph_data_delete(monkeypatch): @@ -265,24 +351,57 @@ def test_execute_gremlin_read_tool_aligns_error_source(monkeypatch): def test_admin_gate_blocks_write_tool_by_default(monkeypatch): + monkeypatch.delenv("HUGEGRAPH_MCP_TOOLSET", raising=False) monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "false") result = server.execute_gremlin_write_tool(gremlin_query="g.addV('test')") assert result["ok"] is False assert result["error"]["type"] == "FEATURE_DISABLED" - assert "ADMIN_MODE" in result["error"]["message"] - assert "HUGEGRAPH_MCP_READONLY=false" in result["error"]["suggestion"] + assert ( + result["error"]["message"] + == "execute_gremlin_write_tool is an admin/debug tool and is disabled by default." + ) + assert ( + result["error"]["suggestion"] + == "Set HUGEGRAPH_MCP_ADMIN_MODE=true and HUGEGRAPH_MCP_READONLY=false " + "to enable execute_gremlin_write_tool." + ) + assert result["error"]["details"]["toolset"] == "v2_core" + assert result["error"]["details"]["required_env"] == { + "HUGEGRAPH_MCP_ADMIN_MODE": "true", + "HUGEGRAPH_MCP_READONLY": "false", + } + assert "V1" not in result["error"]["message"] + assert "V1" not in result["error"]["suggestion"] + assert "V1" not in str(result["error"]["details"]) def test_admin_gate_blocks_refresh_embeddings_by_default(monkeypatch): + monkeypatch.delenv("HUGEGRAPH_MCP_TOOLSET", raising=False) monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "false") result = server.refresh_vid_embeddings_tool(confirm=True) assert result["ok"] is False assert result["error"]["type"] == "FEATURE_DISABLED" - assert "ADMIN_MODE" in result["error"]["message"] + assert ( + result["error"]["message"] + == "refresh_vid_embeddings_tool is an admin/debug tool and is disabled by default." + ) + assert ( + result["error"]["suggestion"] + == "Set HUGEGRAPH_MCP_ADMIN_MODE=true and HUGEGRAPH_MCP_READONLY=false " + "to enable refresh_vid_embeddings_tool." + ) + assert result["error"]["details"]["toolset"] == "v2_core" + assert result["error"]["details"]["required_env"] == { + "HUGEGRAPH_MCP_ADMIN_MODE": "true", + "HUGEGRAPH_MCP_READONLY": "false", + } + assert "V1" not in result["error"]["message"] + assert "V1" not in result["error"]["suggestion"] + assert "V1" not in str(result["error"]["details"]) def test_admin_gate_allows_write_tool_when_enabled(monkeypatch): diff --git a/hugegraph-python-client/src/pyhugegraph/api/graph.py b/hugegraph-python-client/src/pyhugegraph/api/graph.py index b30cc8a32..887b520c9 100644 --- a/hugegraph-python-client/src/pyhugegraph/api/graph.py +++ b/hugegraph-python-client/src/pyhugegraph/api/graph.py @@ -16,14 +16,28 @@ # under the License. import json -from urllib.parse import urlencode +from urllib.parse import quote_plus, urlencode from pyhugegraph.api.common import HugeParamsBase from pyhugegraph.structure.edge_data import EdgeData from pyhugegraph.structure.vertex_data import VertexData from pyhugegraph.utils import huge_router as router from pyhugegraph.utils.exceptions import NotFoundError -from pyhugegraph.utils.id_format import format_vertex_id, format_vertex_id_path +from pyhugegraph.utils.id_format import ( + format_edge_id_path, + format_vertex_id, + format_vertex_id_path, +) + + +def _urlencode_query(params): + parts = [] + for key, value in params: + if value is None: + parts.append(quote_plus(str(key), safe="")) + else: + parts.append(urlencode([(key, value)])) + return "&".join(parts) class GraphManager(HugeParamsBase): @@ -69,39 +83,47 @@ def getVertexById(self, vertex_id): def getVertexByPage(self, label, limit, page=None, properties=None): path = "graph/vertices?" - para = "" - para = para + "&label=" + label + params = [("label", label)] if properties: - para = para + "&properties=" + json.dumps(properties) + params.append(("properties", json.dumps(properties))) if page: - para += f"&page={page}" + params.append(("page", page)) else: - para += "&page" - para = para + "&limit=" + str(limit) - path = path + para[1:] + params.append(("page", None)) + params.append(("limit", str(limit))) + path = path + _urlencode_query(params) if response := self._sess.request(path): res = [VertexData(item) for item in response["vertices"]] next_page = response["page"] return res, next_page return None, None - def getVertexByCondition(self, label="", limit=0, page=None, properties=None): + def getVertexByConditionWithPage(self, label="", limit=0, page=None, properties=None): path = "graph/vertices?" - para = "" + params = [] if label: - para = para + "&label=" + label + params.append(("label", label)) if properties: - para = para + "&properties=" + json.dumps(properties) + params.append(("properties", json.dumps(properties))) if limit > 0: - para = para + "&limit=" + str(limit) + params.append(("limit", str(limit))) if page: - para += f"&page={page}" + params.append(("page", page)) else: - para += "&page" - path = path + para[1:] + params.append(("page", None)) + path = path + _urlencode_query(params) if response := self._sess.request(path): - return [VertexData(item) for item in response["vertices"]] - return None + return [VertexData(item) for item in response["vertices"]], response.get("page") + return None, None + + def getVertexByCondition(self, label="", limit=0, page=None, properties=None): + vertices, _ = self.getVertexByConditionWithPage( + label=label, + limit=limit, + page=page, + properties=properties, + ) + return vertices def removeVertexById(self, vertex_id): path = f"graph/vertices/{format_vertex_id_path(vertex_id)}" @@ -137,29 +159,37 @@ def addEdges(self, input_data) -> list[EdgeData] | None: return [EdgeData({"id": item}) for item in response] return None - @router.http("PUT", "graph/edges/{edge_id}?action=append") def appendEdge( self, edge_id, - properties, # pylint: disable=unused-argument + properties, ) -> EdgeData | None: - if response := self._invoke_request(data=json.dumps({"properties": properties})): + path = f"graph/edges/{format_edge_id_path(edge_id)}?action=append" + if response := self._sess.request( + path, + "PUT", + data=json.dumps({"properties": properties}), + ): return EdgeData(response) return None - @router.http("PUT", "graph/edges/{edge_id}?action=eliminate") def eliminateEdge( self, edge_id, - properties, # pylint: disable=unused-argument + properties, ) -> EdgeData | None: - if response := self._invoke_request(data=json.dumps({"properties": properties})): + path = f"graph/edges/{format_edge_id_path(edge_id)}?action=eliminate" + if response := self._sess.request( + path, + "PUT", + data=json.dumps({"properties": properties}), + ): return EdgeData(response) return None - @router.http("GET", "graph/edges/{edge_id}") - def getEdgeById(self, edge_id) -> EdgeData | None: # pylint: disable=unused-argument - if response := self._invoke_request(): + def getEdgeById(self, edge_id) -> EdgeData | None: + path = f"graph/edges/{format_edge_id_path(edge_id)}" + if response := self._sess.request(path): return EdgeData(response) return None @@ -173,31 +203,31 @@ def getEdgeByPage( properties=None, ): path = "graph/edges?" - para = "" + params = [] if vertex_id is not None: if direction: - vertex_query = urlencode({"vertex_id": format_vertex_id(vertex_id)}) - para = para + "&" + vertex_query + "&direction=" + direction + params.append(("vertex_id", format_vertex_id(vertex_id))) + params.append(("direction", direction)) else: raise NotFoundError("Direction can not be empty.") if label: - para = para + "&label=" + label + params.append(("label", label)) if properties: - para = para + "&properties=" + json.dumps(properties) + params.append(("properties", json.dumps(properties))) if page: - para += f"&page={page}" + params.append(("page", page)) else: - para += "&page" + params.append(("page", None)) if limit > 0: - para = para + "&limit=" + str(limit) - path = path + para[1:] + params.append(("limit", str(limit))) + path = path + _urlencode_query(params) if response := self._sess.request(path): return [EdgeData(item) for item in response["edges"]], response["page"] return None, None - @router.http("DELETE", "graph/edges/{edge_id}") - def removeEdgeById(self, edge_id) -> dict: # pylint: disable=unused-argument - return self._invoke_request() + def removeEdgeById(self, edge_id) -> dict: + path = f"graph/edges/{format_edge_id_path(edge_id)}" + return self._sess.request(path, "DELETE") def getVerticesById(self, vertex_ids) -> list[VertexData] | None: if not vertex_ids: @@ -213,9 +243,7 @@ def getEdgesById(self, edge_ids) -> list[EdgeData] | None: if not edge_ids: return [] path = "traversers/edges?" - for vertex_id in edge_ids: - path += f"ids={vertex_id}&" # pylint: disable=consider-using-join - path = path.rstrip("&") + path += urlencode([("ids", str(edge_id)) for edge_id in edge_ids]) if response := self._sess.request(path): return [EdgeData(item) for item in response["edges"]] return None diff --git a/hugegraph-python-client/src/pyhugegraph/api/schema_manage/index_label.py b/hugegraph-python-client/src/pyhugegraph/api/schema_manage/index_label.py index 94563a064..90c70e083 100644 --- a/hugegraph-python-client/src/pyhugegraph/api/schema_manage/index_label.py +++ b/hugegraph-python-client/src/pyhugegraph/api/schema_manage/index_label.py @@ -1,107 +1,108 @@ -# 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. - -# pylint: disable=logging-fstring-interpolation - -import json - -from pyhugegraph.api.common import HugeParamsBase -from pyhugegraph.utils.huge_decorator import decorator_create, decorator_params -from pyhugegraph.utils.log import log -from pyhugegraph.utils.util import ResponseValidation - - -class IndexLabel(HugeParamsBase): - @decorator_params - def onV(self, vertex_label) -> "IndexLabel": - self._parameter_holder.set("base_value", vertex_label) - self._parameter_holder.set("base_type", "VERTEX_LABEL") - return self - - @decorator_params - def onE(self, edge_label) -> "IndexLabel": - self._parameter_holder.set("base_value", edge_label) - self._parameter_holder.set("base_type", "EDGE_LABEL") - return self - - @decorator_params - def by(self, *args) -> "IndexLabel": - if "fields" not in self._parameter_holder.get_keys(): - self._parameter_holder.set("fields", set()) - s = self._parameter_holder.get_value("fields") - for item in args: - s.add(item) - return self - - @decorator_params - def secondary(self) -> "IndexLabel": - self._parameter_holder.set("index_type", "SECONDARY") - return self - - @decorator_params - def range(self) -> "IndexLabel": - self._parameter_holder.set("index_type", "RANGE") - return self - - @decorator_params - def search(self) -> "IndexLabel": - self._parameter_holder.set("index_type", "SEARCH") - return self - - @decorator_params - def shard(self) -> "IndexLabel": - self._parameter_holder.set("index_type", "SHARD") - return self - - @decorator_params - def unique(self) -> "IndexLabel": - self._parameter_holder.set("index_type", "UNIQUE") - return self - - @decorator_params - def ifNotExist(self) -> "IndexLabel": - path = f"schema/indexlabels/{self._parameter_holder.get_value('name')}" - if _ := self._sess.request(path, validator=ResponseValidation(strict=False)): - self._parameter_holder.set("not_exist", False) - return self - - @decorator_create - def create(self): - dic = self._parameter_holder.get_dic() - data = { - "name": dic["name"], - "base_type": dic["base_type"], - "base_value": dic["base_value"], - "index_type": dic["index_type"], - "fields": list(dic["fields"]), - } - path = "schema/indexlabels" - self.clean_parameter_holder() - if response := self._sess.request(path, "POST", data=json.dumps(data)): - return f'create IndexLabel success, Detail: "{response!s}"' - log.error(f'create IndexLabel failed, Detail: "{response!s}"') - return None - - @decorator_params - def remove(self): - name = self._parameter_holder.get_value("name") - path = f"schema/indexlabels/{name}" - self.clean_parameter_holder() - if response := self._sess.request(path, "DELETE"): - return f'remove IndexLabel success, Detail: "{response!s}"' - log.error(f'remove IndexLabel failed, Detail: "{response!s}"') - return None +# 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. + +# pylint: disable=logging-fstring-interpolation + +import json + +from pyhugegraph.api.common import HugeParamsBase +from pyhugegraph.utils.huge_decorator import decorator_create, decorator_params +from pyhugegraph.utils.log import log +from pyhugegraph.utils.util import ResponseValidation + + +class IndexLabel(HugeParamsBase): + @decorator_params + def onV(self, vertex_label) -> "IndexLabel": + self._parameter_holder.set("base_value", vertex_label) + self._parameter_holder.set("base_type", "VERTEX_LABEL") + return self + + @decorator_params + def onE(self, edge_label) -> "IndexLabel": + self._parameter_holder.set("base_value", edge_label) + self._parameter_holder.set("base_type", "EDGE_LABEL") + return self + + @decorator_params + def by(self, *args) -> "IndexLabel": + if "fields" not in self._parameter_holder.get_keys(): + self._parameter_holder.set("fields", []) + fields = self._parameter_holder.get_value("fields") + for item in args: + if item not in fields: + fields.append(item) + return self + + @decorator_params + def secondary(self) -> "IndexLabel": + self._parameter_holder.set("index_type", "SECONDARY") + return self + + @decorator_params + def range(self) -> "IndexLabel": + self._parameter_holder.set("index_type", "RANGE") + return self + + @decorator_params + def search(self) -> "IndexLabel": + self._parameter_holder.set("index_type", "SEARCH") + return self + + @decorator_params + def shard(self) -> "IndexLabel": + self._parameter_holder.set("index_type", "SHARD") + return self + + @decorator_params + def unique(self) -> "IndexLabel": + self._parameter_holder.set("index_type", "UNIQUE") + return self + + @decorator_params + def ifNotExist(self) -> "IndexLabel": + path = f"schema/indexlabels/{self._parameter_holder.get_value('name')}" + if _ := self._sess.request(path, validator=ResponseValidation(strict=False)): + self._parameter_holder.set("not_exist", False) + return self + + @decorator_create + def create(self): + dic = self._parameter_holder.get_dic() + data = { + "name": dic["name"], + "base_type": dic["base_type"], + "base_value": dic["base_value"], + "index_type": dic["index_type"], + "fields": dic["fields"], + } + path = "schema/indexlabels" + self.clean_parameter_holder() + if response := self._sess.request(path, "POST", data=json.dumps(data)): + return f'create IndexLabel success, Detail: "{response!s}"' + log.error(f'create IndexLabel failed, Detail: "{response!s}"') + return None + + @decorator_params + def remove(self): + name = self._parameter_holder.get_value("name") + path = f"schema/indexlabels/{name}" + self.clean_parameter_holder() + if response := self._sess.request(path, "DELETE"): + return f'remove IndexLabel success, Detail: "{response!s}"' + log.error(f'remove IndexLabel failed, Detail: "{response!s}"') + return None diff --git a/hugegraph-python-client/src/pyhugegraph/api/schema_manage/property_key.py b/hugegraph-python-client/src/pyhugegraph/api/schema_manage/property_key.py index eaefb59c1..410e7948f 100644 --- a/hugegraph-python-client/src/pyhugegraph/api/schema_manage/property_key.py +++ b/hugegraph-python-client/src/pyhugegraph/api/schema_manage/property_key.py @@ -1,187 +1,191 @@ -# 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. - -import json - -from pyhugegraph.api.common import HugeParamsBase -from pyhugegraph.utils.huge_decorator import decorator_create, decorator_params -from pyhugegraph.utils.log import log -from pyhugegraph.utils.util import ResponseValidation - - -# TODO: support UpdateStrategy for PropertyKey (refer java-client/rest-api) -class PropertyKey(HugeParamsBase): - # Data Type: [OBJECT, BOOLEAN, BYTE, INT, LONG, FLOAT, DOUBLE, TEXT, BLOB, DATE] - @decorator_params - def asObject(self) -> "PropertyKey": - self._parameter_holder.set("data_type", "OBJECT") - return self - - @decorator_params - def asBool(self) -> "PropertyKey": - self._parameter_holder.set("data_type", "BOOLEAN") - return self - - @decorator_params - def asByte(self) -> "PropertyKey": - self._parameter_holder.set("data_type", "BYTE") - return self - - @decorator_params - def asInt(self) -> "PropertyKey": - self._parameter_holder.set("data_type", "INT") - return self - - @decorator_params - def asLong(self) -> "PropertyKey": - self._parameter_holder.set("data_type", "LONG") - return self - - @decorator_params - def asFloat(self) -> "PropertyKey": - self._parameter_holder.set("data_type", "FLOAT") - return self - - @decorator_params - def asDouble(self) -> "PropertyKey": - self._parameter_holder.set("data_type", "DOUBLE") - return self - - @decorator_params - def asText(self) -> "PropertyKey": - self._parameter_holder.set("data_type", "TEXT") - return self - - @decorator_params - def asBlob(self) -> "PropertyKey": - self._parameter_holder.set("data_type", "BLOB") - return self - - @decorator_params - def asDate(self) -> "PropertyKey": - self._parameter_holder.set("data_type", "DATE") - return self - - # Cardinality Type [SINGLE, LIST, SET] - @decorator_params - def valueSingle(self) -> "PropertyKey": - self._parameter_holder.set("cardinality", "SINGLE") - return self - - @decorator_params - def valueList(self) -> "PropertyKey": - self._parameter_holder.set("cardinality", "LIST") - return self - - @decorator_params - def valueSet(self) -> "PropertyKey": - self._parameter_holder.set("cardinality", "SET") - return self - - # Aggregate Type [NONE, MAX, MIN, SUM, OLD] - @decorator_params - def calcMax(self) -> "PropertyKey": - self._parameter_holder.set("aggregate_type", "MAX") - return self - - @decorator_params - def calcMin(self) -> "PropertyKey": - self._parameter_holder.set("aggregate_type", "MIN") - return self - - @decorator_params - def calcSum(self) -> "PropertyKey": - self._parameter_holder.set("aggregate_type", "SUM") - return self - - @decorator_params - def calcOld(self) -> "PropertyKey": - self._parameter_holder.set("aggregate_type", "OLD") - return self - - @decorator_params - def userdata(self, *args) -> "PropertyKey": - user_data = self._parameter_holder.get_value("user_data") - if not user_data: - self._parameter_holder.set("user_data", {}) - user_data = self._parameter_holder.get_value("user_data") - i = 0 - while i < len(args): - user_data[args[i]] = args[i + 1] - i += 2 - return self - - def ifNotExist(self) -> "PropertyKey": - path = f"schema/propertykeys/{self._parameter_holder.get_value('name')}" - if _ := self._sess.request(path, validator=ResponseValidation(strict=False)): - self._parameter_holder.set("not_exist", False) - return self - - @decorator_create - def create(self): - dic = self._parameter_holder.get_dic() - property_keys = {"name": dic["name"]} - if "data_type" in dic: - property_keys["data_type"] = dic["data_type"] - if "cardinality" in dic: - property_keys["cardinality"] = dic["cardinality"] - path = "schema/propertykeys" - self.clean_parameter_holder() - if response := self._sess.request(path, "POST", data=json.dumps(property_keys)): - return f"create PropertyKey success, Detail: {response!s}" - log.error("create PropertyKey failed, Detail: %s", str(response)) - return "" - - @decorator_params - def append(self): - property_name = self._parameter_holder.get_value("name") - user_data = self._parameter_holder.get_value("user_data") - if not user_data: - user_data = {} - data = {"name": property_name, "user_data": user_data} - - path = f"schema/propertykeys/{property_name}/?action=append" - self.clean_parameter_holder() - if response := self._sess.request(path, "PUT", data=json.dumps(data)): - return f"append PropertyKey success, Detail: {response!s}" - log.error("append PropertyKey failed, Detail: %s", str(response)) - return "" - - @decorator_params - def eliminate(self): - property_name = self._parameter_holder.get_value("name") - user_data = self._parameter_holder.get_value("user_data") - if not user_data: - user_data = {} - data = {"name": property_name, "user_data": user_data} - - path = f"schema/propertykeys/{property_name}/?action=eliminate" - self.clean_parameter_holder() - if response := self._sess.request(path, "PUT", data=json.dumps(data)): - return f"eliminate PropertyKey success, Detail: {response!s}" - log.error("eliminate PropertyKey failed, Detail: %s", str(response)) - return "" - - @decorator_params - def remove(self): - dic = self._parameter_holder.get_dic() - path = f"schema/propertykeys/{dic['name']}" - self.clean_parameter_holder() - if response := self._sess.request(path, "DELETE"): - return f"delete PropertyKey success, Detail: {response!s}" - log.error("delete PropertyKey failed, Detail: %s", str(response)) - return "" +# 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. + +import json + +from pyhugegraph.api.common import HugeParamsBase +from pyhugegraph.utils.huge_decorator import decorator_create, decorator_params +from pyhugegraph.utils.log import log +from pyhugegraph.utils.util import ResponseValidation + + +# TODO: support UpdateStrategy for PropertyKey (refer java-client/rest-api) +class PropertyKey(HugeParamsBase): + # Data Type: [OBJECT, BOOLEAN, BYTE, INT, LONG, FLOAT, DOUBLE, TEXT, BLOB, DATE] + @decorator_params + def asObject(self) -> "PropertyKey": + self._parameter_holder.set("data_type", "OBJECT") + return self + + @decorator_params + def asBool(self) -> "PropertyKey": + self._parameter_holder.set("data_type", "BOOLEAN") + return self + + @decorator_params + def asByte(self) -> "PropertyKey": + self._parameter_holder.set("data_type", "BYTE") + return self + + @decorator_params + def asInt(self) -> "PropertyKey": + self._parameter_holder.set("data_type", "INT") + return self + + @decorator_params + def asLong(self) -> "PropertyKey": + self._parameter_holder.set("data_type", "LONG") + return self + + @decorator_params + def asFloat(self) -> "PropertyKey": + self._parameter_holder.set("data_type", "FLOAT") + return self + + @decorator_params + def asDouble(self) -> "PropertyKey": + self._parameter_holder.set("data_type", "DOUBLE") + return self + + @decorator_params + def asText(self) -> "PropertyKey": + self._parameter_holder.set("data_type", "TEXT") + return self + + @decorator_params + def asBlob(self) -> "PropertyKey": + self._parameter_holder.set("data_type", "BLOB") + return self + + @decorator_params + def asDate(self) -> "PropertyKey": + self._parameter_holder.set("data_type", "DATE") + return self + + # Cardinality Type [SINGLE, LIST, SET] + @decorator_params + def valueSingle(self) -> "PropertyKey": + self._parameter_holder.set("cardinality", "SINGLE") + return self + + @decorator_params + def valueList(self) -> "PropertyKey": + self._parameter_holder.set("cardinality", "LIST") + return self + + @decorator_params + def valueSet(self) -> "PropertyKey": + self._parameter_holder.set("cardinality", "SET") + return self + + # Aggregate Type [NONE, MAX, MIN, SUM, OLD] + @decorator_params + def calcMax(self) -> "PropertyKey": + self._parameter_holder.set("aggregate_type", "MAX") + return self + + @decorator_params + def calcMin(self) -> "PropertyKey": + self._parameter_holder.set("aggregate_type", "MIN") + return self + + @decorator_params + def calcSum(self) -> "PropertyKey": + self._parameter_holder.set("aggregate_type", "SUM") + return self + + @decorator_params + def calcOld(self) -> "PropertyKey": + self._parameter_holder.set("aggregate_type", "OLD") + return self + + @decorator_params + def userdata(self, *args) -> "PropertyKey": + user_data = self._parameter_holder.get_value("user_data") + if not user_data: + self._parameter_holder.set("user_data", {}) + user_data = self._parameter_holder.get_value("user_data") + i = 0 + while i < len(args): + user_data[args[i]] = args[i + 1] + i += 2 + return self + + def ifNotExist(self) -> "PropertyKey": + path = f"schema/propertykeys/{self._parameter_holder.get_value('name')}" + if _ := self._sess.request(path, validator=ResponseValidation(strict=False)): + self._parameter_holder.set("not_exist", False) + return self + + @decorator_create + def create(self): + dic = self._parameter_holder.get_dic() + property_keys = {"name": dic["name"]} + if "data_type" in dic: + property_keys["data_type"] = dic["data_type"] + if "cardinality" in dic: + property_keys["cardinality"] = dic["cardinality"] + if "aggregate_type" in dic: + property_keys["aggregate_type"] = dic["aggregate_type"] + if dic.get("user_data"): + property_keys["user_data"] = dic["user_data"] + path = "schema/propertykeys" + self.clean_parameter_holder() + if response := self._sess.request(path, "POST", data=json.dumps(property_keys)): + return f"create PropertyKey success, Detail: {response!s}" + log.error("create PropertyKey failed, Detail: %s", str(response)) + return "" + + @decorator_params + def append(self): + property_name = self._parameter_holder.get_value("name") + user_data = self._parameter_holder.get_value("user_data") + if not user_data: + user_data = {} + data = {"name": property_name, "user_data": user_data} + + path = f"schema/propertykeys/{property_name}/?action=append" + self.clean_parameter_holder() + if response := self._sess.request(path, "PUT", data=json.dumps(data)): + return f"append PropertyKey success, Detail: {response!s}" + log.error("append PropertyKey failed, Detail: %s", str(response)) + return "" + + @decorator_params + def eliminate(self): + property_name = self._parameter_holder.get_value("name") + user_data = self._parameter_holder.get_value("user_data") + if not user_data: + user_data = {} + data = {"name": property_name, "user_data": user_data} + + path = f"schema/propertykeys/{property_name}/?action=eliminate" + self.clean_parameter_holder() + if response := self._sess.request(path, "PUT", data=json.dumps(data)): + return f"eliminate PropertyKey success, Detail: {response!s}" + log.error("eliminate PropertyKey failed, Detail: %s", str(response)) + return "" + + @decorator_params + def remove(self): + dic = self._parameter_holder.get_dic() + path = f"schema/propertykeys/{dic['name']}" + self.clean_parameter_holder() + if response := self._sess.request(path, "DELETE"): + return f"delete PropertyKey success, Detail: {response!s}" + log.error("delete PropertyKey failed, Detail: %s", str(response)) + return "" diff --git a/hugegraph-python-client/src/pyhugegraph/utils/id_format.py b/hugegraph-python-client/src/pyhugegraph/utils/id_format.py index 7b4dbbfbd..c744627a6 100644 --- a/hugegraph-python-client/src/pyhugegraph/utils/id_format.py +++ b/hugegraph-python-client/src/pyhugegraph/utils/id_format.py @@ -48,7 +48,25 @@ def format_vertex_id(vertex_id, allow_none: bool = False) -> str | None: def format_vertex_id_path(vertex_id, allow_none: bool = False) -> str | None: + """Format a vertex id as a URL path segment. + + Vertex ids pass through format_vertex_id's json.dumps wrapper so HugeGraph + server can parse numeric ids such as 123 differently from string ids such + as "123". + """ formatted_id = format_vertex_id(vertex_id, allow_none=allow_none) if formatted_id is None: return None return quote(formatted_id, safe="") + + +def format_edge_id_path(edge_id) -> str: + """Format an edge id as a URL path segment. + + Edge ids are HugeGraph-generated composite strings such as + "S1:alice>11>>S2:bob", so they do not need JSON type disambiguation and + are URL-encoded directly from their string form. + """ + if edge_id is None: + raise ValueError("The edge id can't be None") + return quote(str(edge_id), safe="") diff --git a/hugegraph-python-client/src/tests/api/test_graph.py b/hugegraph-python-client/src/tests/api/test_graph.py index f4872a4b3..6a7cc0682 100644 --- a/hugegraph-python-client/src/tests/api/test_graph.py +++ b/hugegraph-python-client/src/tests/api/test_graph.py @@ -93,10 +93,10 @@ def test_get_vertex_by_number_id(self): def test_get_vertex_by_page(self): self.graph.addVertex("person", {"name": "Alice", "age": 20}) self.graph.addVertex("person", {"name": "Bob", "age": 23}) - # FIXME: destructure (items, next_page) and assert vertex contents; - # len(tuple) only proves the method returned two values. - vertices = self.graph.getVertexByPage("person", 1) - self.assertEqual(len(vertices), 2) + vertices, next_page = self.graph.getVertexByPage("person", 1) + self.assertEqual(len(vertices), 1) + self.assertIn(vertices[0].properties["name"], {"Alice", "Bob"}) + self.assertIsNotNone(next_page) def test_get_vertex_by_condition(self): self.graph.addVertex("person", {"name": "Alice", "age": 25}) @@ -105,6 +105,17 @@ def test_get_vertex_by_condition(self): self.assertEqual(len(vertices), 1) self.assertEqual(vertices[0].properties["name"], "Bob") + def test_get_vertex_by_condition_with_page(self): + self.graph.addVertex("person", {"name": "Condition Page Alice", "age": 25}) + vertices, next_page = self.graph.getVertexByConditionWithPage( + "person", + limit=1, + properties={"name": "Condition Page Alice"}, + ) + self.assertEqual(len(vertices), 1) + self.assertEqual(vertices[0].properties["name"], "Condition Page Alice") + self.assertIsNone(next_page) + def test_remove_vertex_by_id(self): vertex = self.graph.addVertex("person", {"name": "Alice", "age": 20}) self.graph.removeVertexById(vertex.id) @@ -219,7 +230,8 @@ def test_get_edges_by_id(self): def test_graph_supports_primary_key_and_custom_string_id(client_utils): graph = client_utils.graph graph.addVertex("person", {"name": "quality_marko", "age": 29, "city": "Beijing"}) - person = graph.getVertexByCondition(label="person", properties={"name": "quality_marko"}, limit=1)[0] + people = graph.getVertexByCondition(label="person", properties={"name": "quality_marko"}, limit=1) + person = people[0] assert person.id is not None graph.addVertex("book", {"name": "Quality Book", "price": 100}, id="quality-book-1") diff --git a/hugegraph-python-client/src/tests/api/test_schema.py b/hugegraph-python-client/src/tests/api/test_schema.py index 56e8a12a5..2eef23b83 100644 --- a/hugegraph-python-client/src/tests/api/test_schema.py +++ b/hugegraph-python-client/src/tests/api/test_schema.py @@ -15,17 +15,84 @@ # specific language governing permissions and limitations # under the License. +import json import unittest from contextlib import suppress import pytest +from pyhugegraph.api.schema_manage.index_label import IndexLabel +from pyhugegraph.api.schema_manage.property_key import PropertyKey from ..client_utils import ClientUtils pytestmark = [pytest.mark.integration, pytest.mark.hugegraph] -# FIXME: add schema builder contract tests proving calc*/userdata survive -# property key create() and multi-field index labels preserve field order. + +class DummySchemaSession: + def __init__(self): + self.requests = [] + + def request(self, path, method="GET", validator=None, **kwargs): + self.requests.append({"path": path, "method": method, "validator": validator, **kwargs}) + return {"ok": True} + + +def test_property_key_create_includes_aggregate_type_in_payload(): + session = DummySchemaSession() + property_key = PropertyKey(session) + + property_key.create_parameter_holder() + property_key.add_parameter("name", "score") + property_key.asInt().valueSingle().calcSum().create() + + request = session.requests[-1] + assert request["path"] == "schema/propertykeys" + assert request["method"] == "POST" + assert json.loads(request["data"]) == { + "name": "score", + "data_type": "INT", + "cardinality": "SINGLE", + "aggregate_type": "SUM", + } + + +def test_property_key_create_includes_user_data_in_payload(): + session = DummySchemaSession() + property_key = PropertyKey(session) + + property_key.create_parameter_holder() + property_key.add_parameter("name", "score") + property_key.asInt().valueSingle().userdata("min", 0, "max", 100).create() + + request = session.requests[-1] + assert request["path"] == "schema/propertykeys" + assert request["method"] == "POST" + assert json.loads(request["data"]) == { + "name": "score", + "data_type": "INT", + "cardinality": "SINGLE", + "user_data": {"min": 0, "max": 100}, + } + + +def test_index_label_create_preserves_field_order_and_deduplicates(): + session = DummySchemaSession() + index_label = IndexLabel(session) + + index_label.create_parameter_holder() + index_label.add_parameter("name", "personByAgeCity") + index_label.onV("person").by("age", "city", "age").secondary().create() + + request = session.requests[-1] + assert request["path"] == "schema/indexlabels" + assert request["method"] == "POST" + assert json.loads(request["data"]) == { + "name": "personByAgeCity", + "base_type": "VERTEX_LABEL", + "base_value": "person", + "index_type": "SECONDARY", + "fields": ["age", "city"], + } class TestSchemaManager(unittest.TestCase): diff --git a/hugegraph-python-client/src/tests/api/test_traverser.py b/hugegraph-python-client/src/tests/api/test_traverser.py index 031069666..06f88130d 100644 --- a/hugegraph-python-client/src/tests/api/test_traverser.py +++ b/hugegraph-python-client/src/tests/api/test_traverser.py @@ -51,9 +51,13 @@ def tearDown(self): pass def test_traverser_operations(self): - marko = self.graph.getVertexByCondition("person", properties={"name": "marko"})[0].id - josh = self.graph.getVertexByCondition("person", properties={"name": "josh"})[0].id - ripple = self.graph.getVertexByCondition("software", properties={"name": "ripple"})[0].id + def first_vertex_id(label, properties): + vertices = self.graph.getVertexByCondition(label, properties=properties) + return vertices[0].id + + marko = first_vertex_id("person", {"name": "marko"}) + josh = first_vertex_id("person", {"name": "josh"}) + ripple = first_vertex_id("software", {"name": "ripple"}) k_out_result = self.traverser.k_out(marko, 2) self.assertEqual(k_out_result["vertices"], ["1:peter", "2:ripple"]) diff --git a/hugegraph-python-client/src/tests/api/test_vertex_id_format.py b/hugegraph-python-client/src/tests/api/test_vertex_id_format.py index 24e9950ae..49cb10875 100644 --- a/hugegraph-python-client/src/tests/api/test_vertex_id_format.py +++ b/hugegraph-python-client/src/tests/api/test_vertex_id_format.py @@ -23,7 +23,11 @@ import pytest from pyhugegraph.api.graph import GraphManager from pyhugegraph.api.traverser import TraverserManager -from pyhugegraph.utils.id_format import format_vertex_id, format_vertex_id_path +from pyhugegraph.utils.id_format import ( + format_edge_id_path, + format_vertex_id, + format_vertex_id_path, +) pytestmark = [pytest.mark.unit] @@ -82,6 +86,10 @@ def test_format_vertex_id_path_quotes_json_literal_as_one_url_segment(): ) +def test_format_edge_id_path_quotes_edge_id_as_one_url_segment(): + assert format_edge_id_path("S1:alice>11>knows>S2:bob") == "S1%3Aalice%3E11%3Eknows%3ES2%3Abob" + + def test_graph_vertex_path_formats_json_literal_ids(): session = FakeSession( responses=[ @@ -101,6 +109,33 @@ def test_graph_vertex_path_formats_json_literal_ids(): assert session.calls[2][0] == "graph/vertices/%22a%2Fb%3Fc%23d%26e%22" +def test_graph_edge_id_paths_encode_reserved_characters(): + edge_id = "S1:alice>11>knows>S2:bob" + session = FakeSession( + responses=[ + {"id": edge_id, "label": "knows", "properties": {}}, + {"id": edge_id, "label": "knows", "properties": {"city": "Beijing"}}, + {"id": edge_id, "label": "knows", "properties": {}}, + {}, + ] + ) + graph = GraphManager(session) + + graph.getEdgeById(edge_id) + graph.appendEdge(edge_id, {"city": "Beijing"}) + graph.eliminateEdge(edge_id, {"city": "Beijing"}) + graph.removeEdgeById(edge_id) + + encoded = "S1%3Aalice%3E11%3Eknows%3ES2%3Abob" + assert session.calls[0][0] == f"graph/edges/{encoded}" + assert session.calls[1][0] == f"graph/edges/{encoded}?action=append" + assert session.calls[1][1] == "PUT" + assert session.calls[2][0] == f"graph/edges/{encoded}?action=eliminate" + assert session.calls[2][1] == "PUT" + assert session.calls[3][0] == f"graph/edges/{encoded}" + assert session.calls[3][1] == "DELETE" + + def test_graph_edge_page_formats_and_encodes_vertex_id_query(): session = FakeSession( responses=[ @@ -117,6 +152,71 @@ def test_graph_edge_page_formats_and_encodes_vertex_id_query(): assert session.calls[1][0] == "graph/edges?vertex_id=123&direction=OUT&label=knows&page" +def test_graph_page_queries_urlencode_reserved_query_values(): + properties = {"name": "Alice & Bob", "city": "A/B?C#D"} + session = FakeSession( + responses=[ + {"vertices": [], "page": "next/1?x=y&z"}, + {"vertices": [], "page": "condition/next?x=1"}, + {"edges": [], "page": None}, + ] + ) + graph = GraphManager(session) + + _, vertex_page = graph.getVertexByPage( + label="person/team", + limit=10, + page="page/1?cursor=a&b", + properties=properties, + ) + _, condition_page = graph.getVertexByConditionWithPage( + label="person/team", + limit=5, + page="page/2?cursor=c&d", + properties=properties, + ) + graph.getEdgeByPage( + label="knows/team", + vertex_id="person:alice/bob", + direction="OUT/IN", + limit=3, + page="edge/page?x=1&y=2", + properties=properties, + ) + + assert session.calls[0][0] == ( + "graph/vertices?" + "label=person%2Fteam&" + "properties=%7B%22name%22%3A+%22Alice+%26+Bob%22%2C+%22city%22%3A+%22A%2FB%3FC%23D%22%7D&" + "page=page%2F1%3Fcursor%3Da%26b&limit=10" + ) + assert session.calls[1][0] == ( + "graph/vertices?" + "label=person%2Fteam&" + "properties=%7B%22name%22%3A+%22Alice+%26+Bob%22%2C+%22city%22%3A+%22A%2FB%3FC%23D%22%7D&" + "limit=5&page=page%2F2%3Fcursor%3Dc%26d" + ) + assert session.calls[2][0] == ( + "graph/edges?" + "vertex_id=%22person%3Aalice%2Fbob%22&direction=OUT%2FIN&label=knows%2Fteam&" + "properties=%7B%22name%22%3A+%22Alice+%26+Bob%22%2C+%22city%22%3A+%22A%2FB%3FC%23D%22%7D&" + "page=edge%2Fpage%3Fx%3D1%26y%3D2&limit=3" + ) + assert vertex_page == "next/1?x=y&z" + assert condition_page == "condition/next?x=1" + + +def test_graph_edges_by_id_encodes_repeated_edge_id_query_params(): + edge_id = "S1:alice>11>knows>S2:bob" + session = FakeSession(responses=[{"edges": [{"id": edge_id}]}]) + graph = GraphManager(session) + + edges = graph.getEdgesById([edge_id]) + + assert [edge.id for edge in edges] == [edge_id] + assert session.calls[0][0] == "traversers/edges?ids=S1%3Aalice%3E11%3Eknows%3ES2%3Abob" + + def test_graph_vertices_by_id_formats_repeated_json_literal_query_params(): session = FakeSession( responses=[ diff --git a/pyproject.toml b/pyproject.toml index a9ef7a6e5..69194ef40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -156,6 +156,9 @@ markers = [ "contract: public contract tests; may use mocks but verify stable behavior", "integration: tests requiring a real local service such as HugeGraph", "hugegraph: tests requiring HugeGraph Server", + "live: requires a real HugeGraph Server", + "real_hugegraph: requires a real HugeGraph Server", + "llm: requires HugeGraph-AI or an external LLM provider", "smoke: end-to-end-ish high-value smoke over production pipeline boundaries", "external: tests requiring external provider credentials or non-HugeGraph services", "slow: long-running tests excluded from default local loops", From 8dac401a2ae88ed6212cce6839ba112705341a37 Mon Sep 17 00:00:00 2001 From: duyifeng01 Date: Sat, 11 Jul 2026 12:01:40 +0800 Subject: [PATCH 4/4] fix(mcp): complete remaining defect hardening Change-Id: I39f965b763bf635927d5b00bf8ad6b6295ae1a2d --- .github/workflows/hugegraph-mcp.yml | 49 ++ README.md | 6 + docker/docker-compose-llm.yml | 2 +- docker/docker-compose-network.yml | 2 +- hugegraph-llm/README.md | 17 +- .../src/hugegraph_llm/api/rag_api.py | 377 ++++++++------ .../src/hugegraph_llm/api/thin_api.py | 36 +- .../src/hugegraph_llm/config/__init__.py | 11 +- .../src/hugegraph_llm/demo/rag_demo/app.py | 51 +- .../demo/rag_demo/configs_block.py | 46 +- .../demo/rag_demo/text2gremlin_block.py | 2 + .../src/hugegraph_llm/flows/text2gremlin.py | 4 + .../nodes/hugegraph_node/gremlin_execute.py | 11 +- .../hugegraph_llm/utils/hugegraph_utils.py | 57 ++- hugegraph-llm/src/tests/api/test_rag_api.py | 216 +++++++- hugegraph-llm/src/tests/api/test_thin_api.py | 164 +++++- hugegraph-llm/src/tests/config/test_config.py | 50 +- .../test_core_text2gremlin_smoke.py | 8 + .../integration/test_hugegraph_boundary.py | 59 +++ .../src/tests/nodes/test_gremlin_execute.py | 53 ++ hugegraph-llm/src/tests/test_rag_demo_cli.py | 93 ++++ .../src/tests/utils/test_hugegraph_utils.py | 63 +++ hugegraph-mcp/README.md | 10 +- hugegraph-mcp/README.zh-CN.md | 7 +- .../docs/graph_mcp_post_review_fix_plan.md | 340 +++++++++++++ .../graph_mcp_remaining_defect_fix_plan.md | 285 +++++++++++ .../docs/graph_mcp_review_fix_plan.md | 2 +- .../docs/p0a-integration-checklist.md | 13 +- hugegraph-mcp/hugegraph_mcp/config.py | 104 +++- .../hugegraph_mcp/confirmable_workflow.py | 170 ++++++- .../hugegraph_mcp/confirmation_store.py | 245 +++++++++ hugegraph-mcp/hugegraph_mcp/envelope.py | 1 + hugegraph-mcp/hugegraph_mcp/error_mapping.py | 47 ++ hugegraph-mcp/hugegraph_mcp/gremlin_tools.py | 48 ++ .../hugegraph_mcp/hugegraph_ai_client.py | 98 +++- hugegraph-mcp/hugegraph_mcp/server.py | 6 +- .../hugegraph_mcp/tools/generate_gremlin.py | 67 ++- .../hugegraph_mcp/tools/ingest_graph_data.py | 221 ++++++-- .../hugegraph_mcp/tools/manage_graph_data.py | 15 +- .../hugegraph_mcp/tools/manage_schema.py | 51 +- .../tools/mutate_graph_properties.py | 103 +++- .../hugegraph_mcp/tools/query_graph_data.py | 18 +- .../hugegraph_mcp/tools/schema_utils.py | 47 +- hugegraph-mcp/pyproject.toml | 2 +- hugegraph-mcp/tests/conftest.py | 22 + .../tests/integration/test_real_write_path.py | 98 +++- hugegraph-mcp/tests/test_config.py | 77 ++- hugegraph-mcp/tests/test_envelope.py | 3 +- hugegraph-mcp/tests/test_error_handling.py | 96 +++- hugegraph-mcp/tests/test_generate_gremlin.py | 108 +++- .../tests/test_hugegraph_ai_client.py | 245 ++++++++- hugegraph-mcp/tests/test_ingest_graph_data.py | 479 ++++++++++++++++++ hugegraph-mcp/tests/test_manage_graph_data.py | 185 +++++++ hugegraph-mcp/tests/test_manage_schema.py | 128 +++++ .../test_mutate_graph_properties_tool.py | 363 ++++++++++++- hugegraph-mcp/tests/test_plan_hash.py | 357 ++++++++++++- .../tests/test_query_graph_data_tool.py | 87 +++- hugegraph-mcp/tests/test_readonly_mode.py | 6 +- hugegraph-mcp/tests/test_schema_utils.py | 42 +- hugegraph-mcp/tests/test_v1_stable_tools.py | 9 + .../src/pyhugegraph/api/services.py | 2 +- .../src/pyhugegraph/utils/huge_router.py | 33 +- .../src/pyhugegraph/utils/log.py | 4 +- .../src/tests/api/test_auth_routing.py | 94 +++- .../src/tests/api/test_log.py | 44 ++ .../src/tests/api/test_schema.py | 70 --- .../src/tests/api/test_schema_contract.py | 91 ++++ skills/hugegraph-data-importer/SKILL.md | 2 +- skills/hugegraph-regression-tester/SKILL.md | 2 +- 69 files changed, 5479 insertions(+), 445 deletions(-) create mode 100644 hugegraph-llm/src/tests/nodes/test_gremlin_execute.py create mode 100644 hugegraph-llm/src/tests/test_rag_demo_cli.py create mode 100644 hugegraph-llm/src/tests/utils/test_hugegraph_utils.py create mode 100644 hugegraph-mcp/docs/graph_mcp_post_review_fix_plan.md create mode 100644 hugegraph-mcp/docs/graph_mcp_remaining_defect_fix_plan.md create mode 100644 hugegraph-mcp/hugegraph_mcp/confirmation_store.py create mode 100644 hugegraph-mcp/tests/conftest.py create mode 100644 hugegraph-python-client/src/tests/api/test_log.py create mode 100644 hugegraph-python-client/src/tests/api/test_schema_contract.py diff --git a/.github/workflows/hugegraph-mcp.yml b/.github/workflows/hugegraph-mcp.yml index cbb94c116..3a5b13faa 100644 --- a/.github/workflows/hugegraph-mcp.yml +++ b/.github/workflows/hugegraph-mcp.yml @@ -38,6 +38,9 @@ on: - "uv.lock" - ".github/workflows/hugegraph-mcp.yml" +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest @@ -74,6 +77,52 @@ jobs: run: | uv sync --extra mcp --extra dev + - name: Verify isolated wheel install + if: matrix.python-version == '3.10' + run: | + rm -rf wheelhouse isolated-mcp-venv + uv build --wheel --out-dir wheelhouse hugegraph-python-client + uv build --wheel --out-dir wheelhouse hugegraph-mcp + python -m venv isolated-mcp-venv + isolated-mcp-venv/bin/python -m pip install \ + --find-links wheelhouse \ + wheelhouse/hugegraph_python_client-*.whl \ + wheelhouse/hugegraph_mcp-*.whl + isolated-mcp-venv/bin/python - <<'PY' + from importlib.metadata import version + from types import SimpleNamespace + from unittest.mock import Mock + + import hugegraph_mcp + import pyhugegraph + from hugegraph_mcp.server import main + from pyhugegraph.api.auth import AuthManager + from pyhugegraph.utils.huge_config import HGraphConfig + from pyhugegraph.utils.huge_requests import HGraphSession + + assert version("hugegraph-python-client") == "1.7.0" + assert version("hugegraph-mcp") == "0.1.0" + + class CaptureSession: + cfg = SimpleNamespace(graphspace="GS", gs_supported=True) + + def request(self, path, method="GET", **_kwargs): + self.path = path + return {"ok": True} + + capture = CaptureSession() + AuthManager(capture).list_users() + assert capture.path == "/graphspaces/GS/auth/users" + + config = HGraphConfig( + "http://127.0.0.1:8080", "admin", "pwd", "g", graphspace="GS" + ) + session = HGraphSession(config, session=Mock()) + assert session.resolve("schema") == ( + "http://127.0.0.1:8080/graphspaces/GS/graphs/g/schema" + ) + PY + - name: Check MCP formatting working-directory: hugegraph-mcp run: | diff --git a/README.md b/README.md index c348af1ae..600fae16f 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,10 @@ docker compose -f docker-compose-network.yml up -d # - RAG Service: http://localhost:8001 ``` +The RAG service is published only on the host loopback interface by default. Exposing it on a non-loopback interface is +an explicit deployment choice: the HTTP API has no unified authentication, so configure reverse proxy authentication, +a firewall, or a trusted network first. + ### Option 2: Source Installation ```bash @@ -70,6 +74,8 @@ python -m hugegraph_llm.demo.rag_demo.app # Visit http://127.0.0.1:8001 ``` +The source launcher binds to `127.0.0.1` by default and warns when a non-loopback `--host` is selected. + ### Basic Usage Examples > [!NOTE] diff --git a/docker/docker-compose-llm.yml b/docker/docker-compose-llm.yml index 2e0788b88..2f74b2aad 100644 --- a/docker/docker-compose-llm.yml +++ b/docker/docker-compose-llm.yml @@ -9,7 +9,7 @@ services: container_name: hugegraph-llm-rag restart: always ports: - - "8001:8001" + - "127.0.0.1:8001:8001" # If you want to mount local configs to the container, uncomment the following lines #volumes: # Mount local '.env' file, could use ${ENV_FILE_PATH:-/dev/null} to avoid error diff --git a/docker/docker-compose-network.yml b/docker/docker-compose-network.yml index 43c3aecc3..ac72c2a2a 100644 --- a/docker/docker-compose-network.yml +++ b/docker/docker-compose-network.yml @@ -28,7 +28,7 @@ services: container_name: rag restart: unless-stopped ports: - - "8001:8001" + - "127.0.0.1:8001:8001" volumes: # Mount .env file, please modify according to actual path - ${PROJECT_PATH}/hugegraph-llm/.env:/home/work/hugegraph-llm/.env diff --git a/hugegraph-llm/README.md b/hugegraph-llm/README.md index aa61b2758..bfa91cb39 100644 --- a/hugegraph-llm/README.md +++ b/hugegraph-llm/README.md @@ -60,6 +60,8 @@ docker-compose -f docker-compose-network.yml ps # RAG Service: http://localhost:8001 ``` +The Compose configuration publishes the RAG service only on the host loopback interface by default. + ### Option 2: Individual Docker Containers For more control over individual components: @@ -80,7 +82,7 @@ docker run -itd --name=server -p 8080:8080 --network hugegraph-net hugegraph/hug docker pull hugegraph/rag:latest docker run -itd --name rag \ -v /path/to/your/hugegraph-llm/.env:/home/work/hugegraph-llm/.env \ - -p 8001:8001 --network hugegraph-net hugegraph/rag + -p 127.0.0.1:8001:8001 --network hugegraph-net hugegraph/rag # 4. Monitor logs docker logs -f rag @@ -117,6 +119,10 @@ python -m hugegraph_llm.demo.rag_demo.app python -m hugegraph_llm.demo.rag_demo.app --host 127.0.0.1 --port 18001 ``` +The source launcher also binds to `127.0.0.1` by default. Publishing the Docker port on a non-loopback interface or +passing a non-loopback `--host` is an explicit deployment choice. The HTTP API has no unified authentication, so use +reverse proxy authentication, a firewall, or a trusted network before exposing it to other machines. + #### Additional Setup (Optional) > [!NOTE] @@ -181,6 +187,15 @@ The system supports both English and Chinese prompts. To switch languages: > [!NOTE] > Configuration changes are automatically saved when using the web interface. For manual changes, simply refresh the page to load updates. +### Legacy Thin API writes + +The compatibility endpoints `POST /graph-import` and +`POST /vid-embeddings/refresh` are disabled by default. They are intended only +for authenticated internal callers. To enable them, set both +`ENABLE_LOGIN=true` and `HUGEGRAPH_LLM_ENABLE_THIN_WRITES=true`, then use the +configured `USER_TOKEN` as a Bearer token. Prefer the HugeGraph MCP guarded +write tools for user-facing workflows. + **LLM Provider Support**: This project uses [LiteLLM](https://docs.litellm.ai/docs/providers) for multi-provider LLM support. ### Programmatic Examples (new workflow engine) diff --git a/hugegraph-llm/src/hugegraph_llm/api/rag_api.py b/hugegraph-llm/src/hugegraph_llm/api/rag_api.py index fc7aa6533..a142921c8 100644 --- a/hugegraph-llm/src/hugegraph_llm/api/rag_api.py +++ b/hugegraph-llm/src/hugegraph_llm/api/rag_api.py @@ -16,7 +16,7 @@ # under the License. import json -from contextlib import contextmanager +from urllib.parse import urlsplit from fastapi import APIRouter, HTTPException, status @@ -30,7 +30,7 @@ RerankerConfigRequest, ) from hugegraph_llm.api.models.rag_response import RAGResponse -from hugegraph_llm.config import huge_settings, llm_settings, prompt +from hugegraph_llm.config import huge_settings, llm_settings, prompt, runtime_config_lock from hugegraph_llm.utils.graph_index_utils import get_vertex_details from hugegraph_llm.utils.log import log @@ -106,6 +106,89 @@ def _restore_settings(settings, values): setattr(settings, field, value) +def _request_graph_connection(req): + client_config = getattr(req, "client_config", None) + values = { + "url": huge_settings.graph_url, + "graph": huge_settings.graph_name, + "user": huge_settings.graph_user, + "pwd": huge_settings.graph_pwd, + "graphspace": huge_settings.graph_space, + } + if client_config is None: + return values + if "url" in client_config.model_fields_set: + if _normalize_graph_url(client_config.url) != _normalize_graph_url(values["url"]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Request graph URL must match the configured HugeGraph URL.", + ) + configured_targets = { + "graph": values["graph"], + "gs": values["graphspace"], + } + for request_field, configured_value in configured_targets.items(): + if ( + request_field in client_config.model_fields_set + and getattr(client_config, request_field) != configured_value + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Request {request_field} must match the configured HugeGraph target.", + ) + configured_credentials = { + "user": values["user"], + "pwd": values["pwd"], + } + for request_field, configured_value in configured_credentials.items(): + if ( + request_field in client_config.model_fields_set + and getattr(client_config, request_field) != configured_value + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Request {request_field} must match the configured HugeGraph credentials.", + ) + for request_field in _GRAPH_CONFIG_FIELD_MAP: + if request_field == "url": + continue + if request_field in client_config.model_fields_set: + connection_field = "graphspace" if request_field == "gs" else request_field + values[connection_field] = getattr(client_config, request_field) + return values + + +def _normalize_graph_url(url: str) -> str: + raw_url = str(url).strip() + if "://" not in raw_url: + raw_url = f"http://{raw_url}" + try: + parsed = urlsplit(raw_url) + if ( + parsed.scheme.lower() not in {"http", "https"} + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ValueError("not an absolute HTTP(S) URL") + port = parsed.port + hostname = (parsed.hostname or "").lower() + if not hostname: + raise ValueError("missing hostname") + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Graph URL must be a valid absolute HTTP(S) URL.", + ) from exc + authority = f"[{hostname}]" if ":" in hostname else hostname + default_port = 80 if parsed.scheme.lower() == "http" else 443 + if port is not None and port != default_port: + authority += f":{port}" + return f"{parsed.scheme.lower()}://{authority}{parsed.path.rstrip('/')}" + + # pylint: disable=too-many-statements def rag_http_api( router: APIRouter, @@ -117,25 +200,61 @@ def rag_http_api( apply_reranker_conf, gremlin_generate_selective_func, ): - @contextmanager - def request_graph_config(req): - # FIXME: per-request graph overrides still mutate process-global huge_settings. - # A global lock would serialize long RAG/Text2Gremlin requests, so the real - # fix is passing graph config through request-scoped flow/operator context. - original_values = _snapshot_settings(huge_settings, _GRAPH_CONFIG_FIELD_MAP.values()) - try: - client_config = getattr(req, "client_config", None) - if client_config is not None: - for request_field, settings_field in _GRAPH_CONFIG_FIELD_MAP.items(): - if request_field in client_config.model_fields_set: - setattr(huge_settings, settings_field, getattr(client_config, request_field)) - yield - finally: - _restore_settings(huge_settings, original_values) - @router.post("/rag", status_code=status.HTTP_200_OK) def rag_answer_api(req: RAGRequest): - with request_graph_config(req): + if req.client_config is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="client_config is not supported on /rag; use /config/graph.", + ) + # Basic parameter validation: empty query => 400 + if not req.query or not str(req.query).strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Query must not be empty.", + ) + + result = rag_answer_func( + text=req.query, + raw_answer=req.raw_answer, + vector_only_answer=req.vector_only, + graph_only_answer=req.graph_only, + graph_vector_answer=req.graph_vector_answer, + graph_ratio=req.graph_ratio, + rerank_method=req.rerank_method, + near_neighbor_first=req.near_neighbor_first, + gremlin_tmpl_num=req.gremlin_tmpl_num, + max_graph_items=req.max_graph_items, + topk_return_results=req.topk_return_results, + vector_dis_threshold=req.vector_dis_threshold, + topk_per_keyword=req.topk_per_keyword, + # Keep prompt params in the end + custom_related_information=req.custom_priority_info, + answer_prompt=req.answer_prompt or prompt.answer_prompt, + keywords_extract_prompt=req.keywords_extract_prompt or prompt.keywords_extract_prompt, + gremlin_prompt=req.gremlin_prompt or prompt.gremlin_generate_prompt, + ) + # TODO: we need more info in the response for users to understand the query logic + return { + "query": req.query, + **{ + key: value + for key, value in zip( + ["raw_answer", "vector_only", "graph_only", "graph_vector_answer"], + result, + ) + if getattr(req, key) + }, + } + + @router.post("/rag/graph", status_code=status.HTTP_200_OK) + def graph_rag_recall_api(req: GraphRAGRequest): + try: + if req.client_config is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="client_config is not supported on /rag/graph; use /config/graph.", + ) # Basic parameter validation: empty query => 400 if not req.query or not str(req.query).strip(): raise HTTPException( @@ -143,82 +262,38 @@ def rag_answer_api(req: RAGRequest): detail="Query must not be empty.", ) - result = rag_answer_func( - text=req.query, - raw_answer=req.raw_answer, - vector_only_answer=req.vector_only, - graph_only_answer=req.graph_only, - graph_vector_answer=req.graph_vector_answer, - graph_ratio=req.graph_ratio, - rerank_method=req.rerank_method, - near_neighbor_first=req.near_neighbor_first, - gremlin_tmpl_num=req.gremlin_tmpl_num, + result = graph_rag_recall_func( + query=req.query, max_graph_items=req.max_graph_items, topk_return_results=req.topk_return_results, vector_dis_threshold=req.vector_dis_threshold, topk_per_keyword=req.topk_per_keyword, - # Keep prompt params in the end + gremlin_tmpl_num=req.gremlin_tmpl_num, + rerank_method=req.rerank_method, + near_neighbor_first=req.near_neighbor_first, custom_related_information=req.custom_priority_info, - answer_prompt=req.answer_prompt or prompt.answer_prompt, - keywords_extract_prompt=req.keywords_extract_prompt or prompt.keywords_extract_prompt, gremlin_prompt=req.gremlin_prompt or prompt.gremlin_generate_prompt, + get_vertex_only=req.get_vertex_only, ) - # TODO: we need more info in the response for users to understand the query logic - return { - "query": req.query, - **{ - key: value - for key, value in zip( - ["raw_answer", "vector_only", "graph_only", "graph_vector_answer"], - result, - ) - if getattr(req, key) - }, - } - @router.post("/rag/graph", status_code=status.HTTP_200_OK) - def graph_rag_recall_api(req: GraphRAGRequest): - try: - with request_graph_config(req): - # Basic parameter validation: empty query => 400 - if not req.query or not str(req.query).strip(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Query must not be empty.", - ) - - result = graph_rag_recall_func( - query=req.query, - max_graph_items=req.max_graph_items, - topk_return_results=req.topk_return_results, - vector_dis_threshold=req.vector_dis_threshold, - topk_per_keyword=req.topk_per_keyword, - gremlin_tmpl_num=req.gremlin_tmpl_num, - rerank_method=req.rerank_method, - near_neighbor_first=req.near_neighbor_first, - custom_related_information=req.custom_priority_info, - gremlin_prompt=req.gremlin_prompt or prompt.gremlin_generate_prompt, - get_vertex_only=req.get_vertex_only, - ) - - if req.get_vertex_only: - vertex_details = get_vertex_details(result["match_vids"], result) - if vertex_details: - result["match_vids"] = vertex_details + if req.get_vertex_only: + vertex_details = get_vertex_details(result["match_vids"], result) + if vertex_details: + result["match_vids"] = vertex_details - if isinstance(result, dict): - params = [ - "query", - "keywords", - "match_vids", - "graph_result_flag", - "gremlin", - "graph_result", - "vertex_degree_list", - ] - user_result = {key: result[key] for key in params if key in result} - return {"graph_recall": user_result} - return {"graph_recall": json.dumps(result)} + if isinstance(result, dict): + params = [ + "query", + "keywords", + "match_vids", + "graph_result_flag", + "gremlin", + "graph_result", + "vertex_degree_list", + ] + user_result = {key: result[key] for key in params if key in result} + return {"graph_recall": user_result} + return {"graph_recall": json.dumps(result)} except HTTPException as e: raise e @@ -241,82 +316,90 @@ def graph_config_api(req: GraphConfigRequest): # TODO: restructure the implement of llm to three types, like "/config/chat_llm" @router.post("/config/llm", status_code=status.HTTP_201_CREATED) def llm_config_api(req: LLMConfigRequest): - original_values = _snapshot_settings(llm_settings, _LLM_CONFIG_FIELDS) - try: - llm_settings.chat_llm_type = req.llm_type - llm_settings.extract_llm_type = req.llm_type - llm_settings.text2gql_llm_type = req.llm_type + with runtime_config_lock: + original_values = _snapshot_settings(llm_settings, _LLM_CONFIG_FIELDS) + try: + llm_settings.chat_llm_type = req.llm_type + llm_settings.extract_llm_type = req.llm_type + llm_settings.text2gql_llm_type = req.llm_type - if req.llm_type in ("openai", "litellm"): - res = apply_llm_conf( - req.api_key, - req.api_base, - req.language_model, - req.max_tokens, - origin_call="http", - ) - else: - res = apply_llm_conf(req.host, req.port, req.language_model, None, origin_call="http") - return generate_response(RAGResponse(status_code=res, message="Missing Value")) - except Exception: - _restore_settings(llm_settings, original_values) - raise + if req.llm_type in ("openai", "litellm"): + res = apply_llm_conf( + req.api_key, + req.api_base, + req.language_model, + req.max_tokens, + origin_call="http", + ) + else: + res = apply_llm_conf(req.host, req.port, req.language_model, None, origin_call="http") + if not 200 <= res < 300: + _restore_settings(llm_settings, original_values) + return generate_response(RAGResponse(status_code=res, message="Missing Value")) + except Exception: + _restore_settings(llm_settings, original_values) + raise @router.post("/config/embedding", status_code=status.HTTP_201_CREATED) def embedding_config_api(req: LLMConfigRequest): - original_values = _snapshot_settings(llm_settings, _EMBEDDING_CONFIG_FIELDS) - try: - llm_settings.embedding_type = req.llm_type + with runtime_config_lock: + original_values = _snapshot_settings(llm_settings, _EMBEDDING_CONFIG_FIELDS) + try: + llm_settings.embedding_type = req.llm_type - if req.llm_type in ("openai", "litellm"): - res = apply_embedding_conf(req.api_key, req.api_base, req.language_model, origin_call="http") - else: - res = apply_embedding_conf(req.host, req.port, req.language_model, origin_call="http") - return generate_response(RAGResponse(status_code=res, message="Missing Value")) - except Exception: - _restore_settings(llm_settings, original_values) - raise + if req.llm_type in ("openai", "litellm"): + res = apply_embedding_conf(req.api_key, req.api_base, req.language_model, origin_call="http") + else: + res = apply_embedding_conf(req.host, req.port, req.language_model, origin_call="http") + if not 200 <= res < 300: + _restore_settings(llm_settings, original_values) + return generate_response(RAGResponse(status_code=res, message="Missing Value")) + except Exception: + _restore_settings(llm_settings, original_values) + raise @router.post("/config/rerank", status_code=status.HTTP_201_CREATED) def rerank_config_api(req: RerankerConfigRequest): - original_values = _snapshot_settings(llm_settings, _RERANKER_CONFIG_FIELDS) - try: - llm_settings.reranker_type = req.reranker_type + with runtime_config_lock: + original_values = _snapshot_settings(llm_settings, _RERANKER_CONFIG_FIELDS) + try: + llm_settings.reranker_type = req.reranker_type - if req.reranker_type == "cohere": - res = apply_reranker_conf(req.api_key, req.reranker_model, req.cohere_base_url, origin_call="http") - elif req.reranker_type == "siliconflow": - res = apply_reranker_conf(req.api_key, req.reranker_model, None, origin_call="http") - else: - res = status.HTTP_501_NOT_IMPLEMENTED - return generate_response(RAGResponse(status_code=res, message="Missing Value")) - except Exception: - _restore_settings(llm_settings, original_values) - raise + if req.reranker_type == "cohere": + res = apply_reranker_conf(req.api_key, req.reranker_model, req.cohere_base_url, origin_call="http") + else: + res = apply_reranker_conf(req.api_key, req.reranker_model, None, origin_call="http") + if not 200 <= res < 300: + _restore_settings(llm_settings, original_values) + return generate_response(RAGResponse(status_code=res, message="Missing Value")) + except Exception: + _restore_settings(llm_settings, original_values) + raise @router.post("/text2gremlin", status_code=status.HTTP_200_OK) def text2gremlin_api(req: GremlinGenerateRequest): try: - with request_graph_config(req): - # Basic parameter validation: empty query => 400 - if not req.query or not str(req.query).strip(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Query must not be empty.", - ) + # Basic parameter validation: empty query => 400 + if not req.query or not str(req.query).strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Query must not be empty.", + ) - output_types_str_list = None - if req.output_types: - output_types_str_list = [ot.value for ot in req.output_types] + output_types_str_list = None + if req.output_types: + output_types_str_list = [ot.value for ot in req.output_types] - response_dict = gremlin_generate_selective_func( - inp=req.query, - example_num=req.example_num, - schema_input=huge_settings.graph_name, - gremlin_prompt_input=req.gremlin_prompt, - requested_outputs=output_types_str_list, - ) - return response_dict + graph_connection = _request_graph_connection(req) + response_dict = gremlin_generate_selective_func( + inp=req.query, + example_num=req.example_num, + schema_input=graph_connection["graph"], + gremlin_prompt_input=req.gremlin_prompt, + requested_outputs=output_types_str_list, + graph_client_config=graph_connection, + ) + return response_dict except HTTPException as e: raise e except Exception as e: diff --git a/hugegraph-llm/src/hugegraph_llm/api/thin_api.py b/hugegraph-llm/src/hugegraph_llm/api/thin_api.py index 6f6629545..20ecbb1dc 100644 --- a/hugegraph-llm/src/hugegraph_llm/api/thin_api.py +++ b/hugegraph-llm/src/hugegraph_llm/api/thin_api.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. +import os import time from typing import Any from uuid import uuid4 @@ -27,6 +28,7 @@ VidEmbeddingsRefreshRequest, ) from hugegraph_llm.api.models.rag_response import ThinAPIResponse +from hugegraph_llm.config import admin_settings, prompt from hugegraph_llm.flows import FlowName from hugegraph_llm.flows.scheduler import SchedulerSingleton from hugegraph_llm.utils.log import log @@ -77,10 +79,10 @@ def _envelope_err( } -def _wrap_flow_call(flow_name: FlowName, *args: Any) -> dict[str, Any]: +def _wrap_flow_call(flow_name: FlowName, *args: Any, **kwargs: Any) -> dict[str, Any]: start = time.perf_counter() try: - result = SchedulerSingleton.get_instance().schedule_flow(flow_name, *args) + result = SchedulerSingleton.get_instance().schedule_flow(flow_name, *args, **kwargs) envelope = _envelope_ok(result) envelope["meta"]["duration_ms"] = (time.perf_counter() - start) * 1000.0 return envelope @@ -95,25 +97,51 @@ def _wrap_flow_call(flow_name: FlowName, *args: Any) -> dict[str, Any]: return envelope +def _thin_write_disabled() -> dict[str, Any] | None: + """Keep legacy write endpoints fail-closed unless authenticated and enabled.""" + login_enabled = str(admin_settings.enable_login).strip().lower() == "true" + configured_write_flag = os.getenv("HUGEGRAPH_LLM_ENABLE_THIN_WRITES", "false") + writes_enabled = str(configured_write_flag).strip().lower() == "true" + if login_enabled and writes_enabled: + return None + return _envelope_err( + "FEATURE_DISABLED", + "Legacy Thin API writes are disabled.", + suggestion=( + "Set ENABLE_LOGIN=true and HUGEGRAPH_LLM_ENABLE_THIN_WRITES=true " + "only for authenticated internal deployments." + ), + details={ + "requires_login": True, + "enable_env": "HUGEGRAPH_LLM_ENABLE_THIN_WRITES", + }, + ) + + @thin_router.post("/graph-extract", status_code=status.HTTP_200_OK, response_model=ThinAPIResponse) def graph_extract_api(req: GraphExtractRequest): return _wrap_flow_call( FlowName.GRAPH_EXTRACT, req.graph_schema, req.text, - req.example_prompt, + prompt.extract_graph_prompt if req.example_prompt is None else req.example_prompt, "property_graph", - req.language, + split_type="document", + language=req.language, ) @thin_router.post("/graph-import", status_code=status.HTTP_200_OK, response_model=ThinAPIResponse) def graph_import_api(req: GraphImportRequest): + if blocked := _thin_write_disabled(): + return blocked return _wrap_flow_call(FlowName.IMPORT_GRAPH_DATA, req.data, req.graph_schema) @thin_router.post("/vid-embeddings/refresh", status_code=status.HTTP_200_OK, response_model=ThinAPIResponse) def vid_embeddings_refresh_api(_req: VidEmbeddingsRefreshRequest): + if blocked := _thin_write_disabled(): + return blocked return _wrap_flow_call(FlowName.UPDATE_VID_EMBEDDINGS) diff --git a/hugegraph-llm/src/hugegraph_llm/config/__init__.py b/hugegraph-llm/src/hugegraph_llm/config/__init__.py index 43efb0ab3..bf6906297 100644 --- a/hugegraph-llm/src/hugegraph_llm/config/__init__.py +++ b/hugegraph-llm/src/hugegraph_llm/config/__init__.py @@ -16,9 +16,17 @@ # under the License. -__all__ = ["huge_settings", "admin_settings", "llm_settings", "resource_path", "index_settings"] +__all__ = [ + "huge_settings", + "admin_settings", + "llm_settings", + "resource_path", + "index_settings", + "runtime_config_lock", +] import os +import threading from .admin_config import AdminConfig from .hugegraph_config import HugeGraphConfig @@ -33,6 +41,7 @@ huge_settings = HugeGraphConfig() admin_settings = AdminConfig() index_settings = IndexConfig() +runtime_config_lock = threading.RLock() package_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) resource_path = os.path.join(package_path, "resources") diff --git a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py index 0bff2ced6..526b23641 100644 --- a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py +++ b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py @@ -16,6 +16,7 @@ # under the License. import argparse +import ipaddress import os import gradio as gr @@ -58,11 +59,20 @@ def authenticate(credentials: HTTPAuthorizationCredentials = Depends(sec)): raise HTTPException( status_code=401, - detail=f"Invalid token {credentials.credentials}, please contact the admin", + detail="Invalid authentication token, please contact the admin", headers={"WWW-Authenticate": "Bearer"}, ) +def create_api_router() -> tuple[APIRouter, bool]: + """Build the production API router, including its authentication boundary.""" + auth_enabled = admin_settings.enable_login.lower() == "true" + log.info("(Status) Authentication is %s now.", "enabled" if auth_enabled else "disabled") + api_router = APIRouter(dependencies=[Depends(authenticate)] if auth_enabled else []) + api_router.include_router(thin_router) + return api_router, auth_enabled + + # pylint: disable=C0301 def init_rag_ui() -> gr.Interface: with gr.Blocks( @@ -163,9 +173,7 @@ def create_app(): # we don't need to manually check the env now # settings.check_env() prompt.update_yaml_file() - auth_enabled = admin_settings.enable_login.lower() == "true" - log.info("(Status) Authentication is %s now.", "enabled" if auth_enabled else "disabled") - api_auth = APIRouter(dependencies=[Depends(authenticate)] if auth_enabled else []) + api_auth, auth_enabled = create_api_router() hugegraph_llm = init_rag_ui() @@ -180,7 +188,6 @@ def create_app(): gremlin_generate_selective, ) admin_http_api(api_auth, log_stream) - api_auth.include_router(thin_router) graph_extract_http_api(api_auth) app.include_router(api_auth) @@ -196,11 +203,31 @@ def create_app(): return app -if __name__ == "__main__": +def is_loopback_host(host: str) -> bool: + normalized_host = host.strip() + if normalized_host.lower() == "localhost": + return True + + try: + return ipaddress.ip_address(normalized_host).is_loopback + except ValueError: + return False + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser() - parser.add_argument("--host", type=str, default="0.0.0.0", help="host") + parser.add_argument("--host", type=str, default="127.0.0.1", help="host") parser.add_argument("--port", type=int, default=8001, help="port") - args = parser.parse_args() + return parser.parse_args(argv) + + +def run_server(args: argparse.Namespace) -> None: + if not is_loopback_host(args.host): + log.warning( + "SECURITY WARNING: The HugeGraph RAG HTTP API has no unified authentication. " + "Binding to a non-loopback host can expose it to other machines. Configure reverse proxy authentication, " + "a firewall, or a trusted network before continuing." + ) uvicorn.run( "hugegraph_llm.demo.rag_demo.app:create_app", @@ -209,3 +236,11 @@ def create_app(): factory=True, reload=os.getenv("HG_DEV_RELOAD") == "1", ) + + +def main(argv: list[str] | None = None) -> None: + run_server(parse_args(argv)) + + +if __name__ == "__main__": + main() diff --git a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/configs_block.py b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/configs_block.py index 0b51aa6c6..53e93c129 100644 --- a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/configs_block.py +++ b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/configs_block.py @@ -16,7 +16,7 @@ # under the License. import json -from functools import partial +from functools import partial, wraps from typing import Optional import gradio as gr @@ -24,7 +24,7 @@ from dotenv import dotenv_values from requests.auth import HTTPBasicAuth -from hugegraph_llm.config import huge_settings, index_settings, llm_settings +from hugegraph_llm.config import huge_settings, index_settings, llm_settings, runtime_config_lock from hugegraph_llm.config.models.base_config import env_path from hugegraph_llm.models.embeddings.litellm import LiteLLMEmbedding from hugegraph_llm.models.llms.litellm import LiteLLMClient @@ -33,6 +33,37 @@ current_llm = "chat" +def _restore_config(settings, values): + for field, value in values.items(): + setattr(settings, field, value) + + +def _transactional_config_update(settings_getter): + """Serialize, persist on success, and roll back failed config updates.""" + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + settings = settings_getter() + with runtime_config_lock: + original_values = settings.model_dump() + try: + status_code = func(*args, **kwargs) + if not 200 <= status_code < 300: + _restore_config(settings, original_values) + return status_code + settings.update_env() + except Exception: + _restore_config(settings, original_values) + raise + gr.Info("Configured!") + return status_code + + return wrapper + + return decorator + + def test_litellm_embedding(api_key, api_base, model_name) -> int: llm_client = LiteLLMEmbedding( api_key=api_key, @@ -179,6 +210,7 @@ def apply_vector_engine_backend( # pylint: disable=too-many-branches return status_code +@_transactional_config_update(lambda: llm_settings) def apply_embedding_config(arg1, arg2, arg3, origin_call=None) -> int: status_code = -1 embedding_option = llm_settings.embedding_type @@ -200,11 +232,10 @@ def apply_embedding_config(arg1, arg2, arg3, origin_call=None) -> int: llm_settings.litellm_embedding_api_base = arg2 llm_settings.litellm_embedding_model = arg3 status_code = test_litellm_embedding(arg1, arg2, arg3) - llm_settings.update_env() - gr.Info("Configured!") return status_code +@_transactional_config_update(lambda: llm_settings) def apply_reranker_config( reranker_api_key: Optional[str] = None, reranker_model: Optional[str] = None, @@ -238,11 +269,10 @@ def apply_reranker_config( headers=headers, origin_call=origin_call, ) - llm_settings.update_env() - gr.Info("Configured!") return status_code +@_transactional_config_update(lambda: huge_settings) def apply_graph_config(url, name, user, pwd, gs, origin_call=None) -> int: # Add URL prefix automatically to improve the user experience if url and not (url.startswith("http://") or url.startswith("https://")): @@ -261,10 +291,10 @@ def apply_graph_config(url, name, user, pwd, gs, origin_call=None) -> int: auth = HTTPBasicAuth(user, pwd) # for http api return status response = test_api_connection(test_url, auth=auth, origin_call=origin_call) - huge_settings.update_env() return response +@_transactional_config_update(lambda: llm_settings) def apply_llm_config( current_llm_config, api_key_or_host, @@ -307,8 +337,6 @@ def apply_llm_config( status_code = test_litellm_chat(api_key_or_host, api_base_or_port, model_name, int(max_tokens)) - gr.Info("Configured!") - llm_settings.update_env() return status_code diff --git a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/text2gremlin_block.py b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/text2gremlin_block.py index 8c318cb6c..dde6e4909 100644 --- a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/text2gremlin_block.py +++ b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/text2gremlin_block.py @@ -341,6 +341,7 @@ def gremlin_generate_selective( schema_input: str, gremlin_prompt_input: str, requested_outputs: Optional[List[str]] = None, + graph_client_config: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: response_dict = SchedulerSingleton.get_instance().schedule_flow( FlowName.TEXT2GREMLIN, @@ -349,6 +350,7 @@ def gremlin_generate_selective( schema_input, gremlin_prompt_input, requested_outputs, + graph_client_config, ) return response_dict diff --git a/hugegraph-llm/src/hugegraph_llm/flows/text2gremlin.py b/hugegraph-llm/src/hugegraph_llm/flows/text2gremlin.py index 10818b128..d41862cd6 100644 --- a/hugegraph-llm/src/hugegraph_llm/flows/text2gremlin.py +++ b/hugegraph-llm/src/hugegraph_llm/flows/text2gremlin.py @@ -40,6 +40,7 @@ def prepare( schema_input: str, gremlin_prompt_input: Optional[str], requested_outputs: Optional[List[str]], + graph_client_config: Optional[Dict[str, Any]] = None, **kwargs, ): # sanitize example_num to [0,10], fallback to 2 if invalid @@ -67,6 +68,7 @@ def prepare( prepared_input.schema = schema_input prepared_input.gremlin_prompt = gremlin_prompt_input prepared_input.requested_outputs = req + prepared_input.graph_client_config = graph_client_config def build_flow( self, @@ -75,6 +77,7 @@ def build_flow( schema_input: str, gremlin_prompt_input: Optional[str] = None, requested_outputs: Optional[List[str]] = None, + graph_client_config: Optional[Dict[str, Any]] = None, **kwargs, ): pipeline = GPipeline() @@ -87,6 +90,7 @@ def build_flow( schema_input=schema_input, gremlin_prompt_input=gremlin_prompt_input, requested_outputs=requested_outputs, + graph_client_config=graph_client_config, ) pipeline.createGParam(prepared_input, "wkflow_input") diff --git a/hugegraph-llm/src/hugegraph_llm/nodes/hugegraph_node/gremlin_execute.py b/hugegraph-llm/src/hugegraph_llm/nodes/hugegraph_node/gremlin_execute.py index 6a96062c5..4527fc0c6 100644 --- a/hugegraph-llm/src/hugegraph_llm/nodes/hugegraph_node/gremlin_execute.py +++ b/hugegraph-llm/src/hugegraph_llm/nodes/hugegraph_node/gremlin_execute.py @@ -46,11 +46,15 @@ def operator_schedule(self, data_json: Dict[str, Any]): tmpl_q = data_json.get("result", "") raw_q = data_json.get("raw_result", "") + graph_connection = getattr(self.wk_input, "graph_client_config", None) if need_template: try: safe_q = _ensure_limit(tmpl_q) - data_json["template_exec_res"] = run_gremlin_query(query=safe_q) + data_json["template_exec_res"] = run_gremlin_query( + query=safe_q, + connection=graph_connection, + ) except Exception as exc: # pylint: disable=broad-except data_json["template_exec_res"] = f"{exc}" else: @@ -59,7 +63,10 @@ def operator_schedule(self, data_json: Dict[str, Any]): if need_raw: try: safe_q = _ensure_limit(raw_q) - data_json["raw_exec_res"] = run_gremlin_query(query=safe_q) + data_json["raw_exec_res"] = run_gremlin_query( + query=safe_q, + connection=graph_connection, + ) except Exception as exc: # pylint: disable=broad-except data_json["raw_exec_res"] = f"{exc}" else: diff --git a/hugegraph-llm/src/hugegraph_llm/utils/hugegraph_utils.py b/hugegraph-llm/src/hugegraph_llm/utils/hugegraph_utils.py index a151110f0..f7cac5419 100644 --- a/hugegraph-llm/src/hugegraph_llm/utils/hugegraph_utils.py +++ b/hugegraph-llm/src/hugegraph_llm/utils/hugegraph_utils.py @@ -19,6 +19,7 @@ import os import shutil from datetime import datetime +from urllib.parse import urlsplit import requests from pyhugegraph.client import PyHugeClient @@ -33,21 +34,61 @@ BACKUP_DIR = str(os.path.join(resource_path, "backup-graph-data-4020", huge_settings.graph_name)) -def run_gremlin_query(query, fmt=True): - res = get_hg_client().gremlin().exec(query) +def run_gremlin_query(query, fmt=True, connection=None): + res = get_hg_client(connection=connection).gremlin().exec(query) return json.dumps(res, indent=4, ensure_ascii=False) if fmt else res -def get_hg_client(): +def get_hg_client(connection=None): + if connection is None: + connection = { + "url": huge_settings.graph_url, + "graph": huge_settings.graph_name, + "user": huge_settings.graph_user, + "pwd": huge_settings.graph_pwd, + "graphspace": huge_settings.graph_space, + } + configured = _normalize_graph_url(huge_settings.graph_url) + requested = _normalize_graph_url(connection["url"]) + if requested != configured: + raise ValueError("Request graph URL must match the configured HugeGraph URL") return PyHugeClient( - url=huge_settings.graph_url, - graph=huge_settings.graph_name, - user=huge_settings.graph_user, - pwd=huge_settings.graph_pwd, - graphspace=huge_settings.graph_space, + url=requested, + graph=connection["graph"], + user=connection["user"], + pwd=connection["pwd"], + graphspace=connection["graphspace"], ) +def _normalize_graph_url(url): + raw_url = str(url).strip() + if "://" not in raw_url: + raw_url = f"http://{raw_url}" + try: + parsed = urlsplit(raw_url) + if ( + parsed.scheme.lower() not in {"http", "https"} + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ValueError("not an absolute HTTP(S) URL") + hostname = (parsed.hostname or "").lower() + if not hostname: + raise ValueError("missing hostname") + port = parsed.port + except ValueError as exc: + raise ValueError("Graph URL must be a valid HTTP(S) URL") from exc + default_port = 80 if parsed.scheme.lower() == "http" else 443 + authority = f"[{hostname}]" if ":" in hostname else hostname + if port is not None and port != default_port: + authority += f":{port}" + return f"{parsed.scheme.lower()}://{authority}{parsed.path.rstrip('/')}" + + def init_hg_test_data(): client = get_hg_client() client.graphs().clear_graph_all_data() diff --git a/hugegraph-llm/src/tests/api/test_rag_api.py b/hugegraph-llm/src/tests/api/test_rag_api.py index ee03e8358..35fc8c432 100644 --- a/hugegraph-llm/src/tests/api/test_rag_api.py +++ b/hugegraph-llm/src/tests/api/test_rag_api.py @@ -15,6 +15,8 @@ # specific language governing permissions and limitations # under the License. +import threading +import time from unittest.mock import Mock import pytest @@ -286,26 +288,37 @@ def test_rag_api_invalid_request_body_returns_validation_shape(): assert response.json()["detail"][0]["loc"][-1] == "query" -def test_rag_client_config_updates_only_explicit_graph_fields(monkeypatch): +def test_rag_rejects_request_graph_config_without_mutating_globals(monkeypatch): monkeypatch.setattr(huge_settings, "graph_url", "http://original:8080") monkeypatch.setattr(huge_settings, "graph_name", "original_graph") monkeypatch.setattr(huge_settings, "graph_user", "original_user") monkeypatch.setattr(huge_settings, "graph_pwd", "original_pwd") monkeypatch.setattr(huge_settings, "graph_space", "original_space") - observed_settings = {} + client, callbacks = _make_test_client() - def rag_answer_func(**_kwargs): - observed_settings["graph_url"] = huge_settings.graph_url - observed_settings["graph_name"] = huge_settings.graph_name - observed_settings["graph_user"] = huge_settings.graph_user - observed_settings["graph_pwd"] = huge_settings.graph_pwd - observed_settings["graph_space"] = huge_settings.graph_space - return ("raw", "vector", "graph", "graph_vector") + response = client.post( + "/rag", + json={ + "query": "find vertices", + "client_config": { + "url": "http://override:8080", + }, + }, + ) - client, callbacks = _make_test_client(rag_answer_func=Mock(side_effect=rag_answer_func)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + callbacks["rag_answer_func"].assert_not_called() + assert huge_settings.graph_url == "http://original:8080" + assert huge_settings.graph_name == "original_graph" + assert huge_settings.graph_user == "original_user" + assert huge_settings.graph_pwd == "original_pwd" + assert huge_settings.graph_space == "original_space" + +def test_graph_rag_rejects_request_graph_config(): + client, callbacks = _make_test_client() response = client.post( - "/rag", + "/rag/graph", json={ "query": "find vertices", "client_config": { @@ -314,14 +327,40 @@ def rag_answer_func(**_kwargs): }, ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + callbacks["graph_rag_recall_func"].assert_not_called() + + +def test_text2gremlin_passes_configured_graph_target_without_mutating_globals(monkeypatch): + monkeypatch.setattr(huge_settings, "graph_url", "http://original:8080") + monkeypatch.setattr(huge_settings, "graph_name", "original_graph") + monkeypatch.setattr(huge_settings, "graph_user", "original_user") + monkeypatch.setattr(huge_settings, "graph_pwd", "original_pwd") + monkeypatch.setattr(huge_settings, "graph_space", "original_space") + callback = Mock(return_value={"template_gremlin": "g.V()"}) + client, _ = _make_test_client(gremlin_generate_selective_func=callback) + + response = client.post( + "/text2gremlin", + json={ + "query": "find vertices", + "client_config": { + "graph": "original_graph", + "gs": "original_space", + }, + }, + ) + assert response.status_code == status.HTTP_200_OK - callbacks["rag_answer_func"].assert_called_once() - assert observed_settings == { - "graph_url": "http://override:8080", - "graph_name": "original_graph", - "graph_user": "original_user", - "graph_pwd": "original_pwd", - "graph_space": "original_space", + callback.assert_called_once() + call = callback.call_args.kwargs + assert call["schema_input"] == "original_graph" + assert call["graph_client_config"] == { + "url": "http://original:8080", + "graph": "original_graph", + "user": "original_user", + "pwd": "original_pwd", + "graphspace": "original_space", } assert huge_settings.graph_url == "http://original:8080" assert huge_settings.graph_name == "original_graph" @@ -330,6 +369,83 @@ def rag_answer_func(**_kwargs): assert huge_settings.graph_space == "original_space" +@pytest.mark.parametrize( + "client_config, expected_field", + [ + ({"graph": "other_graph"}, "graph"), + ({"gs": "other_space"}, "gs"), + ({"user": "other_user"}, "user"), + ({"pwd": "other_pwd"}, "pwd"), + ], +) +def test_text2gremlin_rejects_changed_graph_target(monkeypatch, client_config, expected_field): + monkeypatch.setattr(huge_settings, "graph_name", "original_graph") + monkeypatch.setattr(huge_settings, "graph_space", "original_space") + callback = Mock() + client, _ = _make_test_client(gremlin_generate_selective_func=callback) + + response = client.post( + "/text2gremlin", + json={"query": "find vertices", "client_config": client_config}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert expected_field in response.json()["detail"] + callback.assert_not_called() + + +def test_text2gremlin_rejects_changed_graph_url(monkeypatch): + monkeypatch.setattr(huge_settings, "graph_url", "http://original:8080") + callback = Mock() + client, _ = _make_test_client(gremlin_generate_selective_func=callback) + + response = client.post( + "/text2gremlin", + json={ + "query": "find vertices", + "client_config": {"url": "http://attacker:8080"}, + }, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "must match" in response.json()["detail"] + callback.assert_not_called() + + +def test_text2gremlin_accepts_equivalent_graph_url(monkeypatch): + monkeypatch.setattr(huge_settings, "graph_url", "127.0.0.1:8080") + callback = Mock(return_value={"template_gremlin": "g.V()"}) + client, _ = _make_test_client(gremlin_generate_selective_func=callback) + + response = client.post( + "/text2gremlin", + json={ + "query": "find vertices", + "client_config": {"url": "http://127.0.0.1:8080/"}, + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert callback.call_args.kwargs["graph_client_config"]["url"] == "127.0.0.1:8080" + + +def test_text2gremlin_rejects_malformed_graph_url(monkeypatch): + monkeypatch.setattr(huge_settings, "graph_url", "http://original:8080") + callback = Mock() + client, _ = _make_test_client(gremlin_generate_selective_func=callback) + + response = client.post( + "/text2gremlin", + json={ + "query": "find vertices", + "client_config": {"url": "http://[::1"}, + }, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + callback.assert_not_called() + + def test_llm_config_rejects_unsupported_provider_without_mutating(monkeypatch): monkeypatch.setattr(llm_settings, "chat_llm_type", "openai") client, callbacks = _make_test_client() @@ -408,6 +524,70 @@ def failed_apply_reranker_conf(api_key, reranker_model, cohere_base_url, **_kwar callbacks["apply_reranker_conf"].assert_called_once() +def test_llm_config_rolls_back_on_failed_status(monkeypatch): + monkeypatch.setattr(llm_settings, "chat_llm_type", "ollama/local") + monkeypatch.setattr(llm_settings, "extract_llm_type", "ollama/local") + monkeypatch.setattr(llm_settings, "text2gql_llm_type", "ollama/local") + client, _ = _make_test_client(apply_llm_conf=Mock(return_value=status.HTTP_500_INTERNAL_SERVER_ERROR)) + + response = client.post( + "/config/llm", + json={ + "llm_type": "openai", + "api_key": "new-key", + "api_base": "https://api.example.test", + "language_model": "model", + "max_tokens": "1024", + }, + ) + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert llm_settings.chat_llm_type == "ollama/local" + assert llm_settings.extract_llm_type == "ollama/local" + assert llm_settings.text2gql_llm_type == "ollama/local" + + +def test_llm_config_updates_are_serialized(): + first_entered = threading.Event() + release_first = threading.Event() + second_entered = threading.Event() + + def apply_llm_conf(api_key, *_args, **_kwargs): + if api_key == "first-key": + first_entered.set() + assert release_first.wait(timeout=2) + else: + second_entered.set() + return status.HTTP_200_OK + + client, _ = _make_test_client(apply_llm_conf=apply_llm_conf) + payload = { + "llm_type": "openai", + "api_base": "https://api.example.test", + "language_model": "model", + "max_tokens": "1024", + } + responses = {} + + def configure(name): + responses[name] = client.post("/config/llm", json={**payload, "api_key": f"{name}-key"}) + + first = threading.Thread(target=configure, args=("first",)) + second = threading.Thread(target=configure, args=("second",)) + first.start() + assert first_entered.wait(timeout=2) + second.start() + time.sleep(0.05) + assert not second_entered.is_set() + release_first.set() + first.join(timeout=2) + second.join(timeout=2) + + assert second_entered.is_set() + assert responses["first"].status_code == status.HTTP_201_CREATED + assert responses["second"].status_code == status.HTTP_201_CREATED + + def test_text2gremlin_callback_exception_returns_stable_response(): client, _ = _make_test_client(gremlin_generate_selective_func=Mock(side_effect=RuntimeError("callback failed"))) diff --git a/hugegraph-llm/src/tests/api/test_thin_api.py b/hugegraph-llm/src/tests/api/test_thin_api.py index db4c79c77..7b32e3cbf 100644 --- a/hugegraph-llm/src/tests/api/test_thin_api.py +++ b/hugegraph-llm/src/tests/api/test_thin_api.py @@ -19,12 +19,17 @@ import warnings from unittest.mock import Mock +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from hugegraph_llm.api.models.rag_response import ThinAPIError, ThinAPIMeta, ThinAPIResponse from hugegraph_llm.api.thin_api import thin_router +from hugegraph_llm.config import prompt +from hugegraph_llm.demo.rag_demo import app as rag_demo_app from hugegraph_llm.flows import FlowName +from hugegraph_llm.flows.graph_extract import GraphExtractFlow +from hugegraph_llm.state.ai_state import WkFlowInput def _client(monkeypatch, scheduler): @@ -37,6 +42,18 @@ def _client(monkeypatch, scheduler): return TestClient(app) +def _production_router_client(monkeypatch, scheduler): + monkeypatch.setattr( + "hugegraph_llm.api.thin_api.SchedulerSingleton.get_instance", + Mock(return_value=scheduler), + ) + app = FastAPI() + api_router, auth_enabled = rag_demo_app.create_api_router() + assert auth_enabled is True + app.include_router(api_router) + return TestClient(app) + + def _assert_envelope(response_json: dict, expected_ok: bool): assert response_json["ok"] is expected_ok assert "data" in response_json @@ -79,13 +96,64 @@ def test_graph_extract_api_calls_flow(monkeypatch): "Alice knows Bob.", "extract graph", "property_graph", - "en", + split_type="document", + language="en", + ) + + +def test_graph_extract_api_arguments_match_real_prepare_contract(monkeypatch): + class FlowContractScheduler: + prepared_input = None + + def schedule_flow(self, flow_name, *args, **kwargs): + assert flow_name is FlowName.GRAPH_EXTRACT + self.prepared_input = WkFlowInput() + GraphExtractFlow().prepare(self.prepared_input, *args, **kwargs) + return '{"vertices": [], "edges": []}' + + scheduler = FlowContractScheduler() + client = _client(monkeypatch, scheduler) + + response = client.post( + "/graph-extract", + json={"text": "Alice knows Bob.", "schema": "{}", "language": "en"}, + ) + + assert response.status_code == 200 + _assert_envelope(response.json(), expected_ok=True) + assert scheduler.prepared_input is not None + assert scheduler.prepared_input.split_type == "document" + assert scheduler.prepared_input.language == "en" + assert scheduler.prepared_input.example_prompt == prompt.extract_graph_prompt + + +def test_graph_extract_api_preserves_explicit_empty_prompt(monkeypatch): + scheduler = Mock() + scheduler.schedule_flow.return_value = '{"vertices": [], "edges": []}' + client = _client(monkeypatch, scheduler) + + response = client.post( + "/graph-extract", + json={"text": "Alice knows Bob.", "schema": "{}", "example_prompt": ""}, + ) + + assert response.status_code == 200 + scheduler.schedule_flow.assert_called_once_with( + FlowName.GRAPH_EXTRACT, + "{}", + "Alice knows Bob.", + "", + "property_graph", + split_type="document", + language="zh", ) def test_graph_import_api_calls_flow(monkeypatch): scheduler = Mock() scheduler.schedule_flow.return_value = '{"imported": true}' + monkeypatch.setattr("hugegraph_llm.api.thin_api.admin_settings.enable_login", "True") + monkeypatch.setenv("HUGEGRAPH_LLM_ENABLE_THIN_WRITES", "true") client = _client(monkeypatch, scheduler) response = client.post("/graph-import", json={"data": "{}", "schema": None}) @@ -117,6 +185,8 @@ def test_thin_api_request_models_do_not_emit_schema_shadow_warning(): def test_vid_embeddings_refresh_api_calls_flow(monkeypatch): scheduler = Mock() scheduler.schedule_flow.return_value = "Removed 0 vectors, added 1 vectors." + monkeypatch.setattr("hugegraph_llm.api.thin_api.admin_settings.enable_login", "True") + monkeypatch.setenv("HUGEGRAPH_LLM_ENABLE_THIN_WRITES", "true") client = _client(monkeypatch, scheduler) response = client.post("/vid-embeddings/refresh", json={}) @@ -128,6 +198,98 @@ def test_vid_embeddings_refresh_api_calls_flow(monkeypatch): scheduler.schedule_flow.assert_called_once_with(FlowName.UPDATE_VID_EMBEDDINGS) +@pytest.mark.parametrize( + ("path", "payload"), + [ + ("/graph-import", {"data": "{}", "schema": None}), + ("/vid-embeddings/refresh", {}), + ], +) +def test_thin_write_endpoints_are_disabled_by_default(monkeypatch, path, payload): + scheduler = Mock() + monkeypatch.delenv("HUGEGRAPH_LLM_ENABLE_THIN_WRITES", raising=False) + monkeypatch.setattr("hugegraph_llm.api.thin_api.admin_settings.enable_login", "False") + client = _client(monkeypatch, scheduler) + + response = client.post(path, json=payload) + + assert response.status_code == 200 + body = response.json() + _assert_envelope(body, expected_ok=False) + assert body["error"]["type"] == "FEATURE_DISABLED" + scheduler.schedule_flow.assert_not_called() + + +def test_thin_writes_remain_disabled_without_login(monkeypatch): + scheduler = Mock() + monkeypatch.setattr("hugegraph_llm.api.thin_api.admin_settings.enable_login", "False") + monkeypatch.setenv("HUGEGRAPH_LLM_ENABLE_THIN_WRITES", "true") + client = _client(monkeypatch, scheduler) + + response = client.post("/graph-import", json={"data": "{}", "schema": None}) + + assert response.status_code == 200 + assert response.json()["error"]["type"] == "FEATURE_DISABLED" + scheduler.schedule_flow.assert_not_called() + + +def test_thin_writes_remain_disabled_without_write_flag(monkeypatch): + scheduler = Mock() + monkeypatch.setattr("hugegraph_llm.api.thin_api.admin_settings.enable_login", "True") + monkeypatch.delenv("HUGEGRAPH_LLM_ENABLE_THIN_WRITES", raising=False) + client = _client(monkeypatch, scheduler) + + response = client.post("/graph-import", json={"data": "{}", "schema": None}) + + assert response.status_code == 200 + assert response.json()["error"]["type"] == "FEATURE_DISABLED" + scheduler.schedule_flow.assert_not_called() + + +@pytest.mark.parametrize( + ("path", "payload", "expected_flow"), + [ + ( + "/graph-import", + {"data": "{}", "schema": None}, + FlowName.IMPORT_GRAPH_DATA, + ), + ( + "/vid-embeddings/refresh", + {}, + FlowName.UPDATE_VID_EMBEDDINGS, + ), + ], +) +def test_production_router_requires_bearer_for_enabled_thin_writes(monkeypatch, path, payload, expected_flow): + scheduler = Mock() + scheduler.schedule_flow.return_value = {"status": "ok"} + monkeypatch.setattr(rag_demo_app.admin_settings, "enable_login", "True") + monkeypatch.setattr(rag_demo_app.admin_settings, "user_token", "internal-token") + monkeypatch.setenv("HUGEGRAPH_LLM_ENABLE_THIN_WRITES", "true") + client = _production_router_client(monkeypatch, scheduler) + + missing = client.post(path, json=payload) + invalid = client.post( + path, + json=payload, + headers={"Authorization": "Bearer wrong-token"}, + ) + accepted = client.post( + path, + json=payload, + headers={"Authorization": "Bearer internal-token"}, + ) + + assert missing.status_code in {401, 403} + assert invalid.status_code == 401 + assert "wrong-token" not in invalid.text + assert accepted.status_code == 200 + assert accepted.json()["ok"] is True + assert scheduler.schedule_flow.call_count == 1 + assert scheduler.schedule_flow.call_args.args[0] == expected_flow + + def test_graph_index_info_api_calls_flow(monkeypatch): scheduler = Mock() scheduler.schedule_flow.return_value = '{"vertices": 1}' diff --git a/hugegraph-llm/src/tests/config/test_config.py b/hugegraph-llm/src/tests/config/test_config.py index 339207309..a5b4a6063 100644 --- a/hugegraph-llm/src/tests/config/test_config.py +++ b/hugegraph-llm/src/tests/config/test_config.py @@ -21,7 +21,7 @@ from pathlib import Path from tempfile import TemporaryDirectory from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -93,3 +93,51 @@ def test_demo_config_block_uses_shared_env_path(self): from hugegraph_llm.demo.rag_demo import configs_block self.assertEqual(configs_block.env_path, base_config.env_path) + + +def test_failed_ui_config_update_restores_memory_without_persisting(monkeypatch): + from hugegraph_llm.config import llm_settings + from hugegraph_llm.demo.rag_demo import configs_block + + monkeypatch.setattr(llm_settings, "embedding_type", "openai") + monkeypatch.setattr(llm_settings, "openai_embedding_api_key", "old-key") + monkeypatch.setattr(llm_settings, "openai_embedding_api_base", "https://old.example") + monkeypatch.setattr(llm_settings, "openai_embedding_model", "old-model") + update_env = Mock() + monkeypatch.setattr(type(llm_settings), "update_env", update_env) + monkeypatch.setattr(configs_block, "test_api_connection", Mock(return_value=500)) + monkeypatch.setattr(configs_block.gr, "Info", Mock()) + + result = configs_block.apply_embedding_config("new-key", "https://new.example", "new-model", origin_call="http") + + assert result == 500 + assert llm_settings.openai_embedding_api_key == "old-key" + assert llm_settings.openai_embedding_api_base == "https://old.example" + assert llm_settings.openai_embedding_model == "old-model" + update_env.assert_not_called() + + +def test_successful_ui_config_update_persists(monkeypatch): + from hugegraph_llm.config import llm_settings + from hugegraph_llm.demo.rag_demo import configs_block + + monkeypatch.setattr(llm_settings, "embedding_type", "openai") + update_env = Mock() + monkeypatch.setattr(type(llm_settings), "update_env", update_env) + monkeypatch.setattr(configs_block, "test_api_connection", Mock(return_value=200)) + monkeypatch.setattr(configs_block.gr, "Info", Mock()) + + result = configs_block.apply_embedding_config("new-key", "https://new.example", "new-model", origin_call="http") + + assert result == 200 + assert llm_settings.openai_embedding_api_key == "new-key" + update_env.assert_called_once_with() + + +def test_http_and_ui_config_updates_share_lock(): + from hugegraph_llm.api import rag_api + from hugegraph_llm.config import runtime_config_lock + from hugegraph_llm.demo.rag_demo import configs_block + + assert rag_api.runtime_config_lock is runtime_config_lock + assert configs_block.runtime_config_lock is runtime_config_lock diff --git a/hugegraph-llm/src/tests/integration/test_core_text2gremlin_smoke.py b/hugegraph-llm/src/tests/integration/test_core_text2gremlin_smoke.py index 3ebecd283..ec0b813de 100644 --- a/hugegraph-llm/src/tests/integration/test_core_text2gremlin_smoke.py +++ b/hugegraph-llm/src/tests/integration/test_core_text2gremlin_smoke.py @@ -51,12 +51,20 @@ def test_text2gremlin_smoke_normalizes_fake_llm_output(): schema_input="hugegraph", gremlin_prompt_input=None, requested_outputs=["template_gremlin", "invalid_output"], + graph_client_config={ + "url": "http://graph.example:8080", + "graph": "hugegraph", + "user": "admin", + "pwd": "secret", + "graphspace": "DEFAULT", + }, ) assert result["result"] == "g.V().has('quality_person', 'name', 'marko')" assert result["raw_result"] == "g.V().hasLabel('quality_person')" assert prepared.example_num == 10 assert prepared.requested_outputs == ["template_gremlin"] + assert prepared.graph_client_config["url"] == "http://graph.example:8080" def test_text2gremlin_smoke_invalid_query_fails_explicitly(): diff --git a/hugegraph-llm/src/tests/integration/test_hugegraph_boundary.py b/hugegraph-llm/src/tests/integration/test_hugegraph_boundary.py index 581963a96..eff60abca 100644 --- a/hugegraph-llm/src/tests/integration/test_hugegraph_boundary.py +++ b/hugegraph-llm/src/tests/integration/test_hugegraph_boundary.py @@ -185,3 +185,62 @@ def test_gremlin_execute_surfaces_invalid_query(hugegraph_client): or "No signature" in result["raw_exec_res"] or "NotFound" in result["raw_exec_res"] ) + + +@pytest.mark.parametrize( + ("requested_output", "result_key", "query_key"), + [ + ("template_execution_result", "template_exec_res", "result"), + ("raw_execution_result", "raw_exec_res", "raw_result"), + ], +) +def test_gremlin_execute_passes_request_connection(monkeypatch, requested_output, result_key, query_key): + from unittest.mock import Mock + + from hugegraph_llm.nodes.hugegraph_node.gremlin_execute import GremlinExecuteNode + + run_query = Mock(return_value="query result") + monkeypatch.setattr( + "hugegraph_llm.nodes.hugegraph_node.gremlin_execute.run_gremlin_query", + run_query, + ) + connection = { + "url": "http://request:8080", + "graph": "request_graph", + "user": "user", + "pwd": "pwd", + "graphspace": "space", + } + node = GremlinExecuteNode() + node.wk_input = type( + "Input", + (), + {"requested_outputs": [requested_output], "graph_client_config": connection}, + )() + + result = node.operator_schedule({query_key: "g.V()"}) + + assert result[result_key] == "query result" + run_query.assert_called_once_with(query="g.V().limit(100)", connection=connection) + + +def test_gremlin_execute_passes_none_connection_for_default_config(monkeypatch): + from unittest.mock import Mock + + from hugegraph_llm.nodes.hugegraph_node.gremlin_execute import GremlinExecuteNode + + run_query = Mock(return_value="query result") + monkeypatch.setattr( + "hugegraph_llm.nodes.hugegraph_node.gremlin_execute.run_gremlin_query", + run_query, + ) + node = GremlinExecuteNode() + node.wk_input = type( + "Input", + (), + {"requested_outputs": ["raw_execution_result"], "graph_client_config": None}, + )() + + node.operator_schedule({"raw_result": "g.E()"}) + + run_query.assert_called_once_with(query="g.E().limit(100)", connection=None) diff --git a/hugegraph-llm/src/tests/nodes/test_gremlin_execute.py b/hugegraph-llm/src/tests/nodes/test_gremlin_execute.py new file mode 100644 index 000000000..11bee047c --- /dev/null +++ b/hugegraph-llm/src/tests/nodes/test_gremlin_execute.py @@ -0,0 +1,53 @@ +# 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. + +from types import SimpleNamespace +from unittest.mock import Mock, call + +from hugegraph_llm.nodes.hugegraph_node.gremlin_execute import GremlinExecuteNode + + +def test_gremlin_execute_passes_request_connection_to_both_queries(monkeypatch): + execute = Mock(side_effect=["template-result", "raw-result"]) + monkeypatch.setattr("hugegraph_llm.nodes.hugegraph_node.gremlin_execute.run_gremlin_query", execute) + connection = {"url": "http://graph.example:8080", "graph": "target"} + node = GremlinExecuteNode() + node.wk_input = SimpleNamespace( + requested_outputs=["template_execution_result", "raw_execution_result"], + graph_client_config=connection, + ) + + result = node.operator_schedule({"result": "g.V()", "raw_result": "g.E()"}) + + assert result["template_exec_res"] == "template-result" + assert result["raw_exec_res"] == "raw-result" + assert execute.call_args_list == [ + call(query="g.V().limit(100)", connection=connection), + call(query="g.E().limit(100)", connection=connection), + ] + + +def test_gremlin_execute_defaults_to_global_connection(monkeypatch): + execute = Mock(return_value="result") + monkeypatch.setattr("hugegraph_llm.nodes.hugegraph_node.gremlin_execute.run_gremlin_query", execute) + node = GremlinExecuteNode() + node.wk_input = SimpleNamespace(requested_outputs=["raw_execution_result"]) + + result = node.operator_schedule({"raw_result": "g.V().limit(1)"}) + + assert result["raw_exec_res"] == "result" + execute.assert_called_once_with(query="g.V().limit(1)", connection=None) diff --git a/hugegraph-llm/src/tests/test_rag_demo_cli.py b/hugegraph-llm/src/tests/test_rag_demo_cli.py new file mode 100644 index 000000000..19b3919a8 --- /dev/null +++ b/hugegraph-llm/src/tests/test_rag_demo_cli.py @@ -0,0 +1,93 @@ +# 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. + +from unittest.mock import Mock + +import pytest + +from hugegraph_llm.demo.rag_demo import app + +pytestmark = pytest.mark.contract + + +@pytest.mark.parametrize( + ("host", "expected"), + [ + ("127.0.0.1", True), + ("127.255.255.254", True), + ("::1", True), + ("localhost", True), + ("LOCALHOST", True), + ("0.0.0.0", False), + ("::", False), + ("192.168.1.10", False), + ("example.internal", False), + ], +) +def test_is_loopback_host(host, expected): + assert app.is_loopback_host(host) is expected + + +def test_parse_args_defaults_to_loopback(): + args = app.parse_args([]) + + assert args.host == "127.0.0.1" + assert args.port == 8001 + + +def test_run_server_passes_default_loopback_host_to_uvicorn(monkeypatch): + uvicorn_run = Mock() + monkeypatch.delenv("HG_DEV_RELOAD", raising=False) + monkeypatch.setattr(app.uvicorn, "run", uvicorn_run) + + app.run_server(app.parse_args([])) + + uvicorn_run.assert_called_once_with( + "hugegraph_llm.demo.rag_demo.app:create_app", + host="127.0.0.1", + port=8001, + factory=True, + reload=False, + ) + + +def test_run_server_warns_when_binding_non_loopback(monkeypatch): + uvicorn_run = Mock() + warning = Mock() + monkeypatch.setattr(app.uvicorn, "run", uvicorn_run) + monkeypatch.setattr(app.log, "warning", warning) + + app.run_server(app.parse_args(["--host", "0.0.0.0"])) + + warning.assert_called_once() + message = warning.call_args.args[0] + assert "no unified authentication" in message.lower() + assert "reverse proxy authentication" in message.lower() + assert "firewall" in message.lower() + assert "trusted network" in message.lower() + uvicorn_run.assert_called_once() + assert uvicorn_run.call_args.kwargs["host"] == "0.0.0.0" + + +def test_run_server_does_not_warn_for_loopback(monkeypatch): + monkeypatch.setattr(app.uvicorn, "run", Mock()) + warning = Mock() + monkeypatch.setattr(app.log, "warning", warning) + + app.run_server(app.parse_args(["--host", "::1"])) + + warning.assert_not_called() diff --git a/hugegraph-llm/src/tests/utils/test_hugegraph_utils.py b/hugegraph-llm/src/tests/utils/test_hugegraph_utils.py new file mode 100644 index 000000000..759eadb97 --- /dev/null +++ b/hugegraph-llm/src/tests/utils/test_hugegraph_utils.py @@ -0,0 +1,63 @@ +# 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. + +from unittest.mock import Mock + +import pytest + +from hugegraph_llm.config import huge_settings +from hugegraph_llm.utils.hugegraph_utils import get_hg_client + + +def test_request_connection_passes_canonical_validated_url(monkeypatch): + monkeypatch.setattr(huge_settings, "graph_url", "127.0.0.1:8080") + client = Mock() + factory = Mock(return_value=client) + monkeypatch.setattr("hugegraph_llm.utils.hugegraph_utils.PyHugeClient", factory) + + result = get_hg_client( + { + "url": "http://127.0.0.1:8080/", + "graph": "hugegraph", + "user": "admin", + "pwd": "secret", + "graphspace": "DEFAULT", + } + ) + + assert result is client + assert factory.call_args.kwargs["url"] == "http://127.0.0.1:8080" + + +@pytest.mark.parametrize("url", ["http://:80", "http://user@127.0.0.1:8080", "http://[::1"]) +def test_request_connection_rejects_invalid_url_before_client_construction(monkeypatch, url): + monkeypatch.setattr(huge_settings, "graph_url", "http://127.0.0.1:8080") + factory = Mock() + monkeypatch.setattr("hugegraph_llm.utils.hugegraph_utils.PyHugeClient", factory) + + with pytest.raises(ValueError, match="Graph URL"): + get_hg_client( + { + "url": url, + "graph": "hugegraph", + "user": "admin", + "pwd": "secret", + "graphspace": None, + } + ) + + factory.assert_not_called() diff --git a/hugegraph-mcp/README.md b/hugegraph-mcp/README.md index caadc0e49..bd864f1e1 100644 --- a/hugegraph-mcp/README.md +++ b/hugegraph-mcp/README.md @@ -43,6 +43,8 @@ These tools are still registered in MCP, but they are admin/debug capabilities a - `execute_gremlin_write_tool` - `refresh_vid_embeddings_tool` +`execute_gremlin_write_tool` is the sole break-glass exception to the write safety chain. It executes arbitrary Gremlin writes without a preview, dry-run, plan hash, or confirmation. Enable it only on an isolated transport available to trusted administrators; do not enable it on a shared agent or client endpoint. + ### Toolset Selection `HUGEGRAPH_MCP_TOOLSET` controls the public tool contract: @@ -113,7 +115,7 @@ The old `query_graph_tool`, `manage_schema_tool`, and `manage_graph_data_tool` a ## Write Safety Chain -All user-reachable write operations must follow this chain: +All normal user-facing write operations must follow this chain. The admin-only `execute_gremlin_write_tool` break-glass exception described above does not: ```text dry_run=true @@ -200,12 +202,18 @@ All configuration is read from environment variables. | `HUGEGRAPH_MCP_ALLOW_AI` | `false` | Whether HugeGraph-AI calls are allowed | | `HUGEGRAPH_MCP_ADMIN_MODE` | `false` | Whether admin/debug tools are enabled | | `HUGEGRAPH_AI_URL` | `http://127.0.0.1:8001` | HugeGraph-AI URL | +| `HUGEGRAPH_AI_TOKEN` | unset | Bearer token for an authenticated HugeGraph-AI service; an explicit per-request `Authorization` header takes precedence | | `HUGEGRAPH_AI_GRAPH_URL` | unset | Graph URL used by HugeGraph-AI; defaults to `HUGEGRAPH_URL` when unset | | `HUGEGRAPH_MCP_TIMEOUT_SECONDS` | `30` | AI call timeout in seconds | | `HUGEGRAPH_MCP_MAX_REPEAT_TIMES` | `10` | Recommended maximum for read-cost warnings on `repeat().times(n)` | +| `HUGEGRAPH_MCP_STATE_DIR` | `$XDG_STATE_HOME/hugegraph-mcp`, or `~/.local/state/hugegraph-mcp` when `XDG_STATE_HOME` is unset | Local state directory for the persistent single-use confirmation ledger | `HUGEGRAPH_MCP_TIMEOUT_SECONDS` only applies to HugeGraph-AI HTTP calls; it does not apply to PyHugeClient Gremlin queries. Read-only Gremlin cost boundaries are reported as non-blocking read cost guard warnings for bare full-graph scans, `repeat()` without a `times()` bound, and `path` / `group` / `profile` without `limit` or `range`. +Boolean configuration accepts `1`, `true`, `yes`, or `on` and `0`, `false`, `no`, or `off`, ignoring case and surrounding whitespace. Empty or invalid values fail closed: `HUGEGRAPH_MCP_READONLY` remains enabled, while `HUGEGRAPH_MCP_ALLOW_AI` and `HUGEGRAPH_MCP_ADMIN_MODE` remain disabled. + +The confirmation ledger persists server-issued dry-run plans and consumed nonce digests. Confirm accepts only a matching server-issued plan, enforces the server's 10-minute maximum TTL, and atomically consumes it so a write plan can be used only once across local process restarts and workers sharing the same state directory. On POSIX platforms, HugeGraph MCP restricts the state directory to mode `0700` and the ledger database to mode `0600`. + Recommended safe defaults: - `HUGEGRAPH_MCP_READONLY=true` diff --git a/hugegraph-mcp/README.zh-CN.md b/hugegraph-mcp/README.zh-CN.md index 4de8c491d..e6d9fffca 100644 --- a/hugegraph-mcp/README.zh-CN.md +++ b/hugegraph-mcp/README.zh-CN.md @@ -45,6 +45,8 @@ HugeGraph MCP 不把 MCP 做成另一套业务内核。MCP 层负责: - `execute_gremlin_write_tool` - `refresh_vid_embeddings_tool` +`execute_gremlin_write_tool` 是写入安全链唯一的 break-glass(紧急直通)例外。它不提供预览、dry-run、plan hash 或确认步骤,可直接执行任意 Gremlin 写语句。只能在隔离且仅受信管理员可访问的 transport 上启用,禁止在共享 Agent 或客户端端点启用。 + ### 工具集选择 `HUGEGRAPH_MCP_TOOLSET` 控制对外工具契约: @@ -118,7 +120,7 @@ HugeGraph MCP 不把 MCP 做成另一套业务内核。MCP 层负责: ## 写入安全链 -所有用户可触达的写操作都必须经过: +所有普通用户可触达的写操作都必须经过以下安全链。上文所述仅管理员可用的 `execute_gremlin_write_tool` break-glass 例外不经过此链: ```text dry_run=true @@ -145,6 +147,8 @@ dry_run=true confirm 阶段必须全量重验。dry-run 结果过期、目标图变化、schema 变化、payload 变化或权限变化时,confirm 必须失败并要求重新 dry-run。 +确认账本会持久化由服务端 dry-run 签发的计划以及已消费 nonce 的摘要。confirm 只接受与服务端签发记录一致的计划,强制执行服务端 10 分钟最大 TTL,并原子消费计划,因此计划在进程重启后以及共享同一状态目录的多个 worker 之间仍只能使用一次。 + ### 导入语义 `import_graph_data_tool(mode="ingest")` 是 MCP V1 对外的结构化导入路径。它使用本地 schema 校验、dry-run/hash/confirm 和 `manage_graph_data()` 的 direct Gremlin 写入;不会调用 HugeGraph-AI `/graph-import` HTTP 路径。legacy/internal 的 AI-backed 函数命名为 `ingest_graph_data_via_ai()`。 @@ -203,6 +207,7 @@ scalar 端点是 same-payload import 的便捷写法,但在单主键 live sche | `HUGEGRAPH_MCP_ALLOW_AI` | `false` | 是否允许调用 HugeGraph-AI | | `HUGEGRAPH_MCP_ADMIN_MODE` | `false` | 是否启用管理/调试工具 | | `HUGEGRAPH_AI_URL` | `http://127.0.0.1:8001` | HugeGraph-AI 地址 | +| `HUGEGRAPH_AI_TOKEN` | 未设置 | HugeGraph-AI 开启认证时使用的 Bearer Token;调用时显式传入的 `Authorization` header 优先 | | `HUGEGRAPH_AI_GRAPH_URL` | 未设置 | AI 侧使用的图地址,未设置时使用 `HUGEGRAPH_URL` | | `HUGEGRAPH_MCP_TIMEOUT_SECONDS` | `30` | AI 调用超时时间 | | `HUGEGRAPH_MCP_MAX_REPEAT_TIMES` | `10` | `repeat().times(n)` 只读成本 warning 的建议最大值 | diff --git a/hugegraph-mcp/docs/graph_mcp_post_review_fix_plan.md b/hugegraph-mcp/docs/graph_mcp_post_review_fix_plan.md new file mode 100644 index 000000000..56b61f972 --- /dev/null +++ b/hugegraph-mcp/docs/graph_mcp_post_review_fix_plan.md @@ -0,0 +1,340 @@ +# Graph MCP Post-Review Fix Plan + +本文档记录当前 `graph-mcp` 分支复核后仍需处理的问题、修复顺序、涉及文件和验收命令。 + +## 1. 复核结论 + +当前分支相对 `github/graph-mcp` 的 MCP/client 修复整体方向正确,核心安全链没有发现被削弱: + +- readonly/admin gate 仍在执行期生效。 +- `dry_run -> plan_hash -> confirm` 仍会绑定 payload、schema、target、readonly、nonce 和 expiry。 +- edge id path/query 编码覆盖 `getEdgeById`、`appendEdge`、`eliminateEdge`、`removeEdgeById`、`getEdgesById`。 + +但合入前仍有以下问题需要修复: + +| 优先级 | 问题 | 结论 | +| --- | --- | --- | +| Blocker | `ruff format --check` 失败 | 必须修复后才能合入 | +| High | `mutate_graph_properties` 的 success data 可能绕过脱敏 | 必须修复并补测试 | +| Medium | `FEATURE_DISABLED` 文案残留 `V1` | 需要修正文案和测试 | +| Low/Medium | `NOT_FOUND` 分类过宽 | 建议修复,避免误导用户 | +| Low | README 工具数量口径不够清晰 | 建议同步修正 | + +## 2. 问题明细 + +### 2.1 格式门禁失败 + +涉及文件: + +- `hugegraph-mcp/tests/test_manage_schema.py` + +复核命令: + +```bash +uv run --project hugegraph-mcp ruff format --check hugegraph-mcp +``` + +当前结果: + +```text +Would reformat: hugegraph-mcp/tests/test_manage_schema.py +1 file would be reformatted, 56 files already formatted +``` + +影响: + +- CI / pre-merge format gate 会失败。 +- `docs/graph_mcp_review_fix_plan.md` 中关于 Phase 0 format check 已修复的状态与实际不一致。 + +修复要求: + +- 运行 `ruff format` 修复该文件。 +- 仅接受机械格式化 diff,不混入行为改动。 + +### 2.2 post-read error 绕过脱敏 + +涉及文件: + +- `hugegraph-mcp/hugegraph_mcp/tools/mutate_graph_properties.py` +- `hugegraph-mcp/tests/test_mutate_graph_properties_tool.py` + +现状: + +`mutate_graph_properties._execute_and_verify()` 在写入返回但 post-read 验证异常时,会把 `str(exc)` 放入成功响应: + +```python +"post_read_error": str(exc) +``` + +`envelope_ok()` 不会对 `data` 做 `sanitize_for_response()`,脱敏只覆盖 `envelope_err()` 的 `message`、`suggestion` 和 `details`。 + +影响: + +- 如果异常文本包含 `Authorization`、`token=`、URL userinfo、password 等敏感信息,会在 `data.post_read_error` 中原样返回。 +- 这是 MCP 实现问题,不是 HugeGraph Server 限制。 + +修复要求: + +- 在该分支中使用 `sanitize_for_response(str(exc))`。 +- 或将 post-read verification failure 改为标准错误 envelope,同时保留 `status="unknown"` 和补偿建议。 +- 推荐最小修复:只对 `post_read_error` 脱敏,保持当前 `ok=true` + warning 的兼容行为。 + +测试要求: + +- 模拟 mutation 执行成功,但 post-read 抛出包含以下内容的异常: + - `Authorization: Bearer abc123` + - `token=xyz` + - `http://user:pass@example.com` +- 断言响应中不包含原始 secret、token 或 URL userinfo。 +- 断言 `status` 仍为 `unknown`,next action 仍提示用户查询目标状态。 + +### 2.3 FEATURE_DISABLED 文案残留 V1 + +涉及文件: + +- `hugegraph-mcp/hugegraph_mcp/server.py` +- `hugegraph-mcp/tests/test_v1_stable_tools.py` +- `hugegraph-mcp/tests/test_import_graph_data_tool.py` + +现状: + +`_admin_gate()` 返回: + +```text + is disabled by default in V1. Enable with HUGEGRAPH_MCP_ADMIN_MODE=true. +``` + +`import_graph_data_tool(mode="table")` 返回: + +```text +Table import is not available in V1. +``` + +影响: + +- 当前默认工具集是 `v2_core`,该文案会误导用户以为自己处于 V1,或需要切换 toolset。 +- 实际下一步应该是开启 admin mode、关闭 readonly,或改用当前支持的 `extract -> ingest` 路径。 + +修复要求: + +- admin/debug 工具禁用文案改为能力维度: + +```text + is an admin/debug tool and is disabled by default. +``` + +- write-capable admin/debug 工具 suggestion 保持指向: + +```text +Set HUGEGRAPH_MCP_ADMIN_MODE=true and HUGEGRAPH_MCP_READONLY=false to enable . +``` + +- table import 文案改为: + +```text +Table import is not supported by the current MCP contract. +``` + +- `details` 建议增加当前 `toolset` 和需要设置的环境变量。 + +测试要求: + +- 默认 `v2_core` 下调用 admin/debug 工具,错误信息不再包含 `V1`。 +- `mode="table"` 的 `FEATURE_DISABLED` 不再包含 `V1`,suggestion 仍指向 `extract -> ingest`。 + +### 2.4 NOT_FOUND 分类过宽 + +涉及文件: + +- `hugegraph-mcp/hugegraph_mcp/error_mapping.py` +- `hugegraph-mcp/tests/test_error_handling.py` + +现状: + +`NOT_FOUND_MARKERS` 包含: + +```python +"404", +"notfound", +"not found", +"not exist", +"does not exist", +``` + +因此以下文本都会被分类为 `NOT_FOUND`: + +```text +Property key does not exist: age +Edge label does not exist: knows +Vertex does not exist: 1 +``` + +影响: + +- 顶点/边目标不存在时返回 `NOT_FOUND` 是正确的。 +- property key / vertex label / edge label / schema object 不存在时,用户更需要检查 schema 或属性名,而不是检查 id、target type、graphspace。 + +修复要求: + +- 在 `NO_INDEX` 判断之后、通用 `NOT_FOUND` 判断之前,增加 schema-object missing 分类。 +- 推荐分类为 `SCHEMA_MISMATCH`。 +- suggestion 指向检查 live schema、label、property key。 + +建议 marker: + +```python +SCHEMA_OBJECT_MISSING_MARKERS = ( + "property key does not exist", + "propertykey does not exist", + "vertex label does not exist", + "vertexlabel does not exist", + "edge label does not exist", + "edgelabel does not exist", + "schema does not exist", +) +``` + +测试要求: + +- `Property key does not exist: age` -> `SCHEMA_MISMATCH` +- `Edge label does not exist: knows` -> `SCHEMA_MISMATCH` +- `Vertex does not exist: 1` -> `NOT_FOUND` +- `NoIndexException` / `not indexed` 仍优先为 `NO_INDEX` + +### 2.5 README 工具口径不够清晰 + +涉及文件: + +- `hugegraph-mcp/README.md` +- `hugegraph-mcp/README.zh-CN.md` + +现状: + +- README 先列出 11 个普通用户稳定工具。 +- 随后说明 2 个 admin/debug 工具仍注册但默认阻断。 +- toolset 表中写 `v2_core=13`、`v1=10`。 + +工具数量本身与代码一致,但读者容易误以为 README 少列了两个工具,或把 admin/debug 工具当成普通用户稳定工具。 + +修复要求: + +- 明确默认 `v2_core` 注册 13 个 MCP 工具: + - 11 个普通用户稳定工具。 + - 2 个 admin/debug 工具,默认注册但受 admin mode 阻断。 + +建议英文表述: + +```text +The default `v2_core` toolset registers 13 MCP tools: 11 normal user-facing stable tools plus 2 admin/debug tools that are registered but blocked by default. +``` + +建议中文表述: + +```text +默认 `v2_core` 工具集注册 13 个 MCP 工具:其中 11 个是普通用户稳定工具,另外 2 个是管理/调试工具,默认注册但受 admin mode 阻断。 +``` + +### 2.6 修复计划状态同步 + +涉及文件: + +- `hugegraph-mcp/docs/graph_mcp_review_fix_plan.md` + +现状: + +该文档顶部声明 Phase 0 的 format check 已修复,但当前 format check 实际失败。 + +修复要求: + +- 如果本轮修复后格式门禁通过,可以保留 Phase 0 已修复状态,并补充本轮 post-review 问题已处理。 +- 如果真实 HugeGraph 集成测试未复跑,不要声明当前已通过,只写待补跑或引用最新实际命令结果。 + +## 3. 修复顺序 + +### Phase 1: 安全脱敏 + +1. 修改 `mutate_graph_properties.py`: + - 引入 `sanitize_for_response`。 + - 对 `post_read_error` 使用脱敏后的异常文本。 +2. 修改 `test_mutate_graph_properties_tool.py`: + - 新增 post-read 异常脱敏测试。 + +验收命令: + +```bash +uv run --project hugegraph-mcp pytest hugegraph-mcp/tests/test_mutate_graph_properties_tool.py -q +``` + +### Phase 2: 错误分类 + +1. 修改 `error_mapping.py`: + - 新增 schema-object missing markers。 + - 在 generic not-found 前优先返回 `SCHEMA_MISMATCH`。 +2. 修改 `test_error_handling.py`: + - 补 schema object missing 分类测试。 + - 保留 vertex/edge target not found 测试。 + +验收命令: + +```bash +uv run --project hugegraph-mcp pytest hugegraph-mcp/tests/test_error_handling.py -q +``` + +### Phase 3: FEATURE_DISABLED 文案 + +1. 修改 `server.py`: + - 去掉 admin gate 和 table import 中误导性的 `V1` 文案。 + - `details` 增加更明确的启用条件。 +2. 修改相关测试: + - `test_v1_stable_tools.py` + - `test_import_graph_data_tool.py` + +验收命令: + +```bash +uv run --project hugegraph-mcp pytest hugegraph-mcp/tests/test_v1_stable_tools.py hugegraph-mcp/tests/test_import_graph_data_tool.py -q +``` + +### Phase 4: README 和修复计划文档 + +1. 修改 `README.md`。 +2. 修改 `README.zh-CN.md`。 +3. 修改 `docs/graph_mcp_review_fix_plan.md`。 + +验收方式: + +- 人工确认英文/中文 README 口径一致。 +- 文档不再声明未通过的检查已经通过。 + +### Phase 5: 格式化 + +运行: + +```bash +uv run --project hugegraph-mcp ruff format hugegraph-mcp/tests/test_manage_schema.py +``` + +要求: + +- 只接受机械格式化变更。 +- 不混入行为修改。 + +## 4. 最终验证命令 + +修复全部完成后按以下顺序验证: + +```bash +uv run --project hugegraph-mcp ruff format --check hugegraph-mcp +uv run --project hugegraph-mcp ruff check hugegraph-mcp +uv run --project hugegraph-mcp pytest hugegraph-mcp/tests/test_error_handling.py hugegraph-mcp/tests/test_mutate_graph_properties_tool.py hugegraph-mcp/tests/test_v1_stable_tools.py hugegraph-mcp/tests/test_import_graph_data_tool.py hugegraph-mcp/tests/test_manage_schema.py -q +uv run --project hugegraph-mcp pytest hugegraph-mcp/tests -m "not live and not integration and not real_hugegraph and not llm" +RUN_MCP_REAL_HUGEGRAPH_TESTS=1 uv run --project hugegraph-mcp pytest hugegraph-mcp/tests/integration/test_real_write_path.py -vv +``` + +如果本地 HugeGraph Server 不可用,最后一条真实集成测试可以暂缓,但合入前必须补跑,并记录 HugeGraph Server 版本、图空间、图名和命令输出摘要。 + +## 5. 不在本轮处理的残余风险 + +- `mutate_graph_properties` 当前主要覆盖整键 append/eliminate 语义。若后续支持 `SET` / `LIST` cardinality 的元素级追加或移除,需要新增真实 HugeGraph 用例。 +- edge id 编码已覆盖当前真实 composite id 路径,但仍建议保留真实 HugeGraph 回归,因为 composite edge id 格式可能随服务端版本或配置变化。 +- `tests/test_v1_stable_tools.py` 当前通过 FastMCP 私有属性列工具,短期可接受;后续可考虑封装项目内测试 helper 或改用公开 API,降低对第三方内部实现的耦合。 diff --git a/hugegraph-mcp/docs/graph_mcp_remaining_defect_fix_plan.md b/hugegraph-mcp/docs/graph_mcp_remaining_defect_fix_plan.md new file mode 100644 index 000000000..9fd5adfa4 --- /dev/null +++ b/hugegraph-mcp/docs/graph_mcp_remaining_defect_fix_plan.md @@ -0,0 +1,285 @@ +# Graph MCP Remaining Defect Fix Plan + +本文档记录 `graph-mcp` 分支在 2026-07-10 复核后确认的剩余缺陷及实施顺序。 +计划基线为 `graph-mcp@32f4563`。本轮只制定计划,不修改实现。 + +## 1. 结论与边界 + +本服务按本地单用户部署设计,不在本轮引入 Bearer Token。无认证成立的前提是: + +- 源码直启默认只监听 loopback。 +- Docker 默认只向宿主机 loopback 发布端口。 +- 用户显式监听非 loopback 地址时,启动日志必须给出安全告警,并由部署方提供前置认证或可信网络隔离。 + +Docker 容器内的进程仍监听 `0.0.0.0`,否则宿主机端口映射无法访问容器服务;安全边界由宿主机的 `127.0.0.1:8001:8001` 端口发布规则保证。 + +本轮计划修复六项问题: + +| 优先级 | 问题 | 处理结论 | +| --- | --- | --- | +| High | HugeGraph-AI 源码直启默认监听 `0.0.0.0`,Docker 默认向所有宿主机接口发布 `8001` | 默认收紧到 loopback;非 loopback 显式启动告警;不增加应用内 Token | +| High | `HUGEGRAPH_MCP_READONLY` 非法值会被解析为 `False` | 严格解析,非法或空值按 `True` 处理并告警 | +| High | confirm nonce 在 TTL 内可重复使用 | 增加持久化、原子、单次消费的确认账本 | +| Medium | `SET` / `LIST` append 的预览和写后比较使用覆盖语义 | 按 property key cardinality 计算预览并比较 | +| Medium | edge page 查询允许 `vertex_id` 与 `page` 同时出现 | MCP 层提前返回 `VALIDATION_ERROR` | +| Low | 三个 client 纯内存合约测试被 integration 文件级 marker 覆盖 | 拆入 contract 测试文件,确保 unit/contract CI 收集 | + +## 2. 已确定的技术方案 + +### 2.1 本地部署网络边界 + +涉及文件: + +- `hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py` +- `hugegraph-llm/src/tests/` 下新增或补充 CLI 配置测试 +- `docker/docker-compose-network.yml` +- `docker/docker-compose-llm.yml` +- `hugegraph-llm/README.md` +- 根目录 `README.md` + +实现要求: + +1. 将 `app.py` 的 `--host` 默认值从 `0.0.0.0` 改为 `127.0.0.1`。 +2. 抽取可测试的参数解析和 host 安全判断:`127.0.0.0/8`、`::1` 和 `localhost` 视为 loopback;`0.0.0.0`、`::`、局域网地址和其他主机名视为非 loopback。 +3. 用户显式选择非 loopback host 时记录醒目的 warning,说明当前 HTTP API 没有统一认证,必须配置反向代理认证、防火墙或可信网络。 +4. 保留 `docker/Dockerfile.llm` 和 `docker/Dockerfile.nk` 中容器内 `--host 0.0.0.0`。 +5. 两个 Compose 文件都将 RAG 服务端口改为 `127.0.0.1:8001:8001`。 +6. README 中的 `docker run` 示例改为 `-p 127.0.0.1:8001:8001`,并明确暴露到非 loopback 是部署方的显式安全选择。 + +验收标准: + +- 不传 `--host` 时,传给 Uvicorn 的 host 是 `127.0.0.1`。 +- 显式 `--host 0.0.0.0` 仍可启动,但产生安全告警。 +- Compose 渲染结果中宿主机绑定地址为 `127.0.0.1`,容器目标端口仍为 `8001`。 +- 文档不宣称服务已有 Bearer Token 或其他尚未实现的认证能力。 + +### 2.2 readonly 配置严格解析并 fail-closed + +涉及文件: + +- `hugegraph-mcp/hugegraph_mcp/config.py` +- `hugegraph-mcp/tests/test_config.py` +- `hugegraph-mcp/tests/test_readonly_mode.py` + +实现要求: + +1. 将布尔解析改为显式 true/false 集合,并接收变量名和安全默认值,不再用“不是 true 就是 false”的规则。 +2. 建议接受: + - true:`1`、`true`、`yes`、`on`,忽略大小写和首尾空格。 + - false:`0`、`false`、`no`、`off`,忽略大小写和首尾空格。 +3. 解析器必须区分未设置(`None`)与显式空字符串:未设置时直接使用默认值且不告警;显式空字符串或非法值进入 fail-closed 分支并告警。 +4. `HUGEGRAPH_MCP_READONLY` 未设置时保持默认 `True`;显式空字符串或非法值(如 `treu`、`junk`)也返回 `True`。 +5. `HUGEGRAPH_MCP_ALLOW_AI` 和 `HUGEGRAPH_MCP_ADMIN_MODE` 使用同一严格解析器,但非法值保持各自安全默认值 `False`。 +6. warning 只包含变量名,不回显非法原始值,更不能输出密码、Token 或其他配置内容。 + +验收标准: + +- `HUGEGRAPH_MCP_READONLY=treu`、`junk`、空字符串均不能开启写权限。 +- 合法 false 值仍能显式关闭 readonly。 +- 非法 `ALLOW_AI` / `ADMIN_MODE` 值不能开启对应能力。 +- 环境变量变化后的配置缓存行为保持正确。 + +### 2.3 confirm nonce 持久化单次消费 + +涉及文件: + +- `hugegraph-mcp/hugegraph_mcp/plan_hash.py` +- `hugegraph-mcp/hugegraph_mcp/config.py` +- 建议新增 `hugegraph-mcp/hugegraph_mcp/confirmation_store.py` +- `hugegraph-mcp/hugegraph_mcp/confirmable_workflow.py` +- `hugegraph-mcp/hugegraph_mcp/envelope.py` +- 四个调用 `verify_plan_hash()` 的写入口: + - `tools/manage_schema.py` + - `tools/manage_graph_data.py` + - `tools/ingest_graph_data.py` + - `tools/mutate_graph_properties.py` +- `hugegraph-mcp/tests/test_plan_hash.py` +- 四个写入口的定向测试 +- `hugegraph-mcp/docs/p0a-integration-checklist.md` + +采用持久化单次消费,而不是仅使用进程内 `set`。原因是进程重启和多个本地 worker 都不能重新放开同一确认请求。 + +实现要求: + +1. 使用 Python 标准库 SQLite 建立本地确认账本,不引入外部服务或第三方依赖。 +2. 账本只保存 `nonce_digest=SHA-256(nonce)`、`plan_hash`、`expires_at`、`consumed_at` 等非敏感摘要,不保存原始 nonce、payload、凭据或图数据。`nonce_digest` 是全局唯一键且不绑定 payload;复用同一 nonce 生成不同工具或 payload 的计划也必须被拒绝。 +3. 状态目录通过 `HUGEGRAPH_MCP_STATE_DIR` 配置;默认使用 `$XDG_STATE_HOME/hugegraph-mcp`,未设置 XDG 时使用 `~/.local/state/hugegraph-mcp`。目录权限为 `0700`,数据库文件权限为 `0600`;不支持 POSIX 权限的平台做能力范围内的最严格处理。 +4. 先完成 plan hash、TTL、目标、schema 和 readonly 校验,再以 `nonce_digest` 为唯一键插入原子消费记录;只有插入成功的请求可以进入真实写入。 +5. 消费发生在第一个副作用之前。写入超时、失败或部分成功后,同一 plan 不允许重试;调用方必须检查目标状态并重新 dry-run。 +6. 并发提交相同确认时只能有一个调用获得执行权。 +7. 过期记录可在消费时惰性清理;清理失败不能影响当前合法请求,但确认账本不可用或无法持久化时必须 fail-closed,禁止写入。 +8. 新增明确错误类型 `PLAN_ALREADY_USED`。响应不得泄露账本路径或内部 SQL 错误。 +9. 保留 `verify_plan_hash()` 作为无副作用的纯校验函数,新增统一的 `verify_and_consume_plan()` 供所有真实写入口调用,避免单元测试或预检查意外消费计划。 + +验收标准: + +- 第一次合法 confirm 可执行,第二次相同 confirm 返回 `PLAN_ALREADY_USED`,且没有第二次写调用。 +- 两个并发相同 confirm 只有一个成功进入执行函数。 +- 服务重新创建 store 实例后,相同 plan 仍被拒绝。 +- 过期、hash 不匹配和 readonly 请求不会占用 nonce。 +- 执行失败或 `PARTIAL_APPLY` 后相同 plan 仍被拒绝,并提示重新查询和 dry-run。 +- 所有四个真实写入口使用同一消费路径,不能存在绕过入口。 + +### 2.4 集合属性 append 预览和写后校验 + +涉及文件: + +- `hugegraph-mcp/hugegraph_mcp/tools/mutate_graph_properties.py` +- `hugegraph-mcp/hugegraph_mcp/tools/schema_utils.py`(如抽取共享 cardinality helper) +- `hugegraph-mcp/tests/test_mutate_graph_properties_tool.py` +- `hugegraph-mcp/tests/integration/test_real_write_path.py` + +实现要求: + +1. 从 live schema 的 `propertykeys` 建立 `property name -> cardinality` 映射,兼容当前 schema 包装格式和字段别名。 +2. `_preview_mutation()` 必须接收 cardinality 信息: + - `SINGLE`:append 后使用新值,保持当前替换语义。 + - `LIST`:按原顺序拼接新值,保留重复项。 + - `SET`:按首次出现顺序合并并去重,使预览稳定;写后比较忽略顺序。 +3. `LIST` / `SET` 属性的 append 输入必须是集合值;不符合 schema cardinality 的输入在写前返回 `VALIDATION_ERROR`。 +4. `eliminate` 继续表示删除整个 property key,不改为按集合元素删除。 +5. 写后 `_properties_match()` 使用同一 cardinality 规则:`SET` 比较集合等价性,`LIST` 比较顺序和重复项,`SINGLE` 直接比较。 +6. `plan_hash` 继续绑定 schema hash,因此 dry-run 后 cardinality 改变时 confirm 必须失效。 + +验收标准: + +- `LIST [a] + [b, b]` 的预览和写后期望为 `[a, b, b]`。 +- `SET [a] + [b, a]` 的预览不重复;服务端以不同顺序返回时仍验证成功。 +- `SINGLE old + new` 仍预览为 `new`。 +- 集合属性传入标量时不执行任何写调用。 +- 使用真实 HugeGraph 1.7.0 覆盖 vertex 和 edge 至少各一条集合 append 路径,避免仅凭 fake manager 证明语义。 + +### 2.5 拒绝非法 edge 分页参数组合 + +涉及文件: + +- `hugegraph-mcp/hugegraph_mcp/tools/query_graph_data.py` +- `hugegraph-mcp/tests/test_query_graph_data_tool.py` + +实现要求: + +1. 当 `target="edge"`、`operation="page"` 且同时提供 `vertex_id` 与非空 `page` 时,在创建 client 或发 REST 请求前返回 `VALIDATION_ERROR`。 +2. 错误 suggestion 明确给出两种合法方式: + - 按顶点查询:传 `vertex_id` 和 `direction`,不传 `page`。 + - 普通分页:传 `page`,不传 `vertex_id`。 +3. 保留已有规则:提供 `vertex_id` 时必须提供合法 `direction`。 +4. 将当前允许 `vertex_id + direction + page` 成功的测试改为负向回归测试,并分别增加两个合法分支测试。 + +验收标准: + +- 非法组合返回 `VALIDATION_ERROR`,fake graph manager 的调用列表为空。 +- `vertex_id + direction` 和普通 `page` 查询仍按原 client 参数顺序执行。 +- vertex 分页行为不受影响。 + +### 2.6 修正 client 测试 marker + +涉及文件: + +- `hugegraph-python-client/src/tests/api/test_schema.py` +- 建议新增 `hugegraph-python-client/src/tests/api/test_schema_contract.py` + +实现要求: + +1. 将以下三个不访问 HugeGraph 的测试及 `DummySchemaSession` 移到独立 contract 文件: + - property key payload 包含 `aggregate_type`。 + - property key payload 包含 `user_data`。 + - index label 字段保持顺序并去重。 +2. 新文件使用 `pytestmark = pytest.mark.contract`;原 `test_schema.py` 保持 `integration + hugegraph` 文件级 marker。 +3. 只移动测试归属,不重复同一测试,也不降低真实 schema integration 覆盖。 + +验收标准: + +- `pytest --collect-only -m "unit or contract"` 能收集这三个测试。 +- `pytest --collect-only -m "integration and hugegraph"` 仍收集 `TestSchemaManager`,但不收集三个纯内存测试。 + +## 3. 实施任务与依赖 + +- [ ] 1. **建立修复前基线** `[优先级: 高]` + - [ ] 1.1. 记录分支 HEAD、工作区状态和现有自动测试结果,保留用户未跟踪文件。 + - [ ] 1.2. 为六项缺陷先补充可失败的定向测试,确认测试能命中真实缺陷。 `(依赖于: 1.1)` + +- [ ] 2. **收紧本地部署网络边界** `[优先级: 高]` + - [ ] 2.1. 增加默认 host 和非 loopback 告警测试。 `(依赖于: 1.2)` + - [ ] 2.2. 修改源码启动默认值并实现告警。 `(依赖于: 2.1)` + - [ ] 2.3. 修改 Compose 端口发布和 README 示例,保留容器内 `0.0.0.0`。 `(依赖于: 2.2)` + +- [ ] 3. **修复写安全配置和确认重放** `[优先级: 高]` + - [ ] 3.1. 实现布尔严格解析和 readonly fail-closed。 `(依赖于: 1.2)` + - [ ] 3.2. 实现 SQLite 确认账本、`PLAN_ALREADY_USED` 和原子消费测试。 `(依赖于: 1.2)` + - [ ] 3.3. 将四个真实写入口迁移到统一 verify-and-consume 流程。 `(依赖于: 3.2)` + - [ ] 3.4. 补充失败、部分成功、并发和进程重建后的重放测试。 `(依赖于: 3.3)` + +- [ ] 4. **修复 MCP 数据语义** `[优先级: 中]` + - [ ] 4.1. 实现 cardinality-aware 属性预览、输入校验和写后比较。 `(依赖于: 1.2)` + - [ ] 4.2. 补 `SINGLE`、`LIST`、`SET`、vertex、edge 的单元与真实服务测试。 `(依赖于: 4.1)` + - [ ] 4.3. 增加 edge `vertex_id + page` 参数互斥校验并补合法分支测试。 `(依赖于: 1.2)` + +- [ ] 5. **修正 client 测试分层** `[优先级: 低]` + - [ ] 5.1. 将三个纯内存 schema 测试移动到 contract 文件。 `(依赖于: 1.1)` + - [ ] 5.2. 用 collect-only 命令验证两个 CI marker 集合互不串层。 `(依赖于: 5.1)` + +- [ ] 6. **完成回归与文档同步** `[优先级: 高]` + - [ ] 6.1. 运行格式、lint、MCP 非外部测试和 client unit/contract 测试。 `(依赖于: 2.3, 3.4, 4.3, 5.2)` + - [ ] 6.2. 使用 HugeGraph 1.7.0 运行 MCP 真实写入和 client integration 测试。 `(依赖于: 6.1)` + - [ ] 6.3. 更新错误类型和本地部署边界文档,只记录实际执行过的验证结果。 `(依赖于: 6.2)` + +## 4. 验证矩阵 + +定向测试通过后,再执行完整门禁。建议命令如下: + +```bash +# Workspace format and lint +uv run ruff format --check . +uv run ruff check . +uv run ty check hugegraph-llm/src hugegraph-python-client/src + +# Docker host-port rendering +docker compose -f docker/docker-compose-network.yml config +docker compose -f docker/docker-compose-llm.yml config + +# HugeGraph-AI API / CLI affected tests +uv run pytest hugegraph-llm/src/tests -m "not integration and not hugegraph and not smoke and not external" -q + +# MCP unit/contract tests +cd hugegraph-mcp +uv run pytest -m "not live and not integration and not llm" -q + +# Client unit/contract tests and marker collection +cd .. +uv run pytest hugegraph-python-client/src/tests -m "unit or contract" -q +uv run pytest hugegraph-python-client/src/tests --collect-only -m "unit or contract" -q +uv run pytest hugegraph-python-client/src/tests --collect-only -m "integration and hugegraph" -q +``` + +真实 HugeGraph 验证必须在 HugeGraph 1.7.0 可用时执行: + +```bash +cd hugegraph-mcp +HUGEGRAPH_URL=http://127.0.0.1:8080 \ +HUGEGRAPH_GRAPH_PATH=DEFAULT/hugegraph \ +HUGEGRAPH_USER=admin \ +HUGEGRAPH_PASSWORD=admin \ +uv run pytest tests/integration/test_real_write_path.py -m real_hugegraph -q + +cd .. +HUGEGRAPH_URL=http://127.0.0.1:8080 \ +HUGEGRAPH_GRAPHSPACE=DEFAULT \ +HUGEGRAPH_GRAPH=hugegraph \ +HUGEGRAPH_USER=admin \ +HUGEGRAPH_PASSWORD=admin \ +uv run pytest hugegraph-python-client/src/tests -m "integration and hugegraph" -q +``` + +## 5. 完成门禁 + +只有同时满足以下条件,才可把本轮六项问题标记为已修复: + +- 源码启动和 Docker 默认都只从宿主机 loopback 暴露 RAG HTTP 服务。 +- readonly 非法配置经过自动测试证明会 fail-closed。 +- 所有确认式写入口都经过持久化单次消费,且并发与重启场景有测试。 +- 集合 append 在真实 HugeGraph 上验证,不再出现错误 `PARTIAL_APPLY`。 +- 非法 edge 分页组合在 MCP 边界被拒绝。 +- 三个 client 合约测试进入 unit/contract CI 收集集合。 +- `ruff`、`ty`、Compose 渲染、MCP 非外部测试、client unit/contract 测试和 HugeGraph 真实集成测试均通过。 + +如果真实 HugeGraph 服务不可用,只能将状态记录为“代码和单元测试已完成,真实集成待验证”,不能声明全部修复完成。 diff --git a/hugegraph-mcp/docs/graph_mcp_review_fix_plan.md b/hugegraph-mcp/docs/graph_mcp_review_fix_plan.md index 73a5eac8e..c1434593d 100644 --- a/hugegraph-mcp/docs/graph_mcp_review_fix_plan.md +++ b/hugegraph-mcp/docs/graph_mcp_review_fix_plan.md @@ -38,7 +38,7 @@ RUN_MCP_REAL_HUGEGRAPH_TESTS=1 uv run --project hugegraph-mcp pytest hugegraph-m 全部修复完成后,必须通过以下检查: ```bash -cd /Users/uleng/Code/hugegraph-ai +cd git diff --check apache/main...HEAD -- hugegraph-mcp uv run --project hugegraph-mcp ruff check hugegraph-mcp diff --git a/hugegraph-mcp/docs/p0a-integration-checklist.md b/hugegraph-mcp/docs/p0a-integration-checklist.md index 52262135f..6e10a9a82 100644 --- a/hugegraph-mcp/docs/p0a-integration-checklist.md +++ b/hugegraph-mcp/docs/p0a-integration-checklist.md @@ -11,9 +11,9 @@ Use a disposable graph. Do not run this checklist against a production graph. Use the repository's existing Docker path: ```bash -cd /Users/uleng/Code/hugegraph-ai +cd cp docker/env.template docker/.env -sed -i.bak "s|PROJECT_PATH=path_to_project|PROJECT_PATH=/Users/uleng/Code/hugegraph-ai|" docker/.env +sed -i.bak "s|PROJECT_PATH=path_to_project|PROJECT_PATH=|" docker/.env cd docker docker compose -f docker-compose-network.yml up -d hugegraph-server curl -f http://127.0.0.1:8080/versions @@ -26,7 +26,7 @@ curl -f http://127.0.0.1:8080/versions Set these variables in the shell or MCP client process that launches `hugegraph-mcp`: ```bash -cd /Users/uleng/Code/hugegraph-ai/hugegraph-mcp +cd /hugegraph-mcp export HUGEGRAPH_URL=http://127.0.0.1:8080 export HUGEGRAPH_GRAPH_PATH=DEFAULT/ export HUGEGRAPH_USER=admin @@ -45,6 +45,12 @@ Start the MCP server with the same environment: uv run hugegraph-mcp ``` +Confirmed write plans are single-use. The server persists only a SHA-256 digest +of each nonce and atomically consumes it before the first write side effect. A +second call with the same confirmation returns `PLAN_ALREADY_USED`, including +after an execution timeout, failure, or partial apply. Inspect the target state +and run a new dry-run instead of retrying an already submitted confirmation. + The request examples below show MCP `tools/call` payloads. Paste the `name` and `arguments` into your MCP client. Replace every `` with one short unique suffix, for example `20260705a`, and keep the same suffix through all steps. Replace `` placeholders with the numeric `expires_at` value from the dry-run response, not a quoted string. ## Step 1: Inspect Tool Contract @@ -767,6 +773,7 @@ Expected bounded-read fields: | `READONLY_VIOLATION` | Any confirm call for schema/data/property writes | `HUGEGRAPH_MCP_READONLY=true` at confirm time. Dry-run may be preview-only in readonly mode. | Set `HUGEGRAPH_MCP_READONLY=false`, restart or reconfigure the MCP process, rerun dry-run, then confirm with the new `plan_hash`. | | `PLAN_HASH_MISMATCH` | Confirm calls for schema/data/property writes | The submitted payload, graph target, schema hash, readonly state, principal, `nonce`, or `expires_at` no longer matches the dry-run plan. | Do not edit the payload between dry-run and confirm. Rerun dry-run and copy the new `plan_hash`, `nonce`, and `expires_at`. | | `PLAN_EXPIRED` | Confirm calls after waiting too long | The dry-run plan expired. Default plan TTL is 10 minutes. | Rerun dry-run and confirm within the returned `expires_at` window. | +| `PLAN_ALREADY_USED` | A confirm call repeats a nonce that already entered execution | Confirmation nonces are globally single-use. A prior call may have succeeded, failed, timed out, or partially applied. | Inspect the current graph or schema state, then rerun dry-run and use its new `plan_hash`, `nonce`, and `expires_at`. Do not retry the old confirmation. | | `TARGET_CHANGED` | Step 5 stale eliminate confirm | The vertex or edge was changed after dry-run and before confirm. | Treat this as a successful stale-plan rejection in Step 5. For real work, rerun `query_graph_data_tool`, review current state, then create a new dry-run plan. | | `NO_INDEX` | `query_graph_data_tool(operation="condition")` or Gremlin `has()` filters | HugeGraph requires an index for that property query, and P0a does not create indexes. | Use `get_by_id` or bounded `page` for this checklist. Index create/rebuild belongs to the P0b workflow. | | `PARTIAL_APPLY` | `apply_schema_tool(mode="apply")` | At least one schema operation may have been applied before a later operation failed. | Call `inspect_schema_tool`, remove already-applied operations, rerun dry-run for the remaining operations only. Do not blindly retry the original full batch. | diff --git a/hugegraph-mcp/hugegraph_mcp/config.py b/hugegraph-mcp/hugegraph_mcp/config.py index c2dc61f6e..524811504 100644 --- a/hugegraph-mcp/hugegraph_mcp/config.py +++ b/hugegraph-mcp/hugegraph_mcp/config.py @@ -18,9 +18,11 @@ import logging import os from dataclasses import dataclass, field +from pathlib import Path from typing import Mapping -TRUE_VALUES = {"1", "true", "yes"} +TRUE_VALUES = frozenset({"1", "true", "yes", "on"}) +FALSE_VALUES = frozenset({"0", "false", "no", "off"}) LOGGER = logging.getLogger("hugegraph_mcp.config") CONFIG_ENV_NAMES = ( "HUGEGRAPH_URL", @@ -31,13 +33,15 @@ "HUGEGRAPH_PASSWORD", "HUGEGRAPH_MCP_READONLY", "HUGEGRAPH_AI_URL", + "HUGEGRAPH_AI_TOKEN", "HUGEGRAPH_AI_GRAPH_URL", "HUGEGRAPH_MCP_ALLOW_AI", "HUGEGRAPH_MCP_ADMIN_MODE", "HUGEGRAPH_MCP_TIMEOUT_SECONDS", + "HUGEGRAPH_MCP_STATE_DIR", + "XDG_STATE_HOME", ) -_CONFIG_CACHE_KEY: tuple[tuple[str, str | None], ...] | None = None -_CONFIG_CACHE_VALUE = None +_CONFIG_CACHE: tuple[tuple[tuple[str, str | None], ...], "MCPConfig"] | None = None @dataclass @@ -51,25 +55,32 @@ class MCPConfig: password: str = "" readonly: bool = True ai_url: str = "http://127.0.0.1:8001" + ai_token: str | None = None ai_graph_url: str | None = None allow_ai: bool = False admin_mode: bool = False timeout_seconds: int = 30 + state_dir: Path = field( + default_factory=lambda: Path.home() / ".local" / "state" / "hugegraph-mcp" + ) warnings: tuple[str, ...] = field(default_factory=tuple) @classmethod def from_env(cls, env: Mapping[str, str] | None = None) -> "MCPConfig": - global _CONFIG_CACHE_KEY, _CONFIG_CACHE_VALUE + global _CONFIG_CACHE use_cache = env is None - env = env if env is not None else os.environ - cache_key = _env_cache_key(env) if use_cache else None - if ( - use_cache - and cache_key == _CONFIG_CACHE_KEY - and _CONFIG_CACHE_VALUE is not None - ): - return _CONFIG_CACHE_VALUE + if use_cache: + # Read each setting once, then use that same snapshot for both the + # cache key and parsing. Reading os.environ once for the key and + # again below could otherwise cache a permissive configuration + # under a fail-closed key when the environment changes mid-call. + env = _environment_snapshot(os.environ) + cache_key = _env_cache_key(env) + if _CONFIG_CACHE is not None and cache_key == _CONFIG_CACHE[0]: + return _CONFIG_CACHE[1] + else: + cache_key = None warnings: list[str] = [] @@ -93,25 +104,48 @@ def from_env(cls, env: Mapping[str, str] | None = None) -> "MCPConfig": if split_graph is not None: graph = _non_empty(split_graph, "hugegraph") + readonly = _parse_bool( + env.get("HUGEGRAPH_MCP_READONLY"), + "HUGEGRAPH_MCP_READONLY", + True, + warnings, + ) + allow_ai = _parse_bool( + env.get("HUGEGRAPH_MCP_ALLOW_AI"), + "HUGEGRAPH_MCP_ALLOW_AI", + False, + warnings, + ) + admin_mode = _parse_bool( + env.get("HUGEGRAPH_MCP_ADMIN_MODE"), + "HUGEGRAPH_MCP_ADMIN_MODE", + False, + warnings, + ) + config = cls( url=env.get("HUGEGRAPH_URL", "http://127.0.0.1:8080"), graph=graph, graphspace=graphspace, user=env.get("HUGEGRAPH_USER", "admin"), password=env.get("HUGEGRAPH_PASSWORD", ""), - readonly=_parse_bool(env.get("HUGEGRAPH_MCP_READONLY", "true")), + readonly=readonly, ai_url=env.get("HUGEGRAPH_AI_URL", "http://127.0.0.1:8001"), + ai_token=_optional_non_empty(env.get("HUGEGRAPH_AI_TOKEN")), ai_graph_url=_optional_non_empty(env.get("HUGEGRAPH_AI_GRAPH_URL")), - allow_ai=_parse_bool(env.get("HUGEGRAPH_MCP_ALLOW_AI", "")), - admin_mode=_parse_bool(env.get("HUGEGRAPH_MCP_ADMIN_MODE", "")), + allow_ai=allow_ai, + admin_mode=admin_mode, timeout_seconds=_parse_int(env.get("HUGEGRAPH_MCP_TIMEOUT_SECONDS"), 30), + state_dir=_state_dir(env), warnings=tuple(warnings), ) for warning in config.warnings: LOGGER.warning(warning) if use_cache: - _CONFIG_CACHE_KEY = cache_key - _CONFIG_CACHE_VALUE = config + assert cache_key is not None + # Store key and value as one object so concurrent readers can + # never observe a key paired with a different configuration. + _CONFIG_CACHE = (cache_key, config) return config def is_readonly(self) -> bool: @@ -127,8 +161,21 @@ def _parse_graph_path(graph_path: str) -> tuple[str, str]: return _non_empty(graphspace, "DEFAULT"), _non_empty(graph, "hugegraph") -def _parse_bool(value: str) -> bool: - return value.strip().lower() in TRUE_VALUES +def _parse_bool( + value: str | None, + env_name: str, + safe_default: bool, + warnings: list[str], +) -> bool: + if value is None: + return safe_default + normalized = value.strip().lower() + if normalized in TRUE_VALUES: + return True + if normalized in FALSE_VALUES: + return False + warnings.append(f"Invalid boolean configuration: {env_name}; using safe default") + return safe_default def _parse_int(value: str | None, default: int) -> int: @@ -160,10 +207,29 @@ def _optional_non_empty(value: str | None) -> str | None: return stripped or None +def _state_dir(env: Mapping[str, str]) -> Path: + explicit = env.get("HUGEGRAPH_MCP_STATE_DIR") + if explicit is not None and explicit.strip(): + return Path(explicit).expanduser() + xdg_state_home = env.get("XDG_STATE_HOME") + if xdg_state_home is not None and xdg_state_home.strip(): + return Path(xdg_state_home).expanduser() / "hugegraph-mcp" + return Path.home() / ".local" / "state" / "hugegraph-mcp" + + def _env_cache_key(env: Mapping[str, str]) -> tuple[tuple[str, str | None], ...]: return tuple((name, env.get(name)) for name in CONFIG_ENV_NAMES) +def _environment_snapshot(env: Mapping[str, str]) -> dict[str, str]: + snapshot: dict[str, str] = {} + for name in CONFIG_ENV_NAMES: + value = env.get(name) + if value is not None: + snapshot[name] = value + return snapshot + + class RuntimeConfigProxy: """Compatibility proxy for code that imports config directly.""" diff --git a/hugegraph-mcp/hugegraph_mcp/confirmable_workflow.py b/hugegraph-mcp/hugegraph_mcp/confirmable_workflow.py index 552dd7431..61e9d4f8e 100644 --- a/hugegraph-mcp/hugegraph_mcp/confirmable_workflow.py +++ b/hugegraph-mcp/hugegraph_mcp/confirmable_workflow.py @@ -15,7 +15,157 @@ from typing import Any +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.confirmation_store import ( + ConfirmationAlreadyUsedError, + ConfirmationNotIssuedError, + ConfirmationPlanExpiredError, + ConfirmationPlanMismatchError, + ConfirmationStore, + ConfirmationStoreUnavailableError, +) from hugegraph_mcp.envelope import ErrorType, envelope_err +from hugegraph_mcp.plan_hash import PlanContext, verify_plan_hash + + +def issue_plan(context: PlanContext, plan_hash: str) -> 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, + ) + except ConfirmationPlanExpiredError: + return plan_hash_error( + error_type=ErrorType.PLAN_EXPIRED, + details={"reason": "The generated plan TTL is outside the allowed window."}, + mismatch_message="The generated confirmation plan is invalid.", + expired_message="The generated confirmation plan is invalid.", + suggestion="Run dry_run again.", + ) + except ConfirmationPlanMismatchError: + return plan_hash_error( + error_type=ErrorType.PLAN_HASH_MISMATCH, + details={"reason": "The nonce is already bound to another issued plan."}, + mismatch_message="The confirmation nonce conflicts with another plan.", + suggestion="Run dry_run again without supplying a nonce.", + ) + except ConfirmationStoreUnavailableError: + return plan_hash_error( + error_type=ErrorType.SERVER_ERROR, + details={"reason": "Confirmation state is unavailable."}, + mismatch_message="Confirmation state could not be recorded safely.", + suggestion="Restore writable persistent confirmation state, then retry.", + ) + return None + + +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) + 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 + 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." + ) + }, + mismatch_message="This confirmation plan has already been used.", + suggestion="Inspect the current target state, then run dry_run again.", + source=source, + ) + + +def verify_and_consume_plan( + *, + submitted_hash: str, + tool_name: str, + mode: str, + payload_digest: str, + schema_hash: str | None = None, + nonce: str | None = None, + expires_at: float | int | None = None, + 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 = verify_plan_hash( + 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, + ) + if not valid: + return False, error_type, details + + if MCPConfig.from_env().is_readonly(): + return ( + False, + ErrorType.READONLY_VIOLATION, + {"reason": "Write confirmation is disabled in readonly mode."}, + ) + + try: + ConfirmationStore.from_config().consume( + nonce=nonce or "", + plan_hash=submitted_hash, + expires_at=int(expires_at or 0), + ) + except ConfirmationAlreadyUsedError: + return ( + False, + ErrorType.PLAN_ALREADY_USED, + { + "reason": ( + "This confirmation has already been used. Inspect the target " + "state and run dry_run again." + ) + }, + ) + except ConfirmationNotIssuedError: + return ( + False, + ErrorType.PLAN_HASH_MISMATCH, + {"reason": "No server-issued dry-run plan matches this confirmation."}, + ) + except ConfirmationPlanMismatchError: + return ( + False, + ErrorType.PLAN_HASH_MISMATCH, + {"reason": "The confirmation does not match the server-issued plan."}, + ) + except ConfirmationPlanExpiredError: + return ( + False, + ErrorType.PLAN_EXPIRED, + {"reason": "The server-issued plan has expired."}, + ) + except ConfirmationStoreUnavailableError: + return ( + False, + ErrorType.SERVER_ERROR, + { + "reason": ( + "Confirmation state is unavailable. The write was blocked " + "before execution." + ) + }, + ) + return True, None, None def mark_readonly_preview( @@ -53,11 +203,21 @@ def plan_hash_error( source: str | None = None, ) -> dict[str, Any]: resolved_error_type = error_type or ErrorType.PLAN_HASH_MISMATCH - message = ( - expired_message - if resolved_error_type == ErrorType.PLAN_EXPIRED and expired_message - else mismatch_message - ) + if resolved_error_type == ErrorType.PLAN_EXPIRED and expired_message: + 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." + ) + 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." + ) + else: + message = mismatch_message return envelope_err( resolved_error_type, message, diff --git a/hugegraph-mcp/hugegraph_mcp/confirmation_store.py b/hugegraph-mcp/hugegraph_mcp/confirmation_store.py new file mode 100644 index 000000000..4e8aec13b --- /dev/null +++ b/hugegraph-mcp/hugegraph_mcp/confirmation_store.py @@ -0,0 +1,245 @@ +# 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. + +"""Persistent, atomic ledger for single-use write confirmations.""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import sqlite3 +import time + +from hugegraph_mcp.config import MCPConfig +from hugegraph_mcp.plan_hash import DEFAULT_PLAN_TTL_SECONDS + + +class ConfirmationAlreadyUsedError(Exception): + """The nonce has already authorized a write attempt.""" + + +class ConfirmationStoreUnavailableError(Exception): + """The durable confirmation ledger cannot safely record a write.""" + + +class ConfirmationNotIssuedError(Exception): + """The submitted plan was not issued by a server-side dry-run.""" + + +class ConfirmationPlanMismatchError(Exception): + """The submitted confirmation does not match the issued plan.""" + + +class ConfirmationPlanExpiredError(Exception): + """The server-issued plan is expired or exceeds the allowed TTL.""" + + +class ConfirmationStore: + """SQLite-backed confirmation ledger with a globally unique nonce digest.""" + + 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 + + @classmethod + def from_config(cls) -> "ConfirmationStore": + return cls(MCPConfig.from_env().state_dir) + + def has_consumed(self, nonce: str | None) -> bool: + """Check whether a nonce was consumed without creating persistent state.""" + if not nonce or not self.database_path.exists(): + return False + nonce_digest = hashlib.sha256(nonce.encode("utf-8")).hexdigest() + try: + with sqlite3.connect(self.database_path, timeout=30) as connection: + row = connection.execute( + """ + SELECT 1 FROM consumed_confirmations + WHERE nonce_digest = ? LIMIT 1 + """, + (nonce_digest,), + ).fetchone() + except (OSError, sqlite3.Error) as exc: + raise ConfirmationStoreUnavailableError from exc + return row is not None + + def issue(self, *, nonce: str, plan_hash: str, expires_at: int) -> 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) + if expires_at <= issued_at or expires_at > issued_at + DEFAULT_PLAN_TTL_SECONDS: + raise ConfirmationPlanExpiredError + + try: + self._prepare_storage() + with sqlite3.connect(self.database_path, timeout=30) as connection: + connection.execute("PRAGMA synchronous = FULL") + self._ensure_schema(connection) + self._cleanup_expired(connection, issued_at) + connection.execute( + """ + INSERT INTO issued_confirmations ( + nonce_digest, plan_hash, expires_at, issued_at + ) VALUES (?, ?, ?, ?) + ON CONFLICT(nonce_digest) DO UPDATE SET + plan_hash = excluded.plan_hash, + expires_at = excluded.expires_at, + issued_at = excluded.issued_at + WHERE issued_confirmations.plan_hash = excluded.plan_hash + """, + (nonce_digest, plan_hash, expires_at, issued_at), + ) + row = connection.execute( + """ + SELECT plan_hash, expires_at FROM issued_confirmations + WHERE nonce_digest = ? + """, + (nonce_digest,), + ).fetchone() + if row != (plan_hash, expires_at): + raise ConfirmationPlanMismatchError + except (ConfirmationPlanExpiredError, ConfirmationPlanMismatchError): + raise + except (OSError, sqlite3.Error) as exc: + raise ConfirmationStoreUnavailableError from exc + + def consume(self, *, nonce: str, plan_hash: str, expires_at: int) -> 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()) + + try: + self._prepare_storage() + with sqlite3.connect(self.database_path, timeout=30) as connection: + connection.execute("PRAGMA synchronous = FULL") + self._ensure_schema(connection) + try: + self._cleanup_expired(connection, consumed_at) + except sqlite3.Error: + connection.rollback() + try: + connection.execute("BEGIN IMMEDIATE") + consumed = connection.execute( + """ + SELECT 1 FROM consumed_confirmations + WHERE nonce_digest = ? LIMIT 1 + """, + (nonce_digest,), + ).fetchone() + if consumed is not None: + raise ConfirmationAlreadyUsedError + issued = connection.execute( + """ + SELECT plan_hash, expires_at, issued_at + FROM issued_confirmations WHERE nonce_digest = ? + """, + (nonce_digest,), + ).fetchone() + if issued is None: + raise ConfirmationNotIssuedError + issued_hash, issued_expires_at, issued_at = 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 + ): + raise ConfirmationPlanExpiredError + connection.execute( + """ + INSERT INTO consumed_confirmations ( + nonce_digest, plan_hash, expires_at, consumed_at + ) VALUES (?, ?, ?, ?) + """, + (nonce_digest, plan_hash, int(expires_at), consumed_at), + ) + connection.execute( + "DELETE FROM issued_confirmations WHERE nonce_digest = ?", + (nonce_digest,), + ) + connection.commit() + except sqlite3.IntegrityError as exc: + raise ConfirmationAlreadyUsedError from exc + except ( + ConfirmationAlreadyUsedError, + ConfirmationNotIssuedError, + ConfirmationPlanMismatchError, + ConfirmationPlanExpiredError, + ): + raise + except (OSError, sqlite3.Error) as exc: + raise ConfirmationStoreUnavailableError from exc + + 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 < ?", + (current_time,), + ) + connection.execute( + "DELETE FROM issued_confirmations WHERE expires_at < ?", + (current_time,), + ) + connection.commit() + + def _ensure_schema(self, connection: sqlite3.Connection) -> None: + """Create additive tables so existing confirmation databases remain valid.""" + connection.execute( + """ + CREATE TABLE IF NOT EXISTS consumed_confirmations ( + nonce_digest TEXT PRIMARY KEY, + plan_hash TEXT NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at INTEGER NOT NULL + ) + """ + ) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS issued_confirmations ( + nonce_digest TEXT PRIMARY KEY, + plan_hash TEXT NOT NULL, + expires_at INTEGER NOT NULL, + issued_at INTEGER NOT NULL + ) + """ + ) + connection.commit() + + def _prepare_storage(self) -> None: + self.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + _restrict_permissions(self.state_dir, 0o700) + + descriptor = os.open( + self.database_path, + os.O_CREAT | os.O_APPEND | os.O_WRONLY, + 0o600, + ) + os.close(descriptor) + _restrict_permissions(self.database_path, 0o600) + + +def _restrict_permissions(path: Path, mode: int) -> None: + try: + path.chmod(mode) + except (NotImplementedError, OSError): + if os.name == "posix": + raise diff --git a/hugegraph-mcp/hugegraph_mcp/envelope.py b/hugegraph-mcp/hugegraph_mcp/envelope.py index f96fba868..fa8705728 100644 --- a/hugegraph-mcp/hugegraph_mcp/envelope.py +++ b/hugegraph-mcp/hugegraph_mcp/envelope.py @@ -48,6 +48,7 @@ class ErrorType(str, Enum): CONFIRM_REQUIRED = "CONFIRM_REQUIRED" PLAN_HASH_MISMATCH = "PLAN_HASH_MISMATCH" PLAN_EXPIRED = "PLAN_EXPIRED" + PLAN_ALREADY_USED = "PLAN_ALREADY_USED" TARGET_CHANGED = "TARGET_CHANGED" PARTIAL_APPLY = "PARTIAL_APPLY" NOT_FOUND = "NOT_FOUND" diff --git a/hugegraph-mcp/hugegraph_mcp/error_mapping.py b/hugegraph-mcp/hugegraph_mcp/error_mapping.py index 3e61ff16b..068a4cbd8 100644 --- a/hugegraph-mcp/hugegraph_mcp/error_mapping.py +++ b/hugegraph-mcp/hugegraph_mcp/error_mapping.py @@ -15,6 +15,16 @@ from dataclasses import dataclass +from pyhugegraph.utils.exceptions import ( + DataFormatError, + InvalidParameterError, + NotAuthorizedError, + NotFoundError, + ResponseParseError, + ServerError, + ServiceUnavailableError, +) + from hugegraph_mcp.envelope import ErrorType @@ -51,6 +61,43 @@ class ErrorClassification: def classify_hugegraph_exception(exc: Exception) -> ErrorClassification: + if isinstance(exc, NotAuthorizedError): + return ErrorClassification( + error_type=ErrorType.AUTHENTICATION_FAILED, + retryable=False, + suggestion="Check HUGEGRAPH_USER and HUGEGRAPH_PASSWORD.", + reason="authentication_error", + ) + if isinstance(exc, NotFoundError): + return ErrorClassification( + error_type=ErrorType.NOT_FOUND, + retryable=False, + suggestion="Verify the graph, graphspace, and endpoint before retrying.", + reason="not_found_error", + ) + if isinstance(exc, (InvalidParameterError, DataFormatError)): + return ErrorClassification( + error_type=ErrorType.VALIDATION_ERROR, + retryable=False, + suggestion="Check the request parameters and data format.", + reason="validation_error", + ) + if isinstance(exc, ServiceUnavailableError): + return ErrorClassification( + error_type=ErrorType.SERVER_ERROR, + retryable=True, + suggestion="Retry after checking HugeGraph Server availability.", + reason="server_error", + ) + if isinstance(exc, ResponseParseError): + return ErrorClassification( + error_type=ErrorType.SERVER_ERROR, + retryable=False, + suggestion="Check the HugeGraph response and client/server compatibility.", + reason="server_error", + ) + if isinstance(exc, ServerError): + return classify_hugegraph_error_message(str(exc)) return classify_hugegraph_error_message(str(exc)) diff --git a/hugegraph-mcp/hugegraph_mcp/gremlin_tools.py b/hugegraph-mcp/hugegraph_mcp/gremlin_tools.py index e93838dc8..541cc524b 100644 --- a/hugegraph-mcp/hugegraph_mcp/gremlin_tools.py +++ b/hugegraph-mcp/hugegraph_mcp/gremlin_tools.py @@ -22,9 +22,19 @@ import requests from pyhugegraph.client import PyHugeClient +from pyhugegraph.utils.exceptions import ( + DataFormatError, + InvalidParameterError, + NotAuthorizedError, + NotFoundError, + ResponseParseError, + ServerError, + ServiceUnavailableError, +) from hugegraph_mcp.config import MCPConfig from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok +from hugegraph_mcp.error_mapping import classify_hugegraph_exception from hugegraph_mcp.gremlin_policy import check_gremlin_read, gremlin_cost_warnings from hugegraph_mcp.guard import Capability, guard_write from hugegraph_mcp.hugegraph_client import build_hugegraph_client @@ -60,6 +70,7 @@ def get_write_client(self): "http_error": ErrorType.SERVER_ERROR, "not_found_error": ErrorType.NOT_FOUND, "unknown_error": ErrorType.SERVER_ERROR, + **{error_type.value: error_type for error_type in ErrorType}, } LIMIT_POLICIES = frozenset({"warn", "reject_unbounded", "auto_append"}) @@ -90,6 +101,8 @@ def _gremlin_error_envelope(result: dict[str, Any]) -> dict[str, Any]: def _gremlin_error_retryable(result: dict[str, Any]) -> bool: + if "retryable" in result: + return bool(result["retryable"]) error_type = result.get("error_type") if error_type in {"connection_error", "timeout_error"}: return True @@ -287,6 +300,41 @@ def _execute_gremlin_with_error_handling( "operation_type": operation_type, } + except ( + NotAuthorizedError, + NotFoundError, + InvalidParameterError, + DataFormatError, + ServiceUnavailableError, + ResponseParseError, + ServerError, + ) as e: + duration_ms = (time.perf_counter() - start) * 1000.0 + if isinstance(e, (InvalidParameterError, DataFormatError)): + return { + "success": False, + "error_type": "query_syntax_error", + "message": str(e) or type(e).__name__, + "suggestions": ["Check the Gremlin query syntax and parameters."], + "retryable": False, + "duration_ms": duration_ms, + "operation_type": operation_type, + } + classification = classify_hugegraph_exception(e) + if classification.error_type == ErrorType.NO_INDEX: + return _no_index_error_result(str(e), duration_ms, operation_type) + return { + "success": False, + # Keep the internal reason for diagnostics; _gremlin_error_envelope + # maps it to the canonical public ErrorType. + "error_type": classification.reason, + "message": str(e) or type(e).__name__, + "suggestions": [classification.suggestion], + "retryable": classification.retryable, + "duration_ms": duration_ms, + "operation_type": operation_type, + } + except ValueError as e: return { "success": False, diff --git a/hugegraph-mcp/hugegraph_mcp/hugegraph_ai_client.py b/hugegraph-mcp/hugegraph_mcp/hugegraph_ai_client.py index 6b04c1d1a..cd6b59e09 100644 --- a/hugegraph-mcp/hugegraph_mcp/hugegraph_ai_client.py +++ b/hugegraph-mcp/hugegraph_mcp/hugegraph_ai_client.py @@ -14,11 +14,12 @@ """HugeGraph-AI HTTP 客户端 — 统一请求层。 所有 AI 调用经 request() 统一处理:allow_ai 开关检查、超时控制、 -Basic Auth 注入、结构化错误返回。不抛异常。 +Bearer Token 注入、结构化错误返回。不抛异常。 """ import time from typing import Any +from urllib.parse import urlsplit import requests @@ -44,6 +45,16 @@ def request( start = time.perf_counter() cfg = cfg or MCPConfig.from_env() method = method.upper() + + if not _is_relative_path(path): + return envelope_err( + ErrorType.VALIDATION_ERROR, + "Absolute URLs are not allowed; use a relative HugeGraph-AI path", + retryable=False, + duration_ms=_duration_ms(start), + details={"method": method, "reason": "absolute_url_not_allowed"}, + ) + url = _build_url(cfg.ai_url, path) if not cfg.allow_ai: @@ -60,20 +71,29 @@ def request( ) try: + request_headers = dict(headers) if headers is not None else {} + if cfg.ai_token and not any( + name.lower() == "authorization" for name in request_headers + ): + request_headers["Authorization"] = f"Bearer {cfg.ai_token}" kwargs: dict[str, Any] = { "params": params, - "headers": headers, + "headers": request_headers or None, "timeout": cfg.timeout_seconds, } if json is not None: kwargs["json"] = json - if cfg.password: - kwargs["auth"] = (cfg.user, cfg.password) - response = requests.request(method, url, **kwargs) + try: + data = response.json() + except ValueError: + # Preserve status-aware handling for empty/HTML HTTP error bodies. + response.raise_for_status() + raise + if _is_thin_api_envelope(data) and not data["ok"]: + return _normalize_response(data, duration_ms=_duration_ms(start)) response.raise_for_status() - data = response.json() - return envelope_ok(data, duration_ms=_duration_ms(start)) + return _normalize_response(data, duration_ms=_duration_ms(start)) except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc: return _ai_error( _exception_message("HugeGraph-AI is unavailable", exc), @@ -184,11 +204,17 @@ def health_check(*, cfg: MCPConfig | None = None) -> dict[str, Any]: def _build_url(base_url: str, path: str) -> str: - if path.startswith(("http://", "https://")): - return path return f"{base_url.rstrip('/')}/{path.lstrip('/')}" +def _is_relative_path(path: str) -> bool: + try: + parsed = urlsplit(path) + except (TypeError, ValueError): + return False + return not parsed.scheme and not parsed.netloc + + def _duration_ms(start: float) -> float: return (time.perf_counter() - start) * 1000.0 @@ -217,3 +243,57 @@ def _ai_error( details=details, duration_ms=duration_ms, ) + + +def _normalize_response(data: Any, *, duration_ms: float) -> dict[str, Any]: + """Convert a Thin API envelope without wrapping it in a second envelope.""" + if not _is_thin_api_envelope(data): + return envelope_ok(data, duration_ms=duration_ms) + + remote_meta = data.get("meta") if isinstance(data.get("meta"), dict) else {} + request_id = remote_meta.get("request_id") + warnings = data.get("warnings") if isinstance(data.get("warnings"), list) else [] + next_actions = ( + data.get("next_actions") if isinstance(data.get("next_actions"), list) else [] + ) + if data["ok"]: + return envelope_ok( + data.get("data"), + duration_ms=duration_ms, + warnings=warnings, + next_actions=next_actions, + request_id=request_id, + ) + + error = data.get("error") if isinstance(data.get("error"), dict) else {} + return envelope_err( + error.get("type", ErrorType.HUGEGRAPH_AI_UNAVAILABLE), + error.get("message", "HugeGraph-AI request failed"), + suggestion=error.get("suggestion"), + retryable=( + error.get("retryable", False) + if isinstance(error.get("retryable", False), bool) + else False + ), + source=error.get("source", "hugegraph-llm"), + details=error.get("details"), + duration_ms=duration_ms, + warnings=warnings, + next_actions=next_actions, + request_id=request_id, + ) + + +def _is_thin_api_envelope(data: Any) -> bool: + meta = data.get("meta") if isinstance(data, dict) else None + return ( + isinstance(data, dict) + and isinstance(data.get("ok"), bool) + and "data" in data + and (data.get("error") is None or isinstance(data.get("error"), dict)) + and isinstance(data.get("warnings"), list) + and isinstance(data.get("next_actions"), list) + and isinstance(meta, dict) + and isinstance(meta.get("request_id"), str) + and isinstance(meta.get("duration_ms"), (int, float)) + ) diff --git a/hugegraph-mcp/hugegraph_mcp/server.py b/hugegraph-mcp/hugegraph_mcp/server.py index b5d2cc356..13b398783 100644 --- a/hugegraph-mcp/hugegraph_mcp/server.py +++ b/hugegraph-mcp/hugegraph_mcp/server.py @@ -588,7 +588,11 @@ def refresh_vid_embeddings_tool(confirm: bool = False) -> dict: @mcp.tool() def execute_gremlin_write_tool(gremlin_query: str) -> dict: - """执行 Gremlin 写查询 — 需 admin mode 且 readonly=false。""" + """Break-glass direct Gremlin write for an isolated trusted admin transport. + + 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. + """ blocked = _admin_gate("execute_gremlin_write_tool", requires_write=True) if blocked: return blocked diff --git a/hugegraph-mcp/hugegraph_mcp/tools/generate_gremlin.py b/hugegraph-mcp/hugegraph_mcp/tools/generate_gremlin.py index 2a802575c..1609fc31e 100644 --- a/hugegraph-mcp/hugegraph_mcp/tools/generate_gremlin.py +++ b/hugegraph-mcp/hugegraph_mcp/tools/generate_gremlin.py @@ -19,12 +19,22 @@ from typing import Any +from hugegraph_mcp.config import MCPConfig from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok from hugegraph_mcp.gremlin_policy import check_gremlin_read from hugegraph_mcp.gremlin_tools import execute_gremlin_read from hugegraph_mcp.hugegraph_ai_client import post +_GENERATION_OUTPUT_TYPES = frozenset( + { + "match_result", + "template_gremlin", + "raw_gremlin", + } +) + + def generate_gremlin( query: str, execute: bool = False, @@ -37,7 +47,38 @@ def generate_gremlin( limit_policy 透传给 execute_gremlin_read,默认 warn 保持兼容。 """ - payload: dict[str, Any] = {"query": query} + invalid_output_types = sorted( + { + output_type if isinstance(output_type, str) else repr(output_type) + for output_type in (output_types or []) + if not isinstance(output_type, str) + or output_type not in _GENERATION_OUTPUT_TYPES + } + ) + if invalid_output_types: + return envelope_err( + ErrorType.VALIDATION_ERROR, + "generate_gremlin only accepts generation output types.", + suggestion=( + "Use match_result, template_gremlin, or raw_gremlin. " + "Execute generated Gremlin through the MCP read execution path." + ), + details={ + "invalid_output_types": invalid_output_types, + "allowed_output_types": sorted(_GENERATION_OUTPUT_TYPES), + }, + ) + + cfg = MCPConfig.from_env() + client_config = { + "graph": cfg.graph, + } + if cfg.graphspace: + client_config["gs"] = cfg.graphspace + payload: dict[str, Any] = { + "query": query, + "client_config": client_config, + } if output_types is not None: payload["output_types"] = output_types @@ -55,8 +96,27 @@ def generate_gremlin( template_gremlin = ai_data.get("template_gremlin") raw_gremlin = ai_data.get("raw_gremlin") - gremlin = template_gremlin or raw_gremlin or ai_data.get("gremlin") - if not gremlin: + match_result = ai_data.get("match_result") + requested = set(output_types or []) + gremlin_candidates = [] + if not requested or "template_gremlin" in requested: + gremlin_candidates.append(template_gremlin) + if not requested or "raw_gremlin" in requested: + gremlin_candidates.append(raw_gremlin) + if not requested: + gremlin_candidates.append(ai_data.get("gremlin")) + gremlin = next( + ( + candidate + for candidate in gremlin_candidates + if isinstance(candidate, str) and candidate.strip() + ), + None, + ) + requires_gremlin = not requested or bool( + requested & {"template_gremlin", "raw_gremlin"} + ) + if requires_gremlin and gremlin is None: return envelope_err( ErrorType.FLOW_EXECUTION_FAILED, "HugeGraph-AI did not return Gremlin.", @@ -75,6 +135,7 @@ def generate_gremlin( "gremlin": gremlin, "template_gremlin": template_gremlin, "raw_gremlin": raw_gremlin, + "match_result": match_result, "is_readonly": is_readonly, "risk_level": risk_level, "requires_index": requires_index, diff --git a/hugegraph-mcp/hugegraph_mcp/tools/ingest_graph_data.py b/hugegraph-mcp/hugegraph_mcp/tools/ingest_graph_data.py index 87cb6230c..3307a330d 100644 --- a/hugegraph-mcp/hugegraph_mcp/tools/ingest_graph_data.py +++ b/hugegraph-mcp/hugegraph_mcp/tools/ingest_graph_data.py @@ -43,6 +43,13 @@ 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.envelope import ErrorType, envelope_err, envelope_ok from hugegraph_mcp.guard import Capability, guard from hugegraph_mcp.hugegraph_ai_client import post @@ -56,16 +63,38 @@ from hugegraph_mcp.tools.live_schema import fetch_live_schema_or_none -def _property_types(raw_schema: dict[str, Any]) -> dict[str, str]: - types: dict[str, str] = {} - for prop in raw_schema.get("propertykeys", []): +_DATA_TYPE_ALIASES = {"INTEGER": "INT", "BOOL": "BOOLEAN"} +_SUPPORTED_DATA_TYPES = { + "TEXT", + "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): - types[name] = data_type.upper() - return types + 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: @@ -81,7 +110,47 @@ def _value_matches_type(value: Any, data_type: str) -> bool: return isinstance(value, bool) if data_type in {"DATE", "BLOB"}: return isinstance(value, str) - return True + 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 def _indexed_labels(raw_schema: dict[str, Any]) -> dict[str, set[str]]: @@ -274,6 +343,11 @@ def _schema_vertex_info(raw_schema: dict[str, Any]) -> dict[str, dict[str, Any]] if isinstance(name, str): info[name] = { "id": vertex_label.get("id"), + "id_strategy": str( + vertex_label.get("id_strategy") + or vertex_label.get("idStrategy") + or "PRIMARY_KEY" + ).upper(), "primary_keys": _primary_key_names(vertex_label), } return info @@ -473,7 +547,8 @@ def validate_graph_payload( schema_vlabels: set[str] = set() schema_props: dict[str, set[str]] = {} schema_primary_keys: dict[str, list[str]] = {} - schema_property_types: dict[str, str] = {} + schema_id_strategies: dict[str, str] = {} + schema_property_specs: dict[str, tuple[str, str]] = {} schema_elabels: dict[str, dict[str, Any]] = {} schema_eprops: dict[str, set[str]] = {} indexed_labels = {"VERTEX": set(), "EDGE": set()} @@ -482,7 +557,7 @@ def validate_graph_payload( if schema_available: # 把 live schema 摘成 label -> 属性/主键/类型表,后续校验只依赖这份快照。 # 这避免遍历过程中 schema 被重复读取导致前后判断不一致。 - schema_property_types = _property_types(raw) + schema_property_specs = _property_specs(raw) for vl in raw.get("vertexlabels", []): if not isinstance(vl, dict): continue @@ -491,6 +566,9 @@ 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() for el in raw.get("edgelabels", []): if not isinstance(el, dict): continue @@ -531,12 +609,38 @@ def validate_graph_payload( errors.append( f"vertex {idx} property '{prop_name}' does not exist on label '{label}'" ) - data_type = schema_property_types.get(prop_name) - if data_type and not _value_matches_type(prop_value, data_type): - errors.append( - f"vertex {idx} property '{prop_name}' expects {data_type}, got {type(prop_value).__name__}" - ) + value_error = _property_value_error( + item_kind="vertex", + item_index=idx, + property_name=prop_name, + value=prop_value, + spec=schema_property_specs.get(prop_name), + ) + if value_error: + errors.append(value_error) primary_keys = schema_primary_keys.get(label, []) + id_strategy = schema_id_strategies.get(label) + 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}'" + ) + elif not isinstance(explicit_id, str): + errors.append( + f"vertex {idx} id for CUSTOMIZE_STRING label '{label}' must be a string, " + f"got {type(explicit_id).__name__}" + ) + 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}'" + ) + 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, " + f"got {type(explicit_id).__name__}" + ) if primary_keys: # PRIMARY_KEY label 必须提供完整主键;否则 HugeGraph 无法构造稳定 id, # 后续边端点也无法可靠解析到这个顶点。 @@ -564,7 +668,6 @@ def validate_graph_payload( ) else: vertex_identity_index[identity] = idx - explicit_id = vertex.get("id") if _identity_value_present(explicit_id): # 同一个 payload 内不允许重复 id 或重复主键身份,避免边端点解析到多义目标。 identity = (label, "id", explicit_id) @@ -642,11 +745,15 @@ def validate_graph_payload( errors.append( f"edge {idx} property '{prop_name}' does not exist on label '{label}'" ) - data_type = schema_property_types.get(prop_name) - if data_type and not _value_matches_type(prop_value, data_type): - errors.append( - f"edge {idx} property '{prop_name}' expects {data_type}, got {type(prop_value).__name__}" - ) + value_error = _property_value_error( + item_kind="edge", + item_index=idx, + property_name=prop_name, + value=prop_value, + spec=schema_property_specs.get(prop_name), + ) + if value_error: + errors.append(value_error) if source is None and target is None: continue if source is None: @@ -785,6 +892,11 @@ 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 + live_schema = _fetch_live_schema() if live_schema is None: return envelope_err( @@ -832,23 +944,40 @@ def ingest_graph_data_via_ai( ) if dry_run: - return envelope_ok( - { - "plan_hash": compute_plan_hash(plan_context), - "plan_context": { - "nonce": plan_context.nonce, - "expires_at": plan_context.expires_at, - "graph_url": plan_context.graph_url, - "graph_name": plan_context.graph_name, - "graphspace": plan_context.graphspace, - "principal": plan_context.principal, - "readonly": plan_context.readonly, - }, - "mutation_summary": mutation_summary, - "warnings": warnings, + plan_hash_value = compute_plan_hash(plan_context) + payload = { + "plan_hash": plan_hash_value, + "plan_context": { + "nonce": plan_context.nonce, + "expires_at": plan_context.expires_at, + "graph_url": plan_context.graph_url, + "graph_name": plan_context.graph_name, + "graphspace": plan_context.graphspace, + "principal": plan_context.principal, + "readonly": plan_context.readonly, }, - warnings=warnings, - ) + "mutation_summary": mutation_summary, + "warnings": warnings, + "confirmable": True, + } + next_actions: list[str] = [] + if MCPConfig.from_env().is_readonly(): + payload, readonly_warnings, next_actions = mark_readonly_preview( + payload, + warning=( + "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." + ), + ) + warnings = list(warnings) + readonly_warnings + 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) if violation is not None: @@ -863,9 +992,7 @@ def ingest_graph_data_via_ai( # 使用目标绑定验证:重新读取 config 和 schema,重新计算哈希 # nonce 必须从 dry_run 返回的 plan_context 中传回 - from hugegraph_mcp.plan_hash import verify_plan_hash - - valid, error_type, details = verify_plan_hash( + valid, error_type, details = verify_and_consume_plan( submitted_hash=plan_hash, tool_name="ingest_graph_data", mode="import", @@ -875,16 +1002,16 @@ def ingest_graph_data_via_ai( expires_at=expires_at, ) if not valid: - message = ( - "Plan has expired. Run dry_run=True again and use the returned plan_hash." - if error_type == ErrorType.PLAN_EXPIRED - else "Plan hash mismatch: config, schema, or payload has changed since dry_run." - ) - return envelope_err( - error_type or ErrorType.PLAN_HASH_MISMATCH, - message, - suggestion="Run dry_run=True again and use the returned plan_hash.", + 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 again and use the returned plan_hash.", ) batch_id = f"batch-{uuid4().hex[:12]}" diff --git a/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py b/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py index 27354d1b7..2ca0fcd6f 100644 --- a/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py +++ b/hugegraph-mcp/hugegraph_mcp/tools/manage_graph_data.py @@ -24,8 +24,11 @@ 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 from hugegraph_mcp.guard import Capability, guard @@ -33,7 +36,6 @@ build_plan_context, compute_payload_digest, compute_plan_hash, - verify_plan_hash, ) from hugegraph_mcp.tools import ingest_graph_data from hugegraph_mcp.tools.graph_data_execute import ( @@ -119,6 +121,11 @@ def manage_graph_data( details={"mode": mode}, ) + if not dry_run and confirm: + replay_error = replayed_plan_error(nonce) + if replay_error is not None: + return replay_error + # 写入前必须读取 live schema。这里不允许在 schema 缺失时降级执行, # 因为主键、端点和属性合法性都依赖当前图的真实 schema。 live_schema = _fetch_live_schema() @@ -207,6 +214,10 @@ def manage_graph_data( ) warnings.extend(readonly_warnings) next_actions.extend(readonly_next_actions) + else: + issue_error = issue_plan(plan_context, target_bound_hash) + if issue_error is not None: + return issue_error return envelope_ok( dry_run_result, warnings=warnings, @@ -226,7 +237,7 @@ def manage_graph_data( ) schema_summary = _schema_summary(live_schema) - valid, error_type, details = verify_plan_hash( + valid, error_type, details = verify_and_consume_plan( submitted_hash=plan_hash, tool_name=plan_tool_name, mode=mode, diff --git a/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py b/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py index 4680aaa86..937ceb780 100644 --- a/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py +++ b/hugegraph-mcp/hugegraph_mcp/tools/manage_schema.py @@ -19,13 +19,20 @@ """ from copy import deepcopy +from dataclasses import replace from typing import Any from pyhugegraph.client import PyHugeClient from hugegraph_mcp import schema_tools from hugegraph_mcp.config import MCPConfig -from hugegraph_mcp.confirmable_workflow import confirm_required_error, plan_hash_error +from hugegraph_mcp.confirmable_workflow import ( + confirm_required_error, + issue_plan, + plan_hash_error, + replayed_plan_error, + verify_and_consume_plan, +) from hugegraph_mcp.envelope import ErrorType, envelope_err, envelope_ok from hugegraph_mcp.guard import Capability, guard from hugegraph_mcp.hugegraph_client import build_hugegraph_client @@ -34,7 +41,6 @@ build_plan_context, compute_payload_digest, compute_plan_hash, - verify_plan_hash, ) from hugegraph_mcp.tools.live_schema import current_live_schema from hugegraph_mcp.tools.schema_utils import normalized_schema_summary @@ -70,6 +76,10 @@ "create_index_label": ("name", "base_label"), } +UNSUPPORTED_EDGE_LABEL_FIELDS = frozenset( + {"parent_label", "parentLabel", "edgelabel_type", "edgeLabelType"} +) + PROPERTY_KEY_DATA_TYPES = frozenset( { "TEXT", @@ -786,6 +796,24 @@ 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 + ) + 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." + ), + ) + ) for field in ("source_label", "target_label"): label = operation[field] if label not in available_vertex_labels: @@ -1460,6 +1488,19 @@ def manage_schema( if error: return error result = dry_run_schema_operations(operations, live_schema, nonce=nonce) + if result.get("valid") and not MCPConfig.from_env().is_readonly(): + plan_context = _build_schema_plan_context( + operations=operations, + live_schema=live_schema, + nonce=result["plan_context"]["nonce"], + ) + plan_context = replace( + plan_context, + expires_at=int(result["plan_context"]["expires_at"]), + ) + issue_error = issue_plan(plan_context, result["plan_hash"]) + if issue_error is not None: + return issue_error if result.get("valid") and MCPConfig.from_env().is_readonly(): result["confirmable"] = False result["readonly_preview_only"] = True @@ -1484,6 +1525,10 @@ def manage_schema( ) if mode == "apply": + if confirm: + replay_error = replayed_plan_error(nonce) + if replay_error is not None: + return replay_error live_schema, error = _safe_fetch_live_schema() if error: return error @@ -1509,7 +1554,7 @@ def manage_schema( "with plan_hash, nonce, and expires_at." ), ) - valid, error_type, details = verify_plan_hash( + valid, error_type, details = verify_and_consume_plan( submitted_hash=plan_hash, tool_name="apply_schema_tool", mode="apply", diff --git a/hugegraph-mcp/hugegraph_mcp/tools/mutate_graph_properties.py b/hugegraph-mcp/hugegraph_mcp/tools/mutate_graph_properties.py index 80f02532c..2d1e55279 100644 --- a/hugegraph-mcp/hugegraph_mcp/tools/mutate_graph_properties.py +++ b/hugegraph-mcp/hugegraph_mcp/tools/mutate_graph_properties.py @@ -21,8 +21,11 @@ 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, @@ -37,11 +40,11 @@ build_plan_context, compute_payload_digest, compute_plan_hash, - verify_plan_hash, ) from hugegraph_mcp.tools.live_schema import current_live_schema from hugegraph_mcp.tools.schema_utils import ( normalized_schema_summary, + property_cardinalities, property_names, schema_payload, ) @@ -91,15 +94,23 @@ 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 + cardinalities = property_cardinalities(live_schema) schema_error = _validate_properties_against_schema( target=target, target_item=target_item, + operation=operation, properties=properties, live_schema=live_schema, + cardinalities=cardinalities, ) if schema_error is not None: return schema_error @@ -110,6 +121,7 @@ def mutate_graph_properties( id=id, properties=properties, before=target_item, + cardinalities=cardinalities, ) nonce = _nonce_with_snapshot(nonce, preview["target_snapshot_digest"]) plan_context = _build_mutation_plan_context( @@ -151,6 +163,10 @@ 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) @@ -184,7 +200,7 @@ def mutate_graph_properties( next_actions=["Call query_graph_data_tool to inspect the current target."], ) - valid, error_type, details = verify_plan_hash( + valid, error_type, details = verify_and_consume_plan( submitted_hash=plan_hash, tool_name="mutate_graph_properties_tool", mode="mutate", @@ -214,6 +230,7 @@ def mutate_graph_properties( properties=properties, before=target_item, planned_after=preview["after"], + cardinalities=cardinalities, payload=payload, ) @@ -326,8 +343,10 @@ def _validate_properties_against_schema( *, target: str, target_item: dict[str, Any], + operation: str, properties: dict[str, Any], live_schema: dict[str, Any] | None, + cardinalities: dict[str, str], ) -> dict[str, Any] | None: raw_schema = schema_payload(live_schema) if raw_schema is None: @@ -364,6 +383,19 @@ def _validate_properties_against_schema( source="mutate_graph_properties_tool", details={"label": label, "unknown_properties": unknown}, ) + if operation == "append": + for name, value in properties.items(): + cardinality = cardinalities.get(name, "SINGLE") + if cardinality in {"LIST", "SET"} and not isinstance(value, list): + return _validation_error( + f"Property {name!r} requires a collection value for append.", + f"Pass a JSON array for {cardinality} property {name!r}.", + { + "property": name, + "cardinality": cardinality, + "value_type": type(value).__name__, + }, + ) return None @@ -374,9 +406,15 @@ def _preview_mutation( id: Any, properties: dict[str, Any], before: dict[str, Any], + cardinalities: dict[str, str], ) -> dict[str, Any]: before_properties = dict(before.get("properties") or {}) - after_properties = _apply_property_preview(before_properties, operation, properties) + after_properties = _apply_property_preview( + before_properties, + operation, + properties, + cardinalities, + ) after = dict(before) after["properties"] = after_properties return { @@ -396,10 +434,20 @@ def _apply_property_preview( before: dict[str, Any], operation: str, properties: dict[str, Any], + cardinalities: dict[str, str], ) -> dict[str, Any]: after = dict(before) if operation == "append": - after.update(properties) + for name, value in properties.items(): + cardinality = cardinalities.get(name, "SINGLE") + if cardinality == "LIST": + after[name] = [*_existing_collection(after.get(name)), *value] + elif cardinality == "SET": + after[name] = _stable_unique( + [*_existing_collection(after.get(name)), *value] + ) + else: + after[name] = value return after for key in properties: after.pop(key, None) @@ -414,6 +462,7 @@ def _execute_and_verify( 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: @@ -498,7 +547,7 @@ def _execute_and_verify( ], ) - if not _properties_match(post_read, planned_after): + 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={ @@ -633,9 +682,47 @@ 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]) -> bool: - return (post_read.get("properties") or {}) == ( - planned_after.get("properties") or {} +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 [] + return list(value) if isinstance(value, list) else [value] + + +def _stable_unique(values: list[Any]) -> list[Any]: + result: list[Any] = [] + for value in values: + if not any(existing == value for existing in result): + result.append(value) + 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 ) diff --git a/hugegraph-mcp/hugegraph_mcp/tools/query_graph_data.py b/hugegraph-mcp/hugegraph_mcp/tools/query_graph_data.py index 9862c0593..b21677dc0 100644 --- a/hugegraph-mcp/hugegraph_mcp/tools/query_graph_data.py +++ b/hugegraph-mcp/hugegraph_mcp/tools/query_graph_data.py @@ -73,6 +73,7 @@ def query_graph_data( label=label, properties=properties, limit=limit, + page=page, vertex_id=vertex_id, direction=direction, ) @@ -133,6 +134,7 @@ def _validate_inputs( label: str | None, properties: dict[str, Any] | None, limit: int | None, + page: str | None, vertex_id: Any, direction: str | None, ) -> dict[str, Any] | None: @@ -148,6 +150,9 @@ def _validate_inputs( "Use one of: get_by_id, get_by_ids, page, condition.", {"operation": operation}, ) + limit_error = _validate_limit(limit) + if limit_error is not None: + return limit_error if operation == "get_by_id" and _is_blank(id): return _validation_error( "id is required for operation='get_by_id'.", @@ -200,11 +205,16 @@ def _validate_inputs( "Pass exact-match property filters, or use operation='page' for bounded scans.", {"operation": operation, "properties": properties}, ) - if operation in {"page", "condition"}: - limit_error = _validate_limit(limit) - if limit_error is not None: - return limit_error if target == "edge" and operation in {"page", "condition"}: + if operation == "page" and vertex_id is not None and not _is_blank(page): + return _validation_error( + "vertex_id and page cannot be combined for edge page queries.", + ( + "For a vertex-scoped query, pass vertex_id and direction without " + "page. For ordinary pagination, pass page without vertex_id." + ), + {"vertex_id": vertex_id, "page": page}, + ) if vertex_id is not None and direction is None: return _validation_error( "direction is required when querying edges by vertex_id.", diff --git a/hugegraph-mcp/hugegraph_mcp/tools/schema_utils.py b/hugegraph-mcp/hugegraph_mcp/tools/schema_utils.py index 96b304218..85392a770 100644 --- a/hugegraph-mcp/hugegraph_mcp/tools/schema_utils.py +++ b/hugegraph-mcp/hugegraph_mcp/tools/schema_utils.py @@ -20,6 +20,7 @@ "edge_schema_endpoint_label", "normalized_schema_summary", "primary_key_names", + "property_cardinalities", "property_names", "schema_name", "schema_payload", @@ -41,6 +42,39 @@ def property_names(properties: Any) -> set[str]: return {name for prop in properties if (name := schema_name(prop))} +def property_cardinalities( + live_schema: dict[str, Any] | None, +) -> dict[str, str]: + """Return property-key cardinalities from supported live-schema shapes.""" + raw = schema_payload(live_schema) + if raw is None: + return {} + property_keys = _property_key_items(raw) + if not isinstance(property_keys, list): + return {} + + cardinalities: dict[str, str] = {} + for item in property_keys: + if not isinstance(item, dict): + continue + name = _field_value(item, "name", "property_name", "propertyName") + cardinality = _field_value( + item, + "cardinality", + "cardinality_type", + "cardinalityType", + ) + if not isinstance(name, str): + continue + normalized = str(cardinality or "SINGLE").strip().upper() + cardinalities[name] = normalized + return cardinalities + + +def _property_key_items(raw_schema: dict[str, Any]) -> Any: + return _field_value(raw_schema, "propertykeys", "property_keys", "propertyKeys") + + def primary_key_names(vertex_label: dict[str, Any]) -> list[str]: primary_keys = vertex_label.get("primary_keys") if primary_keys is None: @@ -85,6 +119,8 @@ def _normalize_named_list(values: Any) -> list[str]: def _normalize_schema_items( items: Any, field_aliases: list[tuple[str, tuple[str, ...]]], + *, + name_aliases: tuple[str, ...] = ("name",), ) -> list[dict[str, Any]]: normalized: list[dict[str, Any]] = [] if not isinstance(items, list): @@ -93,7 +129,7 @@ def _normalize_schema_items( for item in items: if not isinstance(item, dict): continue - name = item.get("name") + name = _field_value(item, *name_aliases) if not isinstance(name, str): continue result: dict[str, Any] = {"name": name} @@ -127,15 +163,20 @@ def normalized_schema_summary( # 元数据被刻意忽略,防止无关字段变化导致 confirm 阶段误拒。 return { "propertykeys": _normalize_schema_items( - raw.get("propertykeys"), + _property_key_items(raw), [ ("data_type", ("data_type", "dataType")), - ("cardinality", ("cardinality",)), + ( + "cardinality", + ("cardinality", "cardinality_type", "cardinalityType"), + ), ], + name_aliases=("name", "property_name", "propertyName"), ), "vertexlabels": _normalize_schema_items( raw.get("vertexlabels"), [ + ("id_strategy", ("id_strategy", "idStrategy")), ("properties", ("properties",)), ("primary_keys", ("primary_keys", "primaryKeys")), ("nullable_keys", ("nullable_keys", "nullableKeys")), diff --git a/hugegraph-mcp/pyproject.toml b/hugegraph-mcp/pyproject.toml index 7bdde9993..8e02f9b55 100644 --- a/hugegraph-mcp/pyproject.toml +++ b/hugegraph-mcp/pyproject.toml @@ -24,7 +24,7 @@ authors = [ dependencies = [ "fastmcp>=2.2.0", - "hugegraph-python-client", + "hugegraph-python-client>=1.7.0", ] [project.scripts] hugegraph-mcp = "hugegraph_mcp.server:main" diff --git a/hugegraph-mcp/tests/conftest.py b/hugegraph-mcp/tests/conftest.py new file mode 100644 index 000000000..684b31dc0 --- /dev/null +++ b/hugegraph-mcp/tests/conftest.py @@ -0,0 +1,22 @@ +# 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. + +import pytest + + +@pytest.fixture(autouse=True) +def isolate_confirmation_state(monkeypatch, tmp_path): + """Keep persistent confirmation records isolated between tests.""" + monkeypatch.setenv("HUGEGRAPH_MCP_STATE_DIR", str(tmp_path / "state")) diff --git a/hugegraph-mcp/tests/integration/test_real_write_path.py b/hugegraph-mcp/tests/integration/test_real_write_path.py index 1c2d4a8b5..bffdd8c5e 100644 --- a/hugegraph-mcp/tests/integration/test_real_write_path.py +++ b/hugegraph-mcp/tests/integration/test_real_write_path.py @@ -469,6 +469,76 @@ def test_edge_by_id_query_and_mutate_handles_real_hugegraph_edge_id( assert names.name_key not in query_result["data"]["items"][0]["properties"] +def test_collection_append_matches_real_vertex_list_and_edge_set(hugegraph_client): + names = _schema_names("collection_append") + _ensure_collection_schema(hugegraph_client, names) + graph = hugegraph_client.graph() + alice = graph.addVertex( + names.vertex_label, + {names.name_key: "Alice", names.list_key: ["a"]}, + ) + bob = graph.addVertex( + names.vertex_label, + {names.name_key: "Bob"}, + ) + edge = graph.addEdge( + names.edge_label, + alice.id, + bob.id, + {names.set_key: ["a"]}, + ) + + vertex_dry_run = server.mutate_graph_properties_tool( + target="vertex", + operation="append", + id=alice.id, + properties={names.list_key: ["b", "b"]}, + ) + assert vertex_dry_run["ok"] is True + assert vertex_dry_run["data"]["after"]["properties"][names.list_key] == [ + "a", + "b", + "b", + ] + vertex_context = vertex_dry_run["data"]["plan_context"] + vertex_result = server.mutate_graph_properties_tool( + target="vertex", + operation="append", + id=alice.id, + properties={names.list_key: ["b", "b"]}, + dry_run=False, + confirm=True, + plan_hash=vertex_dry_run["data"]["plan_hash"], + 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"] + + edge_dry_run = server.mutate_graph_properties_tool( + target="edge", + operation="append", + id=edge.id, + properties={names.set_key: ["b", "a"]}, + ) + assert edge_dry_run["ok"] is True + assert edge_dry_run["data"]["after"]["properties"][names.set_key] == ["a", "b"] + edge_context = edge_dry_run["data"]["plan_context"] + edge_result = server.mutate_graph_properties_tool( + target="edge", + operation="append", + id=edge.id, + properties={names.set_key: ["b", "a"]}, + dry_run=False, + confirm=True, + plan_hash=edge_dry_run["data"]["plan_hash"], + 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"} + + def test_partial_write_returns_error_envelope_and_real_graph_state_matches( hugegraph_client, ): @@ -619,6 +689,8 @@ class _Names: def __init__(self, prefix: str) -> None: suffix = uuid4().hex[:8] self.name_key = f"{prefix}_name_{suffix}" + self.list_key = f"{prefix}_list_{suffix}" + self.set_key = f"{prefix}_set_{suffix}" self.vertex_label = f"{prefix}_v_{suffix}" self.edge_label = f"{prefix}_e_{suffix}" self.name_index = f"{prefix}_name_idx_{suffix}" @@ -664,6 +736,24 @@ def _ensure_primary_key_schema(client, names: _Names) -> None: _wait_for_schema_visibility(client, names) +def _ensure_collection_schema(client, names: _Names) -> None: + schema = client.schema() + 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() + _wait_for_schema_visibility( + client, + names, + required_property_keys={names.name_key, names.list_key, names.set_key}, + ) + + def _exec(client, query: str): return client.gremlin().exec(query) @@ -680,7 +770,11 @@ def _edge_id(client, names: _Names) -> str: def _wait_for_schema_visibility( - client, names: _Names, *, custom_id: bool = False + client, + names: _Names, + *, + custom_id: bool = False, + required_property_keys: set[str] | None = None, ) -> None: deadline = time.monotonic() + 5.0 last_error: Exception | None = None @@ -706,7 +800,7 @@ def _wait_for_schema_visibility( if isinstance(item, dict) } if ( - names.name_key in property_keys + (required_property_keys or {names.name_key}) <= property_keys and names.vertex_label in vertex_labels and names.edge_label in edge_labels ): diff --git a/hugegraph-mcp/tests/test_config.py b/hugegraph-mcp/tests/test_config.py index 5378e8f2a..3a3f74235 100644 --- a/hugegraph-mcp/tests/test_config.py +++ b/hugegraph-mcp/tests/test_config.py @@ -13,6 +13,7 @@ import logging +import hugegraph_mcp.config as config_module from hugegraph_mcp.config import MCPConfig, config @@ -25,10 +26,13 @@ "HUGEGRAPH_PASSWORD", "HUGEGRAPH_MCP_READONLY", "HUGEGRAPH_AI_URL", + "HUGEGRAPH_AI_TOKEN", "HUGEGRAPH_AI_GRAPH_URL", "HUGEGRAPH_MCP_ALLOW_AI", "HUGEGRAPH_MCP_ADMIN_MODE", "HUGEGRAPH_MCP_TIMEOUT_SECONDS", + "HUGEGRAPH_MCP_STATE_DIR", + "XDG_STATE_HOME", ) @@ -46,6 +50,7 @@ def test_basic_config_parsing(monkeypatch): monkeypatch.setenv("HUGEGRAPH_PASSWORD", "secret") monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") monkeypatch.setenv("HUGEGRAPH_AI_URL", "http://ai.example:18001") + monkeypatch.setenv("HUGEGRAPH_AI_TOKEN", "ai-token") monkeypatch.setenv("HUGEGRAPH_AI_GRAPH_URL", "http://graph-internal:8080") monkeypatch.setenv("HUGEGRAPH_MCP_ALLOW_AI", "yes") monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "true") @@ -60,6 +65,7 @@ def test_basic_config_parsing(monkeypatch): assert cfg.password == "secret" assert cfg.is_readonly() is True assert cfg.ai_url == "http://ai.example:18001" + assert cfg.ai_token == "ai-token" assert cfg.ai_graph_url == "http://graph-internal:8080" assert cfg.allow_ai is True assert cfg.admin_mode is True @@ -178,9 +184,9 @@ def test_admin_mode_reads_current_env(monkeypatch): assert MCPConfig.from_env().admin_mode is True -def test_readonly_parsing(monkeypatch): - true_values = ("true", "1", "yes", "TRUE") - false_values = ("false", "0", "no", "") +def test_strict_boolean_parsing(monkeypatch): + true_values = ("true", "1", "yes", "on", "TRUE", " On ") + false_values = ("false", "0", "no", "off", "FALSE", " Off ") for value in true_values: clear_config_env(monkeypatch) @@ -193,6 +199,51 @@ def test_readonly_parsing(monkeypatch): assert MCPConfig.from_env().is_readonly() is False +def test_invalid_boolean_values_fail_closed_without_leaking_value(monkeypatch, caplog): + clear_config_env(monkeypatch) + secret_like_value = "treu-secret-token" + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", secret_like_value) + monkeypatch.setenv("HUGEGRAPH_MCP_ALLOW_AI", "") + monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "junk") + + with caplog.at_level(logging.WARNING, logger="hugegraph_mcp.config"): + cfg = MCPConfig.from_env() + + assert cfg.readonly is True + assert cfg.allow_ai is False + assert cfg.admin_mode is False + assert secret_like_value not in caplog.text + assert "HUGEGRAPH_MCP_READONLY" in caplog.text + assert "HUGEGRAPH_MCP_ALLOW_AI" in caplog.text + assert "HUGEGRAPH_MCP_ADMIN_MODE" in caplog.text + + +def test_unset_boolean_values_use_defaults_without_warning(monkeypatch, caplog): + clear_config_env(monkeypatch) + + with caplog.at_level(logging.WARNING, logger="hugegraph_mcp.config"): + cfg = MCPConfig.from_env() + + assert cfg.readonly is True + assert cfg.allow_ai is False + assert cfg.admin_mode is False + assert "HUGEGRAPH_MCP_READONLY" not in caplog.text + assert "HUGEGRAPH_MCP_ALLOW_AI" not in caplog.text + assert "HUGEGRAPH_MCP_ADMIN_MODE" not in caplog.text + + +def test_state_dir_priority_and_defaults(monkeypatch, tmp_path): + clear_config_env(monkeypatch) + explicit = tmp_path / "explicit-state" + monkeypatch.setenv("HUGEGRAPH_MCP_STATE_DIR", str(explicit)) + assert MCPConfig.from_env().state_dir == explicit + + monkeypatch.delenv("HUGEGRAPH_MCP_STATE_DIR") + xdg = tmp_path / "xdg-state" + monkeypatch.setenv("XDG_STATE_HOME", str(xdg)) + assert MCPConfig.from_env().state_dir == xdg / "hugegraph-mcp" + + def test_default_password_is_empty_but_explicit_xxx_is_accepted(monkeypatch): clear_config_env(monkeypatch) assert MCPConfig.from_env().password == "" @@ -213,6 +264,7 @@ def test_default_values(monkeypatch): assert cfg.password == "" assert cfg.is_readonly() is True assert cfg.ai_url == "http://127.0.0.1:8001" + assert cfg.ai_token is None assert cfg.ai_graph_url is None assert cfg.allow_ai is False assert cfg.admin_mode is False @@ -227,3 +279,22 @@ def test_config_proxy_reads_current_env(monkeypatch): monkeypatch.setenv("HUGEGRAPH_GRAPH", "second_graph") assert config.graph == "second_graph" + + +def test_cached_config_uses_same_environment_snapshot_for_key_and_value(monkeypatch): + class ChangingEnvironment(dict): + readonly_reads = 0 + + def get(self, key, default=None): + if key == "HUGEGRAPH_MCP_READONLY": + self.readonly_reads += 1 + return "false" if self.readonly_reads == 1 else "true" + return super().get(key, default) + + environment = ChangingEnvironment(HUGEGRAPH_URL="http://snapshot.example:8080") + monkeypatch.setattr(config_module.os, "environ", environment) + + cfg = MCPConfig.from_env() + + assert environment.readonly_reads == 1 + assert cfg.readonly is False diff --git a/hugegraph-mcp/tests/test_envelope.py b/hugegraph-mcp/tests/test_envelope.py index 91ad29adc..893eaca91 100644 --- a/hugegraph-mcp/tests/test_envelope.py +++ b/hugegraph-mcp/tests/test_envelope.py @@ -74,9 +74,10 @@ def test_envelope_err_defaults(): def test_envelope_err_all_error_types(): - assert len(ErrorType) == 23 + assert len(ErrorType) == 24 assert ErrorType.VALIDATION_ERROR.value == "VALIDATION_ERROR" assert ErrorType.PLAN_EXPIRED.value == "PLAN_EXPIRED" + assert ErrorType.PLAN_ALREADY_USED.value == "PLAN_ALREADY_USED" assert ErrorType.NOT_FOUND.value == "NOT_FOUND" assert ErrorType.FEATURE_DISABLED.value == "FEATURE_DISABLED" assert ErrorType.QUERY_SYNTAX_ERROR.value == "QUERY_SYNTAX_ERROR" diff --git a/hugegraph-mcp/tests/test_error_handling.py b/hugegraph-mcp/tests/test_error_handling.py index 3d0c1b43e..4fd72ada2 100644 --- a/hugegraph-mcp/tests/test_error_handling.py +++ b/hugegraph-mcp/tests/test_error_handling.py @@ -16,6 +16,16 @@ from unittest.mock import Mock, patch import requests +import pytest +from pyhugegraph.utils.exceptions import ( + DataFormatError, + InvalidParameterError, + NotAuthorizedError, + NotFoundError, + ResponseParseError, + ServerError, + ServiceUnavailableError, +) from hugegraph_mcp.confirmable_workflow import ( confirm_required_error, mark_readonly_preview, @@ -27,7 +37,10 @@ envelope_err, sanitize_for_response, ) -from hugegraph_mcp.error_mapping import classify_hugegraph_error_message +from hugegraph_mcp.error_mapping import ( + classify_hugegraph_error_message, + classify_hugegraph_exception, +) from hugegraph_mcp.gremlin_tools import execute_gremlin_read, execute_gremlin_write @@ -47,7 +60,26 @@ def test_connection_error_handling(): assert "Cannot connect to HugeGraph server" in result["error"]["message"] assert "Check if HugeGraph server is running" in result["error"]["suggestion"] assert result["error"]["details"]["error_type"] == "connection_error" - assert result["error"]["retryable"] is True + assert result["error"]["retryable"] is True + + +@pytest.mark.parametrize("exc_type", [InvalidParameterError, DataFormatError]) +def test_generic_parameter_errors_are_validation_errors(exc_type): + classification = classify_hugegraph_exception(exc_type("invalid value")) + + assert classification.error_type == ErrorType.VALIDATION_ERROR + assert classification.reason == "validation_error" + assert classification.retryable is False + + +@pytest.mark.parametrize("exc_type", [InvalidParameterError, DataFormatError]) +def test_gremlin_parameter_errors_remain_query_syntax_errors(exc_type): + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client.return_value.exec.side_effect = exc_type("invalid query") + result = execute_gremlin_read("g.V().limit(10)") + + assert result["error"]["type"] == "QUERY_SYNTAX_ERROR" + assert result["error"]["retryable"] is False def test_read_client_initialization_connection_error_is_enveloped(): @@ -193,6 +225,66 @@ def test_authentication_error_handling(): assert result["error"]["details"]["error_type"] == "authentication_error" +def test_pyhugegraph_authentication_error_handling(): + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client.return_value.exec.side_effect = NotAuthorizedError( + "bad credentials" + ) + + result = execute_gremlin_read("g.V().limit(10)") + + assert result["ok"] is False + assert result["error"]["type"] == "AUTHENTICATION_FAILED" + assert result["error"]["details"]["error_type"] == "authentication_error" + assert result["error"]["retryable"] is False + + +def test_pyhugegraph_not_found_error_handling(): + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client.return_value.exec.side_effect = NotFoundError("graph not found") + + result = execute_gremlin_read("g.V().limit(10)") + + assert result["ok"] is False + assert result["error"]["type"] == "NOT_FOUND" + assert result["error"]["details"]["error_type"] == "not_found_error" + assert result["error"]["retryable"] is False + + +def test_pyhugegraph_server_error_preserves_no_index_classification(): + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client.return_value.exec.side_effect = ServerError( + "Server Exception: NoIndexException" + ) + + result = execute_gremlin_read("g.V().has('name', 'alice').limit(10)") + + assert result["ok"] is False + assert result["error"]["type"] == "NO_INDEX" + assert result["error"]["details"]["error_type"] == "no_index_error" + assert result["error"]["retryable"] is False + + +@pytest.mark.parametrize( + ("exception", "expected_retryable"), + [ + (ResponseParseError("invalid response"), False), + (ServiceUnavailableError("temporarily unavailable"), True), + ], +) +def test_pyhugegraph_server_error_preserves_retryable_classification( + exception, expected_retryable +): + with patch("hugegraph_mcp.gremlin_tools._get_read_client") as mock_client: + mock_client.return_value.exec.side_effect = exception + + result = execute_gremlin_read("g.V().limit(10)") + + assert result["ok"] is False + assert result["error"]["type"] == "SERVER_ERROR" + assert result["error"]["retryable"] is expected_retryable + + def test_readonly_mode_error(): """Test readonly mode error handling.""" with patch.dict("os.environ", {"HUGEGRAPH_MCP_READONLY": "true"}): diff --git a/hugegraph-mcp/tests/test_generate_gremlin.py b/hugegraph-mcp/tests/test_generate_gremlin.py index 3501170ea..527734131 100644 --- a/hugegraph-mcp/tests/test_generate_gremlin.py +++ b/hugegraph-mcp/tests/test_generate_gremlin.py @@ -53,25 +53,125 @@ def test_generate_gremlin_default_no_execute(monkeypatch): assert result["data"]["assumptions"] is None assert result["data"]["executed"] is False assert result["data"]["execution_result"] is None - post.assert_called_once_with("/text2gremlin", json={"query": "count vertices"}) + post.assert_called_once_with( + "/text2gremlin", + json={ + "query": "count vertices", + "client_config": { + "graph": "hugegraph", + "gs": "DEFAULT", + }, + }, + ) execute_read.assert_not_called() -def test_generate_gremlin_passes_output_types(monkeypatch): +def test_generate_gremlin_passes_generation_output_types(monkeypatch): post = Mock(return_value=_ai_ok("g.V().count()")) monkeypatch.setattr(generate_gremlin_module, "post", post) result = generate_gremlin_module.generate_gremlin( "count vertices", - output_types=["vertex"], + output_types=["match_result", "raw_gremlin"], + ) + + assert result["ok"] is True + post.assert_called_once_with( + "/text2gremlin", + json={ + "query": "count vertices", + "client_config": { + "graph": "hugegraph", + "gs": "DEFAULT", + }, + "output_types": ["match_result", "raw_gremlin"], + }, + ) + + +def test_generate_gremlin_returns_match_result_without_requiring_gremlin(monkeypatch): + post = Mock( + return_value=envelope_ok( + { + "match_result": [{"query": "count", "gremlin": "g.V().count()"}], + "template_gremlin": "", + "raw_gremlin": "", + } + ) + ) + execute_read = Mock() + monkeypatch.setattr(generate_gremlin_module, "post", post) + monkeypatch.setattr(generate_gremlin_module, "execute_gremlin_read", execute_read) + + result = generate_gremlin_module.generate_gremlin( + "count vertices", output_types=["match_result"] ) + assert result["ok"] is True + assert result["data"]["match_result"] == [ + {"query": "count", "gremlin": "g.V().count()"} + ] + assert result["data"]["gremlin"] is None + assert result["data"]["is_readonly"] is False + assert result["data"]["executed"] is False + execute_read.assert_not_called() + + +def test_generate_gremlin_passes_graph_and_graphspace_only(monkeypatch): + post = Mock(return_value=_ai_ok("g.V().count()")) + monkeypatch.setattr(generate_gremlin_module, "post", post) + monkeypatch.setenv("HUGEGRAPH_URL", "http://mcp-graph:8080") + monkeypatch.setenv("HUGEGRAPH_AI_GRAPH_URL", "http://ai-visible-graph:8080") + monkeypatch.setenv("HUGEGRAPH_GRAPH_PATH", "space_a/graph_a") + + result = generate_gremlin_module.generate_gremlin("count vertices") + assert result["ok"] is True post.assert_called_once_with( "/text2gremlin", - json={"query": "count vertices", "output_types": ["vertex"]}, + json={ + "query": "count vertices", + "client_config": { + "graph": "graph_a", + "gs": "space_a", + }, + }, + ) + + +def test_generate_gremlin_rejects_execution_output_types_before_ai_call(monkeypatch): + post = Mock() + execute_read = Mock() + monkeypatch.setattr(generate_gremlin_module, "post", post) + monkeypatch.setattr(generate_gremlin_module, "execute_gremlin_read", execute_read) + + result = generate_gremlin_module.generate_gremlin( + "count vertices", + execute=False, + output_types=["raw_execution_result"], ) + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert result["error"]["details"]["invalid_output_types"] == [ + "raw_execution_result" + ] + post.assert_not_called() + execute_read.assert_not_called() + + +def test_generate_gremlin_rejects_non_string_output_type(monkeypatch): + post = Mock() + monkeypatch.setattr(generate_gremlin_module, "post", post) + + result = generate_gremlin_module.generate_gremlin( + "count vertices", output_types=[1] + ) + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + post.assert_not_called() + def test_generate_gremlin_rejects_missing_gremlin(monkeypatch): post = Mock( diff --git a/hugegraph-mcp/tests/test_hugegraph_ai_client.py b/hugegraph-mcp/tests/test_hugegraph_ai_client.py index e87f44603..5bf6c7139 100644 --- a/hugegraph-mcp/tests/test_hugegraph_ai_client.py +++ b/hugegraph-mcp/tests/test_hugegraph_ai_client.py @@ -13,6 +13,7 @@ from unittest.mock import Mock +import pytest import requests from hugegraph_mcp.config import MCPConfig @@ -65,7 +66,174 @@ def test_request_success(monkeypatch): ) -def test_request_passes_configured_auth_when_password_is_set(monkeypatch): +def test_request_unwraps_thin_api_success_envelope(monkeypatch): + thin_response = { + "ok": True, + "data": {"status": "ready"}, + "error": None, + "warnings": ["remote warning"], + "next_actions": ["continue"], + "meta": {"request_id": "req-ai-1", "duration_ms": 1}, + } + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", + Mock(return_value=FakeResponse(thin_response)), + ) + + result = request("GET", "/health", cfg=_cfg()) + + assert result["ok"] is True + assert result["data"] == {"status": "ready"} + assert result["warnings"] == ["remote warning"] + assert result["next_actions"] == ["continue"] + assert result["meta"]["request_id"] == "req-ai-1" + + +def test_request_propagates_thin_api_error_envelope(monkeypatch): + thin_response = { + "ok": False, + "data": None, + "error": { + "type": "FLOW_EXECUTION_FAILED", + "message": "flow failed", + "suggestion": "check model configuration", + "retryable": False, + "source": "hugegraph-llm", + "details": {"stage": "extract"}, + }, + "warnings": [], + "next_actions": [], + "meta": {"request_id": "req-ai-2", "duration_ms": 1}, + } + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", + Mock(return_value=FakeResponse(thin_response)), + ) + + result = request("POST", "/graph-extract", cfg=_cfg(), json={}) + + assert result["ok"] is False + assert result["error"]["type"] == "FLOW_EXECUTION_FAILED" + assert result["error"]["message"] == "flow failed" + assert result["error"]["source"] == "hugegraph-llm" + assert result["error"]["details"] == {"stage": "extract"} + assert result["meta"]["request_id"] == "req-ai-2" + + +def test_request_propagates_thin_api_error_envelope_on_http_error(monkeypatch): + thin_response = { + "ok": False, + "data": None, + "error": {"type": "VALIDATION_ERROR", "message": "bad request"}, + "warnings": [], + "next_actions": [], + "meta": {"request_id": "req-ai-3", "duration_ms": 1}, + } + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", + Mock(return_value=FakeResponse(thin_response, status_code=400)), + ) + + result = request("POST", "/graph-extract", cfg=_cfg(), json={}) + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert result["meta"]["request_id"] == "req-ai-3" + + +def test_request_does_not_allow_success_envelope_to_hide_http_error(monkeypatch): + thin_response = { + "ok": True, + "data": {"status": "ready"}, + "error": None, + "warnings": [], + "next_actions": [], + "meta": {"request_id": "req-ai-4", "duration_ms": 1}, + } + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", + Mock(return_value=FakeResponse(thin_response, status_code=500)), + ) + + result = request("GET", "/health", cfg=_cfg()) + + assert result["ok"] is False + assert result["error"]["details"]["status_code"] == 500 + + +def test_request_preserves_http_error_for_non_json_body(monkeypatch): + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", + Mock(return_value=FakeResponse(ValueError("not json"), status_code=500)), + ) + + result = request("GET", "/health", cfg=_cfg()) + + assert result["ok"] is False + assert result["error"]["details"]["status_code"] == 500 + + +def test_request_does_not_unwrap_domain_dict_that_only_looks_like_envelope(monkeypatch): + domain_data = {"ok": True, "data": 1, "error": None, "meta": {}} + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", + Mock(return_value=FakeResponse(domain_data)), + ) + + result = request("GET", "/domain", cfg=_cfg()) + + assert result["ok"] is True + assert result["data"] == domain_data + + +def test_request_accepts_relative_path_without_leading_slash(monkeypatch): + http_request = Mock(return_value=FakeResponse({"status": "ok"})) + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", http_request + ) + + result = request("GET", "health", cfg=_cfg()) + + assert result["ok"] is True + http_request.assert_called_once_with( + "GET", + "http://ai.example:8001/health", + params=None, + headers=None, + timeout=7, + ) + + +@pytest.mark.parametrize( + "path", + [ + "http://attacker.example/collect", + "HTTPS://attacker.example/collect", + "ftp://attacker.example/collect", + "//attacker.example/collect", + ], +) +def test_request_rejects_absolute_url_before_network_call(monkeypatch, path): + http_request = Mock() + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", http_request + ) + + result = request("GET", path, cfg=_cfg(ai_token="ai-secret")) + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert result["error"]["retryable"] is False + assert result["error"]["details"] == { + "method": "GET", + "reason": "absolute_url_not_allowed", + } + assert "ai-secret" not in repr(result) + assert "attacker.example" not in repr(result) + http_request.assert_not_called() + + +def test_request_does_not_reuse_graph_password_for_ai_auth(monkeypatch): http_request = Mock(return_value=FakeResponse({"status": "ok"})) monkeypatch.setattr( "hugegraph_mcp.hugegraph_ai_client.requests.request", http_request @@ -80,10 +248,49 @@ def test_request_passes_configured_auth_when_password_is_set(monkeypatch): params=None, headers=None, timeout=7, - auth=("alice", "secret"), ) +def test_request_injects_configured_bearer_token(monkeypatch): + http_request = Mock(return_value=FakeResponse({"status": "ok"})) + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", http_request + ) + + result = request( + "GET", + "/health", + cfg=_cfg(ai_token="ai-secret"), + headers={"X-Trace": "trace-1"}, + ) + + assert result["ok"] is True + assert http_request.call_args.kwargs["headers"] == { + "Authorization": "Bearer ai-secret", + "X-Trace": "trace-1", + } + + +def test_explicit_authorization_header_overrides_configured_token(monkeypatch): + http_request = Mock(return_value=FakeResponse({"status": "ok"})) + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", http_request + ) + + result = request( + "GET", + "/health", + cfg=_cfg(ai_token="configured-token"), + headers={"authorization": "Bearer explicit-token", "X-Trace": "trace-1"}, + ) + + assert result["ok"] is True + assert http_request.call_args.kwargs["headers"] == { + "authorization": "Bearer explicit-token", + "X-Trace": "trace-1", + } + + def test_request_connection_error(monkeypatch): monkeypatch.setattr( "hugegraph_mcp.hugegraph_ai_client.requests.request", @@ -263,3 +470,37 @@ def test_health_check_falls_back_to_openapi(monkeypatch): "GET", "http://ai.example:8001/openapi.json", ) + + +def test_health_check_does_not_report_inner_failure_as_available(monkeypatch): + inner_error = { + "ok": False, + "data": None, + "error": { + "type": "FLOW_EXECUTION_FAILED", + "message": "index inspection failed", + "retryable": False, + "source": "hugegraph-llm", + "details": {}, + }, + "warnings": [], + "next_actions": [], + "meta": {"request_id": "req-ai-health", "duration_ms": 1}, + } + http_request = Mock( + side_effect=[ + FakeResponse(inner_error), + FakeResponse({"openapi": "3.1.0"}), + ] + ) + monkeypatch.setattr( + "hugegraph_mcp.hugegraph_ai_client.requests.request", http_request + ) + + result = health_check(cfg=_cfg()) + + assert result["ok"] is True + assert result["data"]["status"] == "available" + assert result["data"]["health_endpoint"] == "/openapi.json" + assert "index inspection failed" in result["warnings"][0] + assert http_request.call_count == 2 diff --git a/hugegraph-mcp/tests/test_ingest_graph_data.py b/hugegraph-mcp/tests/test_ingest_graph_data.py index eeaf180c1..d5c0479f3 100644 --- a/hugegraph-mcp/tests/test_ingest_graph_data.py +++ b/hugegraph-mcp/tests/test_ingest_graph_data.py @@ -15,8 +15,12 @@ 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 def _graph_data(): @@ -164,6 +168,89 @@ def test_ingest_plan_hash_schema_primary_key_change_different_hash(): assert first != second +def test_ingest_plan_hash_schema_id_strategy_change_different_hash(): + graph_data = _graph_data() + schema = _live_schema() + changed_schema = _live_schema() + changed_schema["schema"]["vertexlabels"][0]["id_strategy"] = "CUSTOMIZE_STRING" + + first = ingest_graph_data_module.calculate_plan_hash(graph_data, schema) + second = ingest_graph_data_module.calculate_plan_hash(graph_data, changed_schema) + + assert first != second + + +@pytest.mark.parametrize( + ("id_strategy", "vertex_id", "expected_error"), + [ + ( + "CUSTOMIZE_STRING", + None, + "missing required id for CUSTOMIZE_STRING label 'person'", + ), + ( + "CUSTOMIZE_STRING", + 1, + "id for CUSTOMIZE_STRING label 'person' must be a string, got int", + ), + ( + "CUSTOMIZE_NUMBER", + None, + "missing required id for CUSTOMIZE_NUMBER label 'person'", + ), + ( + "CUSTOMIZE_NUMBER", + True, + "id for CUSTOMIZE_NUMBER label 'person' must be an integer, got bool", + ), + ], +) +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 + vertex_label["primary_keys"] = [] + vertex = {"label": "person", "properties": {"name": "Alice"}} + if vertex_id is not None: + vertex["id"] = vertex_id + + 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"]) + + +@pytest.mark.parametrize( + ("id_strategy", "vertex_id"), + [("CUSTOMIZE_STRING", "person-1"), ("CUSTOMIZE_NUMBER", 1)], +) +def test_validate_graph_payload_accepts_valid_customize_id(id_strategy, vertex_id): + schema = _live_schema() + vertex_label = schema["schema"]["vertexlabels"][0] + vertex_label["idStrategy"] = id_strategy + vertex_label["primary_keys"] = [] + + result = ingest_graph_data_module.validate_graph_payload( + { + "vertices": [ + { + "id": vertex_id, + "label": "person", + "properties": {"name": "Alice"}, + } + ], + "edges": [], + }, + live_schema=schema, + ) + + assert result["valid"] is True + + def test_ingest_plan_hash_schema_metadata_ignored_same_hash(): graph_data = _graph_data() schema = _live_schema() @@ -304,6 +391,321 @@ def test_ingest_graph_data_rejects_property_type_mismatch(monkeypatch): ) +def _collection_schema(): + schema = _live_schema() + schema["schema"]["propertykeys"].extend( + [ + {"propertyName": "aliases", "dataType": "TEXT", "cardinalityType": "LIST"}, + {"name": "scores", "data_type": "INTEGER", "cardinality": "LIST"}, + {"name": "flags", "data_type": "BOOL", "cardinality": "LIST"}, + {"name": "metadata", "data_type": "OBJECT", "cardinality": "SINGLE"}, + {"name": "tags", "data_type": "TEXT", "cardinality": "SET"}, + {"name": "since", "data_type": "INT"}, + ] + ) + schema["schema"]["vertexlabels"][0]["properties"].extend( + [ + {"name": "aliases"}, + {"name": "scores"}, + {"name": "flags"}, + {"name": "metadata"}, + ] + ) + schema["schema"]["edgelabels"][0]["properties"] = ["tags", "since"] + return schema + + +def _collection_graph_data(): + return { + "vertices": [ + { + "label": "person", + "properties": { + "name": "Alice", + "aliases": ["Al", "A"], + "scores": [1, 2], + "flags": [True, False], + "metadata": {"source": "test"}, + }, + }, + {"label": "person", "properties": {"name": "Bob"}}, + ], + "edges": [ + { + "label": "knows", + "source_label": "person", + "target_label": "person", + "source": {"name": "Alice"}, + "target": {"name": "Bob"}, + "properties": {"tags": ["friend", "work"]}, + } + ], + } + + +def test_ingest_graph_data_accepts_vertex_list_and_edge_set_properties(monkeypatch): + schema = _collection_schema() + monkeypatch.setattr(ingest_graph_data_module, "_fetch_live_schema", lambda: schema) + + result = ingest_graph_data_module.ingest_graph_data(_collection_graph_data()) + + assert result["ok"] is True + assert result["data"]["mutation_summary"] == {"vertices": 2, "edges": 1} + + +@pytest.mark.parametrize( + "property_keys_field", ["propertykeys", "property_keys", "propertyKeys"] +) +def test_validate_graph_payload_accepts_property_key_collection_aliases( + property_keys_field, +): + schema = _collection_schema() + property_keys = schema["schema"].pop("propertykeys") + schema["schema"][property_keys_field] = property_keys + + result = ingest_graph_data_module.validate_graph_payload( + _collection_graph_data(), + live_schema=schema, + ) + + assert result["valid"] is True + + +def test_validate_graph_payload_accepts_empty_collection(): + graph_data = _collection_graph_data() + graph_data["vertices"][0]["properties"]["aliases"] = [] + + result = ingest_graph_data_module.validate_graph_payload( + graph_data, + live_schema=_collection_schema(), + ) + + assert result["valid"] is True + + +def test_validate_graph_payload_preserves_top_level_none_but_rejects_none_element(): + top_level_none = _collection_graph_data() + top_level_none["vertices"][0]["properties"]["aliases"] = None + collection_none = _collection_graph_data() + collection_none["vertices"][0]["properties"]["aliases"] = ["Al", None] + + allowed = ingest_graph_data_module.validate_graph_payload( + top_level_none, + live_schema=_collection_schema(), + ) + rejected = ingest_graph_data_module.validate_graph_payload( + collection_none, + live_schema=_collection_schema(), + ) + + 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 all("Al" not in error for error in rejected["errors"]) + + +@pytest.mark.parametrize( + ("field", "value", "expected"), + [ + ("cardinality", "MANY", "unsupported cardinality 'MANY'"), + ("data_type", "DECIMAL", "unsupported data_type 'DECIMAL'"), + ], +) +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" + ) + aliases[field] = value + if field == "cardinality": + aliases.pop("cardinalityType", None) + else: + aliases.pop("dataType", None) + + result = ingest_graph_data_module.validate_graph_payload( + _collection_graph_data(), + live_schema=schema, + ) + + assert result["valid"] is False + assert f"vertex 0 property 'aliases' {expected}" in result["errors"] + + +def test_validate_graph_payload_validates_object_as_json_object(): + valid = ingest_graph_data_module.validate_graph_payload( + _collection_graph_data(), + live_schema=_collection_schema(), + ) + invalid_data = _collection_graph_data() + invalid_data["vertices"][0]["properties"]["metadata"] = ["not", "an", "object"] + invalid = ingest_graph_data_module.validate_graph_payload( + invalid_data, + live_schema=_collection_schema(), + ) + + assert valid["valid"] is True + assert invalid["valid"] is False + assert "vertex 0 property 'metadata' expects OBJECT, got list" in invalid["errors"] + + +def test_validate_graph_payload_rejects_scalar_for_collection_property(): + graph_data = _collection_graph_data() + graph_data["vertices"][0]["properties"]["aliases"] = "Al" + + result = ingest_graph_data_module.validate_graph_payload( + graph_data, + live_schema=_collection_schema(), + ) + + assert result["valid"] is False + assert ( + "vertex 0 property 'aliases' expects LIST of TEXT, got str" in result["errors"] + ) + + +def test_validate_graph_payload_rejects_tuple_for_collection_property(): + graph_data = _collection_graph_data() + graph_data["vertices"][0]["properties"]["aliases"] = ("Al", "A") + + result = ingest_graph_data_module.validate_graph_payload( + graph_data, + live_schema=_collection_schema(), + ) + + assert result["valid"] is False + 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(): + graph_data = _collection_graph_data() + graph_data["edges"][0]["properties"]["tags"] = ["friend", 7] + + result = ingest_graph_data_module.validate_graph_payload( + graph_data, + live_schema=_collection_schema(), + ) + + assert result["valid"] is False + assert "edge 0 property 'tags' element 1 expects TEXT, got int" in result["errors"] + assert all("friend" not in error for error in result["errors"]) + + +def test_validate_graph_payload_rejects_bool_in_int_collection(): + graph_data = _collection_graph_data() + graph_data["vertices"][0]["properties"]["scores"] = [1, True] + + result = ingest_graph_data_module.validate_graph_payload( + graph_data, + live_schema=_collection_schema(), + ) + + assert result["valid"] is False + 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(): + graph_data = _collection_graph_data() + + valid = ingest_graph_data_module.validate_graph_payload( + graph_data, + live_schema=_collection_schema(), + ) + graph_data["vertices"][0]["properties"]["flags"] = [True, 1] + invalid = ingest_graph_data_module.validate_graph_payload( + graph_data, + live_schema=_collection_schema(), + ) + + assert valid["valid"] is True + assert invalid["valid"] is False + assert ( + "vertex 0 property 'flags' element 1 expects BOOLEAN, got int" + in invalid["errors"] + ) + + +def test_validate_graph_payload_rejects_list_for_single_property(): + graph_data = _collection_graph_data() + graph_data["edges"][0]["properties"]["since"] = [2020] + + result = ingest_graph_data_module.validate_graph_payload( + graph_data, + live_schema=_collection_schema(), + ) + + assert result["valid"] is False + assert "edge 0 property 'since' expects INT, got list" in result["errors"] + + +def test_manage_graph_data_import_accepts_collections_in_dry_run(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + monkeypatch.setattr( + manage_graph_data_module, + "_fetch_live_schema", + lambda: _collection_schema(), + ) + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + lambda _query: {"data": [0], "total": 1, "is_read": True}, + ) + + result = manage_graph_data_module.manage_graph_data( + mode="import", + graph_data=_collection_graph_data(), + ) + + assert result["ok"] is True + assert result["data"]["confirmable"] is True + assert result["data"]["plan_hash"] + assert result["data"]["mutation_summary"] == { + "create_edge": 1, + "create_vertex": 2, + } + + +def test_public_import_graph_data_rejects_invalid_collection_before_execute( + monkeypatch, +): + graph_data = _collection_graph_data() + graph_data["vertices"][0]["properties"]["aliases"] = "Al" + execute = Mock() + monkeypatch.setattr( + manage_graph_data_module, + "_fetch_live_schema", + lambda: _collection_schema(), + ) + monkeypatch.setattr(manage_graph_data_module, "execute_graph_change_plan", execute) + + result = server.import_graph_data_tool( + mode="ingest", + 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"] + ) + execute.assert_not_called() + + def test_ingest_graph_data_rejects_missing_schema_primary_key(monkeypatch): _mock_schema(monkeypatch) @@ -639,6 +1041,20 @@ def test_ingest_graph_data_readonly(monkeypatch): assert result["error"]["type"] == "READONLY_VIOLATION" +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) + + result = ingest_graph_data_module.ingest_graph_data(_graph_data()) + + assert result["ok"] is True + assert result["data"]["confirmable"] is False + assert result["data"]["readonly_preview_only"] is True + issue.assert_not_called() + + def test_ingest_graph_data_success(monkeypatch): _mock_schema(monkeypatch) monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") @@ -680,6 +1096,69 @@ def test_ingest_graph_data_success(monkeypatch): assert import_payload["edges"][0]["properties"] == {} +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) + graph_data = _graph_data() + 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, + "dry_run": False, + "confirm": True, + "plan_hash": dry_run["data"]["plan_hash"], + "nonce": context["nonce"], + "expires_at": context["expires_at"], + } + + first = ingest_graph_data_module.ingest_graph_data(**arguments) + second = ingest_graph_data_module.ingest_graph_data(**arguments) + + assert first["ok"] is True + 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() + + +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) + graph_data = _graph_data() + 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, + "dry_run": False, + "confirm": True, + "plan_hash": dry_run["data"]["plan_hash"], + "nonce": context["nonce"], + "expires_at": context["expires_at"], + } + + partial = ingest_graph_data_module.ingest_graph_data(**arguments) + replay = ingest_graph_data_module.ingest_graph_data(**arguments) + + assert partial["ok"] is False + assert partial["error"]["details"]["status"] == "partial" + assert replay["ok"] is False + assert replay["error"]["type"] == "PLAN_ALREADY_USED" + post.assert_called_once() + + def test_ingest_graph_data_degrades_when_ai_omits_counts(monkeypatch): _mock_schema(monkeypatch) monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") diff --git a/hugegraph-mcp/tests/test_manage_graph_data.py b/hugegraph-mcp/tests/test_manage_graph_data.py index 125aa3625..2642ce985 100644 --- a/hugegraph-mcp/tests/test_manage_graph_data.py +++ b/hugegraph-mcp/tests/test_manage_graph_data.py @@ -85,6 +85,14 @@ def _mock_schema(monkeypatch): ) +def _customize_schema(id_strategy): + schema = _live_schema() + vertex_label = schema["schema"]["vertexlabels"][0] + vertex_label["id_strategy"] = id_strategy + vertex_label["primary_keys"] = [] + return schema + + def _nested_count_result(count): return { "data": {"data": [count], "meta": {}}, @@ -104,6 +112,64 @@ def test_validate_graph_change_plan_rejects_unknown_op(): assert "unsupported op" in result["errors"][0]["reason"] +def test_public_import_dry_run_rejects_customize_string_vertex_without_id( + monkeypatch, +): + monkeypatch.setattr( + manage_graph_data_module, + "_fetch_live_schema", + lambda: _customize_schema("CUSTOMIZE_STRING"), + ) + + result = manage_graph_data_module.manage_graph_data( + mode="import", + graph_data={ + "vertices": [ + {"label": "person", "properties": {"name": "Alice", "age": 30}} + ], + "edges": [], + }, + dry_run=True, + ) + + 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'" + ] + + +def test_public_import_dry_run_rejects_customize_number_vertex_with_bool_id( + monkeypatch, +): + monkeypatch.setattr( + manage_graph_data_module, + "_fetch_live_schema", + lambda: _customize_schema("CUSTOMIZE_NUMBER"), + ) + + result = manage_graph_data_module.manage_graph_data( + mode="import", + graph_data={ + "vertices": [ + { + "id": True, + "label": "person", + "properties": {"name": "Alice", "age": 30}, + } + ], + "edges": [], + }, + dry_run=True, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "SCHEMA_MISMATCH" + assert result["error"]["details"]["errors"] == [ + "vertex 0 id for CUSTOMIZE_NUMBER label 'person' must be an integer, got bool" + ] + + def test_validate_mode_operations_handles_unknown_mode(): result = manage_graph_data_module._validate_mode_operations( "upsert", @@ -1423,9 +1489,13 @@ def test_manage_graph_data_dry_run_returns_plan_hash(monkeypatch): def test_manage_graph_data_readonly_dry_run_warns_plan_must_be_regenerated( monkeypatch, + tmp_path, ): _mock_schema(monkeypatch) monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + unusable_state_dir = tmp_path / "not-a-directory" + unusable_state_dir.write_text("occupied", encoding="utf-8") + monkeypatch.setenv("HUGEGRAPH_MCP_STATE_DIR", str(unusable_state_dir)) counts = iter([1, 0, 1, 0]) monkeypatch.setattr( @@ -1637,6 +1707,121 @@ def fake_write(query, **_kwargs): assert len(writes) == 2 +def test_manage_graph_data_replayed_confirmation_does_not_execute_twice( + monkeypatch, +): + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + state = {"target_exists": True} + read_calls = [] + + def fake_read(query): + read_calls.append(query) + count = 0 if "bothE()" in query else int(state["target_exists"]) + return { + "data": [count], + "total": 1, + "duration_ms": 1, + "is_read": True, + } + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, + "execute_gremlin_read", + fake_read, + ) + execute_calls = [] + + def fake_execute(plan, *, live_schema): + execute_calls.append((plan, live_schema)) + state["target_exists"] = False + return {"success": True, "results": [{"success": True}]} + + monkeypatch.setattr( + manage_graph_data_module, "execute_graph_change_plan", fake_execute + ) + dry_run = manage_graph_data_module.manage_graph_data( + mode="delete", change_plan=_delete_vertex_plan(), nonce="data-replay" + ) + context = dry_run["data"]["plan_context"] + arguments = { + "mode": "delete", + "change_plan": _delete_vertex_plan(), + "dry_run": False, + "confirm": True, + "plan_hash": dry_run["data"]["plan_hash"], + "nonce": context["nonce"], + "expires_at": context["expires_at"], + } + + first = manage_graph_data_module.manage_graph_data(**arguments) + second = manage_graph_data_module.manage_graph_data(**arguments) + + assert first["ok"] is True + assert second["ok"] is False + assert second["error"]["type"] == "PLAN_ALREADY_USED" + assert state["target_exists"] is False + assert len(execute_calls) == 1 + assert len(read_calls) == 4 + + +def test_manage_graph_data_unconsumed_target_drift_does_not_consume_nonce( + monkeypatch, +): + from hugegraph_mcp.confirmation_store import ConfirmationStore + + _mock_schema(monkeypatch) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + state = {"target_exists": True} + + def fake_read(query): + count = 0 if "bothE()" in query else int(state["target_exists"]) + return { + "data": [count], + "total": 1, + "duration_ms": 1, + "is_read": True, + } + + monkeypatch.setattr( + manage_graph_data_module.gremlin_tools, "execute_gremlin_read", fake_read + ) + execute_calls = [] + + def fake_execute(plan, *, live_schema): + execute_calls.append((plan, live_schema)) + return {"success": True, "results": [{"success": True}]} + + monkeypatch.setattr( + manage_graph_data_module, "execute_graph_change_plan", fake_execute + ) + dry_run = manage_graph_data_module.manage_graph_data( + mode="delete", change_plan=_delete_vertex_plan(), nonce="data-stale" + ) + context = dry_run["data"]["plan_context"] + arguments = { + "mode": "delete", + "change_plan": _delete_vertex_plan(), + "dry_run": False, + "confirm": True, + "plan_hash": dry_run["data"]["plan_hash"], + "nonce": context["nonce"], + "expires_at": context["expires_at"], + } + + state["target_exists"] = False + stale = manage_graph_data_module.manage_graph_data(**arguments) + + assert stale["ok"] is False + assert ConfirmationStore.from_config().has_consumed(context["nonce"]) is False + assert execute_calls == [] + + state["target_exists"] = True + restored = manage_graph_data_module.manage_graph_data(**arguments) + assert restored["ok"] is True + assert len(execute_calls) == 1 + + def test_manage_graph_data_import_validates_graph_payload(monkeypatch): _mock_schema(monkeypatch) diff --git a/hugegraph-mcp/tests/test_manage_schema.py b/hugegraph-mcp/tests/test_manage_schema.py index b4eb8dcaf..ff3644596 100644 --- a/hugegraph-mcp/tests/test_manage_schema.py +++ b/hugegraph-mcp/tests/test_manage_schema.py @@ -12,6 +12,7 @@ # limitations under the License. import re +from unittest.mock import Mock from hugegraph_mcp.tools import manage_schema as manage_schema_module from hugegraph_mcp.tools.manage_schema import manage_schema @@ -728,6 +729,22 @@ def test_manage_schema_dry_run(monkeypatch): assert isinstance(result["data"]["warnings"], list) +def test_manage_schema_readonly_preview_does_not_issue_plan(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", _empty_schema + ) + issue = Mock() + monkeypatch.setattr(manage_schema_module, "issue_plan", issue) + + result = manage_schema(mode="dry_run", operations=[_property_key()]) + + assert result["ok"] is True + assert result["data"]["confirmable"] is False + assert result["data"]["readonly_preview_only"] is True + issue.assert_not_called() + + def test_manage_schema_dry_run_invalid_schema_has_no_plan_hash(monkeypatch): monkeypatch.setattr( manage_schema_module.schema_tools, @@ -1035,6 +1052,30 @@ def test_dry_run_rejects_invalid_edge_frequency_when_provided(monkeypatch): assert "plan_hash" in valid["data"] +def test_dry_run_rejects_unsupported_parent_sub_edge_fields(monkeypatch): + monkeypatch.setattr( + manage_schema_module.schema_tools, + "get_live_schema", + lambda: _schema(vertexlabels=[_live_vertex("person")]), + ) + + for field, value in ( + ("parent_label", "parent_edge"), + ("parentLabel", "parent_edge"), + ("edgelabel_type", "SUB"), + ("edgeLabelType", "SUB"), + ): + operation = _edge_label("knows", source_label="person", target_label="person") + operation[field] = value + + result = manage_schema(mode="dry_run", operations=[operation]) + + _assert_dry_run_invalid(result) + assert result["data"]["errors"][0]["reason"] == ( + f"unsupported parent/sub edge label field(s): {field}" + ) + + def test_dry_run_rejects_nullable_and_sort_key_contract_violations(monkeypatch): monkeypatch.setattr( manage_schema_module.schema_tools, @@ -1565,6 +1606,93 @@ def live_schema(): assert result["data"]["applied_operations"] == [_property_key()] +def test_manage_schema_replayed_confirmation_does_not_apply_twice(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + state = _empty_schema() + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", lambda: state + ) + apply_calls = [] + + def fake_apply(operations, *, live_schema): + apply_calls.append((operations, live_schema)) + state["schema"]["propertykeys"].append(_live_pk("age")) + return { + "status": "applied", + "valid": True, + "applied_operations": operations, + } + + monkeypatch.setattr(manage_schema_module, "apply_schema_operations", fake_apply) + dry_run = manage_schema( + mode="dry_run", operations=[_property_key()], nonce="schema-replay" + ) + context = dry_run["data"]["plan_context"] + arguments = { + "mode": "apply", + "operations": [_property_key()], + "confirm": True, + "plan_hash": dry_run["data"]["plan_hash"], + "nonce": context["nonce"], + "expires_at": context["expires_at"], + } + + first = manage_schema(**arguments) + second = manage_schema(**arguments) + + assert first["ok"] is True + assert second["ok"] is False + assert second["error"]["type"] == "PLAN_ALREADY_USED" + assert state["schema"]["propertykeys"] == [_live_pk("age")] + assert len(apply_calls) == 1 + + +def test_manage_schema_unconsumed_schema_drift_does_not_consume_nonce(monkeypatch): + from hugegraph_mcp.confirmation_store import ConfirmationStore + + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + state = _empty_schema() + monkeypatch.setattr( + manage_schema_module.schema_tools, "get_live_schema", lambda: state + ) + apply_calls = [] + + def fake_apply(operations, *, live_schema): + apply_calls.append((operations, live_schema)) + return { + "status": "applied", + "valid": True, + "applied_operations": operations, + } + + monkeypatch.setattr(manage_schema_module, "apply_schema_operations", fake_apply) + dry_run = manage_schema( + mode="dry_run", operations=[_property_key()], nonce="schema-stale" + ) + context = dry_run["data"]["plan_context"] + arguments = { + "mode": "apply", + "operations": [_property_key()], + "confirm": True, + "plan_hash": dry_run["data"]["plan_hash"], + "nonce": context["nonce"], + "expires_at": context["expires_at"], + } + + state["schema"]["propertykeys"].append(_live_pk("other")) + stale = manage_schema(**arguments) + + assert stale["ok"] is False + assert stale["error"]["type"] == "PLAN_HASH_MISMATCH" + assert ConfirmationStore.from_config().has_consumed(context["nonce"]) is False + assert apply_calls == [] + + state["schema"]["propertykeys"].clear() + restored = manage_schema(**arguments) + assert restored["ok"] is True + assert len(apply_calls) == 1 + + def test_manage_schema_apply_canonicalizes_property_key_post_read_enums(monkeypatch): monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") state = _empty_schema() diff --git a/hugegraph-mcp/tests/test_mutate_graph_properties_tool.py b/hugegraph-mcp/tests/test_mutate_graph_properties_tool.py index a5d0b3114..6d79e307a 100644 --- a/hugegraph-mcp/tests/test_mutate_graph_properties_tool.py +++ b/hugegraph-mcp/tests/test_mutate_graph_properties_tool.py @@ -11,6 +11,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from unittest.mock import Mock + from hugegraph_mcp.tools import mutate_graph_properties as mutate_module @@ -18,13 +20,15 @@ def _schema(): return { "schema": { "propertykeys": [ - {"name": "name", "data_type": "TEXT"}, - {"name": "age", "data_type": "INT"}, + {"name": "name", "data_type": "TEXT", "cardinality": "SINGLE"}, + {"name": "age", "data_type": "INT", "cardinality": "SINGLE"}, + {"name": "aliases", "data_type": "TEXT", "cardinality": "LIST"}, + {"name": "tags", "data_type": "TEXT", "cardinality": "SET"}, ], "vertexlabels": [ { "name": "person", - "properties": ["name", "age"], + "properties": ["name", "age", "aliases", "tags"], "primary_keys": ["name"], } ], @@ -33,7 +37,7 @@ def _schema(): "name": "knows", "source_label": "person", "target_label": "person", - "properties": ["age"], + "properties": ["age", "aliases", "tags"], } ], "indexlabels": [], @@ -135,6 +139,64 @@ def appendVertex(self, vertex_id, properties): raise RuntimeError("404 Not Found: vertex does not exist") +class CollectionGraphManager(FakeGraphManager): + def __init__(self, *, target="vertex", property_name="tags", values=None): + super().__init__( + vertex={ + "id": "1:alice", + "label": "person", + "type": "vertex", + "properties": {property_name: list(values or [])}, + } + ) + self.target = target + self.property_name = property_name + self.edge = { + "id": "edge-1", + "label": "knows", + "type": "edge", + "properties": {property_name: list(values or [])}, + } + + def getEdgeById(self, edge_id): + return self.edge + + def appendVertex(self, vertex_id, properties): + self.append_calls.append((vertex_id, properties)) + self.vertex = self._append(self.vertex, properties) + return self.vertex + + def appendEdge(self, edge_id, properties): + self.append_calls.append((edge_id, properties)) + self.edge = self._append(self.edge, properties) + return self.edge + + def _append(self, item, properties): + values = [ + *item["properties"][self.property_name], + *properties[self.property_name], + ] + if self.property_name == "tags": + values = list(dict.fromkeys(values)) + return {**item, "properties": {self.property_name: values}} + + +class ReorderedSetGraphManager(CollectionGraphManager): + def getVertexById(self, vertex_id): + item = super().getVertexById(vertex_id) + if self.append_calls: + return { + **item, + "properties": { + **item["properties"], + self.property_name: list( + reversed(item["properties"][self.property_name]) + ), + }, + } + return item + + def _patch_schema(monkeypatch): monkeypatch.setattr(mutate_module, "current_live_schema", lambda: _schema()) @@ -159,6 +221,189 @@ def test_mutate_dry_run_returns_snapshot_bound_plan(monkeypatch): assert "|ts:" in result["data"]["plan_context"]["nonce"] +def test_mutate_readonly_preview_does_not_issue_plan(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + _patch_schema(monkeypatch) + monkeypatch.setattr(mutate_module, "_graph_manager", FakeGraphManager) + issue = Mock() + monkeypatch.setattr(mutate_module, "issue_plan", issue) + + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + ) + + assert result["ok"] is True + assert result["data"]["confirmable"] is False + assert result["data"]["readonly_preview_only"] is True + issue.assert_not_called() + + +def test_list_append_preview_preserves_order_and_duplicates(monkeypatch): + _patch_schema(monkeypatch) + manager = CollectionGraphManager(property_name="aliases", values=["a"]) + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"aliases": ["b", "b"]}, + ) + + assert result["ok"] is True + assert result["data"]["after"]["properties"]["aliases"] == ["a", "b", "b"] + + +def test_set_append_preview_is_stably_deduplicated(monkeypatch): + _patch_schema(monkeypatch) + manager = CollectionGraphManager(property_name="tags", values=["a"]) + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"tags": ["b", "a"]}, + ) + + assert result["ok"] is True + assert result["data"]["after"]["properties"]["tags"] == ["a", "b"] + + +def test_single_append_preview_keeps_replacement_semantics(monkeypatch): + _patch_schema(monkeypatch) + manager = FakeGraphManager( + vertex={ + "id": "1:alice", + "label": "person", + "type": "vertex", + "properties": {"name": "old"}, + } + ) + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"name": "new"}, + ) + + assert result["ok"] is True + assert result["data"]["after"]["properties"]["name"] == "new" + + +def test_collection_append_rejects_non_json_array_before_write(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + _patch_schema(monkeypatch) + manager = CollectionGraphManager(property_name="tags", values=["a"]) + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"tags": "b"}, + dry_run=False, + confirm=True, + plan_hash="unused", + nonce="unused", + expires_at=9999999999, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert result["error"]["details"]["property"] == "tags" + assert manager.append_calls == [] + + tuple_result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"tags": ("b",)}, + ) + assert tuple_result["ok"] is False + assert tuple_result["error"]["type"] == "VALIDATION_ERROR" + assert "JSON array" in tuple_result["error"]["suggestion"] + + +def test_set_append_post_read_ignores_server_order(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + _patch_schema(monkeypatch) + manager = ReorderedSetGraphManager(property_name="tags", values=["a"]) + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + dry_run = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"tags": ["b", "a"]}, + ) + context = dry_run["data"]["plan_context"] + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"tags": ["b", "a"]}, + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=context["nonce"], + expires_at=context["expires_at"], + ) + + assert result["ok"] is True + assert manager.append_calls == [("1:alice", {"tags": ["b", "a"]})] + + +def test_edge_set_append_post_read_ignores_server_order(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + _patch_schema(monkeypatch) + manager = CollectionGraphManager(target="edge", property_name="tags", values=["a"]) + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + dry_run = mutate_module.mutate_graph_properties( + target="edge", + operation="append", + id="edge-1", + properties={"tags": ["b", "a"]}, + ) + context = dry_run["data"]["plan_context"] + result = mutate_module.mutate_graph_properties( + target="edge", + operation="append", + id="edge-1", + properties={"tags": ["b", "a"]}, + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=context["nonce"], + expires_at=context["expires_at"], + ) + + assert result["ok"] is True + assert manager.append_calls == [("edge-1", {"tags": ["b", "a"]})] + + +def test_eliminate_collection_still_removes_entire_property(monkeypatch): + _patch_schema(monkeypatch) + manager = CollectionGraphManager(property_name="aliases", values=["a", "b"]) + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="eliminate", + id="1:alice", + properties={"aliases": ["a"]}, + ) + + assert result["ok"] is True + assert "aliases" not in result["data"]["after"]["properties"] + + def test_mutate_rejects_unknown_property(monkeypatch): _patch_schema(monkeypatch) manager = FakeGraphManager() @@ -176,6 +421,39 @@ def test_mutate_rejects_unknown_property(monkeypatch): assert result["error"]["details"]["unknown_properties"] == ["missing"] +def test_mutate_confirm_rejects_cardinality_change_since_dry_run(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + live_schema = _schema() + monkeypatch.setattr(mutate_module, "current_live_schema", lambda: live_schema) + manager = CollectionGraphManager(property_name="aliases", values=["a"]) + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + dry_run = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"aliases": ["b"]}, + ) + context = dry_run["data"]["plan_context"] + live_schema["schema"]["propertykeys"][2]["cardinality"] = "SET" + + result = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"aliases": ["b"]}, + 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"] == "PLAN_HASH_MISMATCH" + assert manager.append_calls == [] + + def test_mutate_missing_vertex_returns_not_found(monkeypatch): _patch_schema(monkeypatch) monkeypatch.setattr(mutate_module, "_graph_manager", lambda: MissingTargetManager()) @@ -240,30 +518,66 @@ def test_mutate_confirm_applies_after_valid_plan(monkeypatch): assert manager.append_calls == [("1:alice", {"age": 30})] -def test_mutate_confirm_maps_execution_errors_with_hugegraph_classifier(monkeypatch): +def test_mutate_replayed_confirmation_does_not_execute_twice(monkeypatch): monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") _patch_schema(monkeypatch) - manager = ExecutionFailureManager() + manager = FakeGraphManager() monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) - dry_run = mutate_module.mutate_graph_properties( target="vertex", operation="append", id="1:alice", properties={"age": 30}, + nonce="mutation-replay", ) context = dry_run["data"]["plan_context"] - result = mutate_module.mutate_graph_properties( + arguments = { + "target": "vertex", + "operation": "append", + "id": "1:alice", + "properties": {"age": 30}, + "dry_run": False, + "confirm": True, + "plan_hash": dry_run["data"]["plan_hash"], + "nonce": context["nonce"], + "expires_at": context["expires_at"], + } + + first = mutate_module.mutate_graph_properties(**arguments) + second = mutate_module.mutate_graph_properties(**arguments) + + assert first["ok"] is True + assert second["ok"] is False + assert second["error"]["type"] == "PLAN_ALREADY_USED" + assert manager.vertex["properties"]["age"] == 30 + assert manager.append_calls == [("1:alice", {"age": 30})] + + +def test_mutate_confirm_maps_execution_errors_with_hugegraph_classifier(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + _patch_schema(monkeypatch) + manager = ExecutionFailureManager() + monkeypatch.setattr(mutate_module, "_graph_manager", lambda: manager) + + dry_run = mutate_module.mutate_graph_properties( target="vertex", operation="append", id="1:alice", properties={"age": 30}, - dry_run=False, - confirm=True, - plan_hash=dry_run["data"]["plan_hash"], - nonce=context["nonce"], - expires_at=context["expires_at"], ) + context = dry_run["data"]["plan_context"] + arguments = { + "target": "vertex", + "operation": "append", + "id": "1:alice", + "properties": {"age": 30}, + "dry_run": False, + "confirm": True, + "plan_hash": dry_run["data"]["plan_hash"], + "nonce": context["nonce"], + "expires_at": context["expires_at"], + } + result = mutate_module.mutate_graph_properties(**arguments) assert result["ok"] is False assert result["error"]["type"] == "NOT_FOUND" @@ -271,6 +585,11 @@ def test_mutate_confirm_maps_execution_errors_with_hugegraph_classifier(monkeypa assert result["error"]["details"]["stage"] == "mutation_execute" assert result["error"]["details"]["reason"] == "not_found" + replay = mutate_module.mutate_graph_properties(**arguments) + assert replay["ok"] is False + assert replay["error"]["type"] == "PLAN_ALREADY_USED" + assert manager.append_calls == [("1:alice", {"age": 30})] + def test_mutate_confirm_sanitizes_post_read_error(monkeypatch): monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") @@ -419,6 +738,24 @@ def test_mutate_confirm_detects_target_changed(monkeypatch): assert result["error"]["type"] == "TARGET_CHANGED" assert manager.append_calls == [] + from hugegraph_mcp.confirmation_store import ConfirmationStore + + assert ConfirmationStore.from_config().has_consumed(context["nonce"]) is False + manager.changed_vertex = None + retry_after_restore = mutate_module.mutate_graph_properties( + target="vertex", + operation="append", + id="1:alice", + properties={"age": 30}, + dry_run=False, + confirm=True, + plan_hash=dry_run["data"]["plan_hash"], + nonce=context["nonce"], + expires_at=context["expires_at"], + ) + assert retry_after_restore["ok"] is True + assert manager.append_calls == [("1:alice", {"age": 30})] + def test_mutate_confirm_requires_non_readonly(monkeypatch): monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") diff --git a/hugegraph-mcp/tests/test_plan_hash.py b/hugegraph-mcp/tests/test_plan_hash.py index 025dfad6f..89b2ada0e 100644 --- a/hugegraph-mcp/tests/test_plan_hash.py +++ b/hugegraph-mcp/tests/test_plan_hash.py @@ -11,7 +11,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Tests for plan_hash module (Milestone 4).""" +"""Tests for plan hash verification and persistent single-use confirmation.""" + +from concurrent.futures import ThreadPoolExecutor +import hashlib +import os +import sqlite3 +import time + +from hugegraph_mcp.confirmable_workflow import verify_and_consume_plan +from hugegraph_mcp.confirmation_store import ( + ConfirmationAlreadyUsedError, + ConfirmationPlanExpiredError, + ConfirmationStore, +) from hugegraph_mcp.envelope import ErrorType from hugegraph_mcp.plan_hash import ( @@ -23,6 +36,29 @@ ) +def _confirmation_args(context, plan_hash, **overrides): + args = { + "submitted_hash": plan_hash, + "tool_name": context.tool_name, + "mode": context.mode, + "payload_digest": context.payload_digest, + "schema_hash": context.schema_hash, + "nonce": context.nonce, + "expires_at": context.expires_at, + "extra_context": context.extra_context, + } + args.update(overrides) + return args + + +def _issue(context, plan_hash): + ConfirmationStore.from_config().issue( + nonce=context.nonce, + plan_hash=plan_hash, + expires_at=context.expires_at, + ) + + def test_plan_hash_changes_when_graph_url_changes(monkeypatch): monkeypatch.setenv("HUGEGRAPH_URL", "http://server-a:8080") ctx_a, hash_a = build_plan_context( @@ -358,3 +394,322 @@ def test_verify_plan_hash_rejects_extended_expires_at(monkeypatch): assert valid is False assert error_type == ErrorType.PLAN_HASH_MISMATCH + + +def test_verify_and_consume_rejects_replay_across_store_instances(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + context, plan_hash = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123", nonce="once" + ) + _issue(context, plan_hash) + + first = verify_and_consume_plan(**_confirmation_args(context, plan_hash)) + second = verify_and_consume_plan(**_confirmation_args(context, plan_hash)) + + assert first == (True, None, None) + assert second[0] is False + assert second[1] == ErrorType.PLAN_ALREADY_USED + assert "path" not in str(second[2]).lower() + assert "sql" not in str(second[2]).lower() + + +def test_verify_and_consume_rejects_client_computed_plan_without_dry_run(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + context, plan_hash = build_plan_context( + tool_name="test", + mode="import", + payload_digest="client-computed", + nonce="never-issued", + ) + + result = verify_and_consume_plan(**_confirmation_args(context, plan_hash)) + + assert result[0] is False + assert result[1] == ErrorType.PLAN_HASH_MISMATCH + assert "server-issued" in result[2]["reason"] + + +def test_confirmation_store_rejects_far_future_expiry(monkeypatch): + store = ConfirmationStore.from_config() + + try: + store.issue( + nonce="far-future", + plan_hash="client-computed", + expires_at=int(time.time()) + 601, + ) + assert False, "Plans beyond the server TTL must not be issued" + except ConfirmationPlanExpiredError: + pass + + +def test_server_issued_plan_survives_store_recreation(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + context, plan_hash = build_plan_context( + tool_name="test", + mode="import", + payload_digest="restart", + nonce="restart-plan", + ) + ConfirmationStore.from_config().issue( + nonce=context.nonce, + plan_hash=plan_hash, + expires_at=context.expires_at, + ) + + assert verify_and_consume_plan(**_confirmation_args(context, plan_hash)) == ( + True, + None, + None, + ) + + +def test_existing_consumed_only_database_is_migrated(monkeypatch): + store = ConfirmationStore.from_config() + store._prepare_storage() + with sqlite3.connect(store.database_path) as connection: + connection.execute( + """ + CREATE TABLE consumed_confirmations ( + nonce_digest TEXT PRIMARY KEY, + plan_hash TEXT NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at INTEGER NOT NULL + ) + """ + ) + expires_at = int(time.time()) + 600 + + store.issue(nonce="migrated", plan_hash="plan", expires_at=expires_at) + store.consume(nonce="migrated", plan_hash="plan", expires_at=expires_at) + + assert store.has_consumed("migrated") is True + + +def test_confirmation_nonce_is_global_across_payloads(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + first_context, first_hash = build_plan_context( + tool_name="first", mode="import", payload_digest="payload-a", nonce="shared" + ) + second_context, second_hash = build_plan_context( + tool_name="second", mode="delete", payload_digest="payload-b", nonce="shared" + ) + _issue(first_context, first_hash) + + assert verify_and_consume_plan(**_confirmation_args(first_context, first_hash))[0] + replay = verify_and_consume_plan(**_confirmation_args(second_context, second_hash)) + + assert replay[0] is False + assert replay[1] == ErrorType.PLAN_ALREADY_USED + + +def test_concurrent_confirmation_has_exactly_one_winner(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + context, plan_hash = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123", nonce="race" + ) + _issue(context, plan_hash) + args = _confirmation_args(context, plan_hash) + + with ThreadPoolExecutor(max_workers=8) as executor: + results = list( + executor.map(lambda _: verify_and_consume_plan(**args), range(8)) + ) + + assert sum(result[0] for result in results) == 1 + assert sum(result[1] == ErrorType.PLAN_ALREADY_USED for result in results) == 7 + + +def test_invalid_plan_does_not_consume_nonce(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + context, plan_hash = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123", nonce="retry-valid" + ) + _issue(context, plan_hash) + + invalid = verify_and_consume_plan(**_confirmation_args(context, "wrong-plan-hash")) + valid = verify_and_consume_plan(**_confirmation_args(context, plan_hash)) + + assert invalid[1] == ErrorType.PLAN_HASH_MISMATCH + assert valid == (True, None, None) + + +def test_expired_plan_does_not_consume_nonce(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + monkeypatch.setattr("hugegraph_mcp.plan_hash.time.time", lambda: 1000) + monkeypatch.setattr("hugegraph_mcp.confirmation_store.time.time", lambda: 1000) + expired_context, expired_hash = build_plan_context( + tool_name="test", + mode="import", + payload_digest="expired", + nonce="after-expired", + ttl_seconds=1, + ) + _issue(expired_context, expired_hash) + monkeypatch.setattr("hugegraph_mcp.plan_hash.time.time", lambda: 1002) + monkeypatch.setattr("hugegraph_mcp.confirmation_store.time.time", lambda: 1002) + expired = verify_and_consume_plan( + **_confirmation_args(expired_context, expired_hash) + ) + + valid_context, valid_hash = build_plan_context( + tool_name="test", + mode="import", + payload_digest="valid", + nonce="after-expired", + ) + _issue(valid_context, valid_hash) + valid = verify_and_consume_plan(**_confirmation_args(valid_context, valid_hash)) + + assert expired[1] == ErrorType.PLAN_EXPIRED + assert valid == (True, None, None) + + +def test_readonly_plan_does_not_consume_nonce(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") + readonly_context, readonly_hash = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123", nonce="after-readonly" + ) + _issue(readonly_context, readonly_hash) + + blocked = verify_and_consume_plan( + **_confirmation_args(readonly_context, readonly_hash) + ) + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + writable_context, writable_hash = build_plan_context( + tool_name="test", + mode="import", + payload_digest="abc123", + nonce="after-readonly-write", + ) + _issue(writable_context, writable_hash) + allowed = verify_and_consume_plan( + **_confirmation_args(writable_context, writable_hash) + ) + + assert blocked[1] == ErrorType.READONLY_VIOLATION + assert allowed == (True, None, None) + + +def test_confirmation_store_permissions(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + context, plan_hash = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123", nonce="permissions" + ) + _issue(context, plan_hash) + assert verify_and_consume_plan(**_confirmation_args(context, plan_hash))[0] + + store = ConfirmationStore.from_config() + if os.name == "posix": + assert store.state_dir.stat().st_mode & 0o777 == 0o700 + assert store.database_path.stat().st_mode & 0o777 == 0o600 + + +def test_confirmation_store_persists_nonce_digest_not_plaintext(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + nonce = "sensitive-confirmation-nonce" + store = ConfirmationStore.from_config() + expires_at = int(time.time()) + 600 + store.issue(nonce=nonce, plan_hash="plan", expires_at=expires_at) + store.consume(nonce=nonce, plan_hash="plan", expires_at=expires_at) + + with sqlite3.connect(store.database_path) as connection: + row = connection.execute( + """ + SELECT nonce_digest, plan_hash, expires_at, consumed_at + FROM consumed_confirmations + """ + ).fetchone() + + assert row is not None + assert row[0] == hashlib.sha256(nonce.encode("utf-8")).hexdigest() + assert nonce.encode("utf-8") not in store.database_path.read_bytes() + assert row[1:] == ("plan", expires_at, row[3]) + + +def test_confirmation_store_has_consumed_is_read_only(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + store = ConfirmationStore.from_config() + + assert store.has_consumed("not-consumed") is False + assert store.database_path.exists() is False + + expires_at = int(time.time()) + 600 + store.issue(nonce="consumed", plan_hash="plan", expires_at=expires_at) + store.consume(nonce="consumed", plan_hash="plan", expires_at=expires_at) + assert store.has_consumed("consumed") is True + assert store.has_consumed("not-consumed") is False + + +def test_confirmation_store_lazily_cleans_expired_records(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + store = ConfirmationStore.from_config() + expires_at = int(time.time()) + 600 + store.issue(nonce="current-row", plan_hash="new", expires_at=expires_at) + store.consume(nonce="current-row", plan_hash="new", expires_at=expires_at) + with sqlite3.connect(store.database_path) as connection: + connection.execute( + """ + INSERT INTO consumed_confirmations + (nonce_digest, plan_hash, expires_at, consumed_at) + VALUES (?, ?, ?, ?) + """, + (hashlib.sha256(b"expired-row").hexdigest(), "old", 0, 0), + ) + other_expiry = int(time.time()) + 600 + store.issue(nonce="cleanup-trigger", plan_hash="trigger", expires_at=other_expiry) + store.consume(nonce="cleanup-trigger", plan_hash="trigger", expires_at=other_expiry) + + with sqlite3.connect(store.database_path) as connection: + rows = connection.execute( + "SELECT plan_hash FROM consumed_confirmations ORDER BY plan_hash" + ).fetchall() + + assert rows == [("new",), ("trigger",)] + + +def test_confirmation_cleanup_failure_does_not_block_current_nonce(monkeypatch): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + store = ConfirmationStore.from_config() + + def fail_cleanup(_connection, _current_time): + raise sqlite3.OperationalError("cleanup unavailable") + + expires_at = int(time.time()) + 600 + store.issue( + nonce="cleanup-failure-current", + plan_hash="current", + expires_at=expires_at, + ) + monkeypatch.setattr(store, "_cleanup_expired", fail_cleanup) + store.consume( + nonce="cleanup-failure-current", plan_hash="current", expires_at=expires_at + ) + try: + store.consume( + nonce="cleanup-failure-current", + plan_hash="current", + expires_at=expires_at, + ) + assert False, "The current nonce must remain globally single-use" + except ConfirmationAlreadyUsedError: + pass + + +def test_unavailable_confirmation_store_fails_closed_without_internal_details( + monkeypatch, tmp_path +): + monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "false") + unusable = tmp_path / "not-a-directory" + unusable.write_text("occupied", encoding="utf-8") + monkeypatch.setenv("HUGEGRAPH_MCP_STATE_DIR", str(unusable)) + context, plan_hash = build_plan_context( + tool_name="test", mode="import", payload_digest="abc123", nonce="unavailable" + ) + + result = verify_and_consume_plan(**_confirmation_args(context, plan_hash)) + + assert result[0] is False + assert result[1] == ErrorType.SERVER_ERROR + assert str(unusable) not in str(result[2]) + assert "sql" not in str(result[2]).lower() diff --git a/hugegraph-mcp/tests/test_query_graph_data_tool.py b/hugegraph-mcp/tests/test_query_graph_data_tool.py index 4b36e674f..0d7b85c0d 100644 --- a/hugegraph-mcp/tests/test_query_graph_data_tool.py +++ b/hugegraph-mcp/tests/test_query_graph_data_tool.py @@ -12,6 +12,7 @@ # limitations under the License. import pytest +from unittest.mock import Mock from hugegraph_mcp.tools import query_graph_data as query_module @@ -229,6 +230,35 @@ def test_query_rejects_limit_over_500(): assert "limit exceeds" in result["error"]["message"] +@pytest.mark.parametrize( + ("operation", "operation_args"), + [ + ("get_by_id", {"id": "1:alice"}), + ("get_by_ids", {"ids": ["1:alice"]}), + ], +) +@pytest.mark.parametrize("limit", ["abc", 0, -1, query_module.MAX_LIMIT + 1]) +def test_query_by_id_operations_reject_invalid_limit_before_manager_call( + monkeypatch, operation, operation_args, limit +): + manager = FakeGraphManager() + manager_factory = Mock(return_value=manager) + monkeypatch.setattr(query_module, "_graph_manager", manager_factory) + + result = query_module.query_graph_data( + target="vertex", + operation=operation, + limit=limit, + **operation_args, + ) + + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert result["error"]["retryable"] is False + manager_factory.assert_not_called() + assert manager.calls == [] + + def test_query_condition_requires_properties(): result = query_module.query_graph_data(target="vertex", operation="condition") @@ -249,7 +279,7 @@ def test_query_edge_page_requires_direction_with_vertex_id(): assert "direction is required" in result["error"]["message"] -def test_query_edge_page(monkeypatch): +def test_query_edge_page_rejects_vertex_id_with_nonempty_page(monkeypatch): manager = FakeGraphManager() monkeypatch.setattr(query_module, "_graph_manager", lambda: manager) @@ -263,13 +293,66 @@ def test_query_edge_page(monkeypatch): page="p1", ) + assert result["ok"] is False + assert result["error"]["type"] == "VALIDATION_ERROR" + assert "vertex_id" in result["error"]["suggestion"] + assert "page" in result["error"]["suggestion"] + assert manager.calls == [] + + +def test_query_edge_page_by_vertex_without_page(monkeypatch): + manager = FakeGraphManager() + monkeypatch.setattr(query_module, "_graph_manager", lambda: manager) + + result = query_module.query_graph_data( + target="edge", + operation="page", + label="knows", + vertex_id="1:alice", + direction="out", + limit=5, + ) + assert result["ok"] is True assert result["data"]["next_page"] == "edge-next" assert manager.calls == [ - ("getEdgeByPage", "knows", "1:alice", "OUT", 5, "p1", None) + ("getEdgeByPage", "knows", "1:alice", "OUT", 5, None, None) ] +def test_query_edge_page_by_vertex_allows_empty_page(monkeypatch): + manager = FakeGraphManager() + monkeypatch.setattr(query_module, "_graph_manager", lambda: manager) + + result = query_module.query_graph_data( + target="edge", + operation="page", + vertex_id="1:alice", + direction="both", + page="", + ) + + assert result["ok"] is True + assert manager.calls == [("getEdgeByPage", None, "1:alice", "BOTH", 100, "", None)] + + +def test_query_edge_page_cursor_without_vertex_id(monkeypatch): + manager = FakeGraphManager() + monkeypatch.setattr(query_module, "_graph_manager", lambda: manager) + + result = query_module.query_graph_data( + target="edge", + operation="page", + label="knows", + limit=5, + page="p1", + ) + + assert result["ok"] is True + assert result["data"]["next_page"] == "edge-next" + assert manager.calls == [("getEdgeByPage", "knows", None, None, 5, "p1", None)] + + def test_query_vertex_condition_returns_next_page(monkeypatch): manager = FakeGraphManager() monkeypatch.setattr(query_module, "_graph_manager", lambda: manager) diff --git a/hugegraph-mcp/tests/test_readonly_mode.py b/hugegraph-mcp/tests/test_readonly_mode.py index ac705156c..f6673d680 100644 --- a/hugegraph-mcp/tests/test_readonly_mode.py +++ b/hugegraph-mcp/tests/test_readonly_mode.py @@ -39,13 +39,15 @@ def test_readonly_env_parsing(): ("true", True), ("1", True), ("yes", True), + ("on", True), ("TRUE", True), ("True", True), ("false", False), ("0", False), ("no", False), - ("", False), - ("invalid", False), + ("off", False), + ("", True), + ("invalid", True), ] for env_value, expected in test_cases: diff --git a/hugegraph-mcp/tests/test_schema_utils.py b/hugegraph-mcp/tests/test_schema_utils.py index bbb7253fc..f9ac60de7 100644 --- a/hugegraph-mcp/tests/test_schema_utils.py +++ b/hugegraph-mcp/tests/test_schema_utils.py @@ -14,7 +14,11 @@ import inspect from hugegraph_mcp.tools import graph_data_validate -from hugegraph_mcp.tools.schema_utils import normalized_schema_summary, schema_payload +from hugegraph_mcp.tools.schema_utils import ( + normalized_schema_summary, + property_cardinalities, + schema_payload, +) def test_normalized_schema_summary_uses_shared_canonical_shape(): @@ -93,6 +97,42 @@ def test_schema_payload_preserves_explicit_empty_schema(): assert schema_payload({"schema": {}}) == {} +def test_property_cardinalities_supports_wrappers_and_field_aliases(): + assert property_cardinalities( + { + "schema": { + "propertyKeys": [ + {"name": "name"}, + {"propertyName": "aliases", "cardinalityType": "list"}, + {"property_name": "tags", "cardinality_type": "set"}, + ] + } + } + ) == {"name": "SINGLE", "aliases": "LIST", "tags": "SET"} + + assert property_cardinalities( + {"propertykeys": [{"name": "values", "cardinality": "LIST"}]} + ) == {"values": "LIST"} + + +def test_normalized_schema_summary_binds_property_key_cardinality_aliases(): + assert normalized_schema_summary( + { + "schema": { + "propertyKeys": [ + { + "propertyName": "aliases", + "dataType": "TEXT", + "cardinalityType": "LIST", + } + ] + } + } + )["propertykeys"] == [ + {"name": "aliases", "data_type": "TEXT", "cardinality": "LIST"} + ] + + def test_graph_data_validate_does_not_reverse_import_ingest_module(): source = inspect.getsource(graph_data_validate) diff --git a/hugegraph-mcp/tests/test_v1_stable_tools.py b/hugegraph-mcp/tests/test_v1_stable_tools.py index fd98e0957..14e0fb337 100644 --- a/hugegraph-mcp/tests/test_v1_stable_tools.py +++ b/hugegraph-mcp/tests/test_v1_stable_tools.py @@ -419,6 +419,15 @@ def test_admin_gate_allows_write_tool_when_enabled(monkeypatch): mock.assert_called_once_with("g.addV('test')", capability=Capability.DEBUG_WRITE) +def test_execute_gremlin_write_tool_documents_break_glass_contract(): + doc = server.execute_gremlin_write_tool.__doc__ or "" + + assert "Break-glass" in doc + assert "sole write-safety-chain exception" in doc + assert "no preview" in doc + assert "isolated trusted admin transport" in doc + + def test_admin_gate_blocks_write_tool_when_readonly(monkeypatch): monkeypatch.setenv("HUGEGRAPH_MCP_ADMIN_MODE", "true") monkeypatch.setenv("HUGEGRAPH_MCP_READONLY", "true") diff --git a/hugegraph-python-client/src/pyhugegraph/api/services.py b/hugegraph-python-client/src/pyhugegraph/api/services.py index 258c0469a..c6ab08eb4 100644 --- a/hugegraph-python-client/src/pyhugegraph/api/services.py +++ b/hugegraph-python-client/src/pyhugegraph/api/services.py @@ -65,7 +65,7 @@ def create_services( """ return self._invoke_request(data=body_params.dumps()) - @router.http("GET", "/graphspaces/${graphspace}/services") + @router.http("GET", "/graphspaces/{graphspace}/services") def list_services(self, graphspace: str): # pylint: disable=unused-argument """ List all services in a specified graph space. diff --git a/hugegraph-python-client/src/pyhugegraph/utils/huge_router.py b/hugegraph-python-client/src/pyhugegraph/utils/huge_router.py index 580f78f07..171847f61 100644 --- a/hugegraph-python-client/src/pyhugegraph/utils/huge_router.py +++ b/hugegraph-python-client/src/pyhugegraph/utils/huge_router.py @@ -127,27 +127,34 @@ def wrapper(self: "HGraphContext", *args: Any, **kwargs: Any) -> Any: # Remove 'self' from the arguments used to format the pathinfo all_kwargs.pop("self") - # Graphspace-scoped auth paths require a graphspace: HugeGraph 1.7.0+ - # only mounts UserAPI/AccessAPI/BelongAPI/TargetAPI under - # /graphspaces/{graphspace}/auth/..., so we fail fast when the - # session lacks one rather than producing an unreachable URL. + # Auth endpoints moved from /auth/... to + # /graphspaces/{graphspace}/auth/... in HugeGraph 1.7.0. Keep + # one set of manager methods while routing pre-1.7 sessions to + # the legacy path. if "{graphspace}" in path: graphspace_arg = all_kwargs.get("graphspace") graphspace_cfg = getattr(self.session.cfg, "graphspace", None) gs_supported = getattr(self.session.cfg, "gs_supported", False) - - if not (graphspace_arg or (graphspace_cfg and gs_supported)): - raise ValueError( - "graphspace is required for auth endpoints on HugeGraph 1.7.0+. " - "Ensure gs_supported is True and graphspace is configured." - ) - prefix = "/graphspaces/{graphspace}" if not path.startswith(prefix + "/"): raise ValueError(f"Expected graphspace-prefixed path, got: {path}") - all_kwargs["graphspace"] = graphspace_arg or graphspace_cfg - formatted_path = path.format(**all_kwargs) + if not gs_supported and path.startswith(prefix + "/auth/"): + formatted_path = path.removeprefix(prefix).format(**all_kwargs) + elif graphspace_arg: + # Some server-level APIs, such as ServicesManager, take + # graphspace explicitly even when the client session is + # not configured for graphspace-scoped graph requests. + all_kwargs["graphspace"] = graphspace_arg + formatted_path = path.format(**all_kwargs) + elif graphspace_cfg and gs_supported: + all_kwargs["graphspace"] = graphspace_cfg + formatted_path = path.format(**all_kwargs) + else: + raise ValueError( + "graphspace is required for this endpoint. " + "Pass graphspace explicitly or configure a supported graphspace session." + ) else: formatted_path = path.format(**all_kwargs) else: diff --git a/hugegraph-python-client/src/pyhugegraph/utils/log.py b/hugegraph-python-client/src/pyhugegraph/utils/log.py index 29be927e8..064239332 100644 --- a/hugegraph-python-client/src/pyhugegraph/utils/log.py +++ b/hugegraph-python-client/src/pyhugegraph/utils/log.py @@ -118,7 +118,9 @@ def init_logger( log_filename = f"{log_filename}.rank{rank}" try: - os.makedirs(os.path.dirname(log_filename), exist_ok=True) + log_directory = os.path.dirname(log_filename) + if log_directory: + os.makedirs(log_directory, exist_ok=True) except OSError: # If we can't create the log directory (e.g., read-only filesystem), # fall back to console-only logging diff --git a/hugegraph-python-client/src/tests/api/test_auth_routing.py b/hugegraph-python-client/src/tests/api/test_auth_routing.py index df4cef5dc..a2af6e5bb 100644 --- a/hugegraph-python-client/src/tests/api/test_auth_routing.py +++ b/hugegraph-python-client/src/tests/api/test_auth_routing.py @@ -21,9 +21,13 @@ import pytest import requests from pyhugegraph.api.auth import AuthManager +from pyhugegraph.api.common import HugeParamsBase from pyhugegraph.api.schema_manage.edge_label import EdgeLabel +from pyhugegraph.api.services import ServicesManager +from pyhugegraph.structure.services_data import ServiceCreateParameters from pyhugegraph.utils.huge_config import HGraphConfig from pyhugegraph.utils.huge_requests import HGraphSession +from pyhugegraph.utils.huge_router import http pytestmark = pytest.mark.contract @@ -91,28 +95,77 @@ def test_graphspace_scoped_endpoints_use_graphspace(endpoint, method_call, args, auth = AuthManager(sess) getattr(auth, method_call)(*args) - assert expected_subpath in sess.last + assert sess.last == f"http://127.0.0.1:8080/{expected_subpath}" @pytest.mark.parametrize( - "endpoint, method_call, args", + "method_call, args, expected_subpath", [ - ("users", "list_users", ()), - ("users", "get_user", ("u1",)), - ("accesses", "list_accesses", ()), - ("accesses", "get_accesses", ("a1",)), - ("targets", "list_targets", ()), - ("belongs", "list_belongs", ()), + ("list_users", (), "auth/users"), + ("get_user", ("u1",), "auth/users/u1"), + ("list_accesses", (), "auth/accesses"), + ("get_accesses", ("a1",), "auth/accesses/a1"), + ("list_targets", (), "auth/targets"), + ("list_belongs", (), "auth/belongs"), ], ) -def test_graphspace_scoped_endpoints_require_graphspace(endpoint, method_call, args): - # HugeGraph 1.7.0+ requires graphspace for these auth endpoints. +def test_auth_endpoints_use_legacy_paths_without_graphspace(method_call, args, expected_subpath): cfg = DummyCfg(url="http://127.0.0.1:8080", graphspace=None, gs_supported=False, graph_name="g") sess = DummySession(cfg) auth = AuthManager(sess) - with pytest.raises(ValueError, match="graphspace is required for auth endpoints"): - getattr(auth, method_call)(*args) + getattr(auth, method_call)(*args) + + assert sess.last == f"http://127.0.0.1:8080/{expected_subpath}" + + +def test_legacy_auth_ignores_explicit_graphspace_placeholder_argument(): + class ExplicitGraphspaceAuth(HugeParamsBase): + @http("GET", "/graphspaces/{graphspace}/auth/users") + def list_users(self, graphspace): + return self._invoke_request() + + cfg = DummyCfg(url="http://127.0.0.1:8080", graphspace=None, gs_supported=False, graph_name="g") + sess = DummySession(cfg) + + ExplicitGraphspaceAuth(sess).list_users("SPACE_X") + + assert sess.last == "http://127.0.0.1:8080/auth/users" + + +@pytest.mark.parametrize( + "method_call, args", + [ + ("list_users", ()), + ("get_user", ("u1",)), + ("list_accesses", ()), + ("get_accesses", ("a1",)), + ("list_targets", ()), + ("list_belongs", ()), + ], +) +def test_graphspace_auth_requires_graphspace_when_supported(method_call, args): + cfg = DummyCfg(url="http://127.0.0.1:8080", graphspace=None, gs_supported=True, graph_name="g") + sess = DummySession(cfg) + + with pytest.raises(ValueError, match="graphspace is required for this endpoint"): + getattr(AuthManager(sess), method_call)(*args) + + +def test_services_use_explicit_graphspace_without_graphspace_session(): + cfg = DummyCfg(url="http://127.0.0.1:8080", graphspace=None, gs_supported=False, graph_name="g") + sess = DummySession(cfg) + services = ServicesManager(sess) + + services.get_service("SPACE_X", "svc") + assert sess.last == "http://127.0.0.1:8080/graphspaces/SPACE_X/services/svc" + + services.list_services("SPACE_X") + assert sess.last == "http://127.0.0.1:8080/graphspaces/SPACE_X/services" + + body = ServiceCreateParameters(name="svc", description="test service") + services.create_services("SPACE_X", body) + assert sess.last == "http://127.0.0.1:8080/graphspaces/SPACE_X/services" def test_groups_are_server_level(): @@ -151,6 +204,23 @@ def test_hgraph_config_does_not_auto_enable_graphspace_before_1_7(monkeypatch): assert cfg.graphspace is None assert cfg.gs_supported is False + sess = DummySession(cfg) + AuthManager(sess).list_users() + assert sess.last == "http://127.0.0.1:8080/auth/users" + + response = mock.Mock(spec=requests.Response) + response.status_code = 200 + response.json.return_value = {"users": []} + response.raise_for_status.return_value = None + raw_session = mock.Mock() + raw_session.get.return_value = response + real_session = HGraphSession(cfg, session=raw_session) + + AuthManager(real_session).list_users() + + requested_url = raw_session.get.call_args.args[0] + assert requested_url == "http://127.0.0.1:8080/auth/users" + def test_hgraph_config_auto_enables_default_graphspace_for_1_7(monkeypatch): monkeypatch.setattr( diff --git a/hugegraph-python-client/src/tests/api/test_log.py b/hugegraph-python-client/src/tests/api/test_log.py new file mode 100644 index 000000000..b57cd54d6 --- /dev/null +++ b/hugegraph-python-client/src/tests/api/test_log.py @@ -0,0 +1,44 @@ +# 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. + +from logging.handlers import RotatingFileHandler + +import pytest +from pyhugegraph.utils.log import init_logger + +pytestmark = pytest.mark.unit + + +def test_init_logger_creates_rotating_handler_for_plain_filename(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + logger = init_logger( + "plain.log", + logger_name="plain-filename-test", + stdout_logging=False, + ) + + try: + handler = next(item for item in logger.handlers if isinstance(item, RotatingFileHandler)) + logger.info("plain filename works") + handler.flush() + + assert (tmp_path / "plain.log").read_text(encoding="utf-8").endswith("plain filename works\n") + finally: + for handler in logger.handlers: + handler.close() + logger.handlers.clear() + init_logger.cache_clear() diff --git a/hugegraph-python-client/src/tests/api/test_schema.py b/hugegraph-python-client/src/tests/api/test_schema.py index 2eef23b83..626ca94c5 100644 --- a/hugegraph-python-client/src/tests/api/test_schema.py +++ b/hugegraph-python-client/src/tests/api/test_schema.py @@ -15,86 +15,16 @@ # specific language governing permissions and limitations # under the License. -import json import unittest from contextlib import suppress import pytest -from pyhugegraph.api.schema_manage.index_label import IndexLabel -from pyhugegraph.api.schema_manage.property_key import PropertyKey from ..client_utils import ClientUtils pytestmark = [pytest.mark.integration, pytest.mark.hugegraph] -class DummySchemaSession: - def __init__(self): - self.requests = [] - - def request(self, path, method="GET", validator=None, **kwargs): - self.requests.append({"path": path, "method": method, "validator": validator, **kwargs}) - return {"ok": True} - - -def test_property_key_create_includes_aggregate_type_in_payload(): - session = DummySchemaSession() - property_key = PropertyKey(session) - - property_key.create_parameter_holder() - property_key.add_parameter("name", "score") - property_key.asInt().valueSingle().calcSum().create() - - request = session.requests[-1] - assert request["path"] == "schema/propertykeys" - assert request["method"] == "POST" - assert json.loads(request["data"]) == { - "name": "score", - "data_type": "INT", - "cardinality": "SINGLE", - "aggregate_type": "SUM", - } - - -def test_property_key_create_includes_user_data_in_payload(): - session = DummySchemaSession() - property_key = PropertyKey(session) - - property_key.create_parameter_holder() - property_key.add_parameter("name", "score") - property_key.asInt().valueSingle().userdata("min", 0, "max", 100).create() - - request = session.requests[-1] - assert request["path"] == "schema/propertykeys" - assert request["method"] == "POST" - assert json.loads(request["data"]) == { - "name": "score", - "data_type": "INT", - "cardinality": "SINGLE", - "user_data": {"min": 0, "max": 100}, - } - - -def test_index_label_create_preserves_field_order_and_deduplicates(): - session = DummySchemaSession() - index_label = IndexLabel(session) - - index_label.create_parameter_holder() - index_label.add_parameter("name", "personByAgeCity") - index_label.onV("person").by("age", "city", "age").secondary().create() - - request = session.requests[-1] - assert request["path"] == "schema/indexlabels" - assert request["method"] == "POST" - assert json.loads(request["data"]) == { - "name": "personByAgeCity", - "base_type": "VERTEX_LABEL", - "base_value": "person", - "index_type": "SECONDARY", - "fields": ["age", "city"], - } - - class TestSchemaManager(unittest.TestCase): client = None schema = None diff --git a/hugegraph-python-client/src/tests/api/test_schema_contract.py b/hugegraph-python-client/src/tests/api/test_schema_contract.py new file mode 100644 index 000000000..3fcbf5855 --- /dev/null +++ b/hugegraph-python-client/src/tests/api/test_schema_contract.py @@ -0,0 +1,91 @@ +# 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. + +import json + +import pytest +from pyhugegraph.api.schema_manage.index_label import IndexLabel +from pyhugegraph.api.schema_manage.property_key import PropertyKey + +pytestmark = pytest.mark.contract + + +class DummySchemaSession: + def __init__(self): + self.requests = [] + + def request(self, path, method="GET", validator=None, **kwargs): + self.requests.append({"path": path, "method": method, "validator": validator, **kwargs}) + return {"ok": True} + + +def test_property_key_create_includes_aggregate_type_in_payload(): + session = DummySchemaSession() + property_key = PropertyKey(session) + + property_key.create_parameter_holder() + property_key.add_parameter("name", "score") + property_key.asInt().valueSingle().calcSum().create() + + request = session.requests[-1] + assert request["path"] == "schema/propertykeys" + assert request["method"] == "POST" + assert json.loads(request["data"]) == { + "name": "score", + "data_type": "INT", + "cardinality": "SINGLE", + "aggregate_type": "SUM", + } + + +def test_property_key_create_includes_user_data_in_payload(): + session = DummySchemaSession() + property_key = PropertyKey(session) + + property_key.create_parameter_holder() + property_key.add_parameter("name", "score") + property_key.asInt().valueSingle().userdata("min", 0, "max", 100).create() + + request = session.requests[-1] + assert request["path"] == "schema/propertykeys" + assert request["method"] == "POST" + assert json.loads(request["data"]) == { + "name": "score", + "data_type": "INT", + "cardinality": "SINGLE", + "user_data": {"min": 0, "max": 100}, + } + + +def test_index_label_create_preserves_field_order_and_deduplicates(): + session = DummySchemaSession() + index_label = IndexLabel(session) + + index_label.create_parameter_holder() + index_label.add_parameter("name", "personByAgeCity") + index_label.onV("person").by("age", "city", "age").secondary().create() + + request = session.requests[-1] + assert request["path"] == "schema/indexlabels" + assert request["method"] == "POST" + assert json.loads(request["data"]) == { + "name": "personByAgeCity", + "base_type": "VERTEX_LABEL", + "base_value": "person", + "index_type": "SECONDARY", + "fields": ["age", "city"], + } diff --git a/skills/hugegraph-data-importer/SKILL.md b/skills/hugegraph-data-importer/SKILL.md index 26d4400e3..d9e54315b 100644 --- a/skills/hugegraph-data-importer/SKILL.md +++ b/skills/hugegraph-data-importer/SKILL.md @@ -10,7 +10,7 @@ description: Route HugeGraph MCP V1 graph data extraction and import tasks to st | Goal | Tool | | --- | --- | | Inspect schema, primary keys, or edge endpoints | `inspect_graph_tool(include_raw_schema=true)` | -| Extract candidate graph data from text | `extract_graph_data_tool(text, schema?, example_prompt?)` | +| Extract candidate graph data from text | `extract_graph_data_tool(text, graph_schema?, example_prompt?)` | | Preview structured vertex/edge import | `import_graph_data_tool(mode="ingest", graph_data, dry_run=true)` | | Execute confirmed import | `import_graph_data_tool(mode="ingest", graph_data, dry_run=false, confirm=true, plan_hash, nonce, expires_at)` | | Preview controlled vertex/edge delete | `delete_graph_data_tool(change_plan, dry_run=true)` | diff --git a/skills/hugegraph-regression-tester/SKILL.md b/skills/hugegraph-regression-tester/SKILL.md index 4a32b40c5..476fc2b89 100644 --- a/skills/hugegraph-regression-tester/SKILL.md +++ b/skills/hugegraph-regression-tester/SKILL.md @@ -14,7 +14,7 @@ description: Route HugeGraph MCP V1 regression testing tasks to the current publ | Natural-language Gremlin generation | `generate_gremlin_tool(query, execute=false)` | | Generate then execute read query | `generate_gremlin_tool(query, execute=false)` -> `execute_gremlin_read_tool(gremlin_query)` | | Write Gremlin rejection | `execute_gremlin_read_tool(unsafe_write_query)` | -| Text-to-graph extraction | `extract_graph_data_tool(text, schema?, example_prompt?)` | +| Text-to-graph extraction | `extract_graph_data_tool(text, graph_schema?, example_prompt?)` | | Import dry-run | `import_graph_data_tool(mode="ingest", graph_data, dry_run=true)` | | Import confirm | `import_graph_data_tool(mode="ingest", graph_data, dry_run=false, confirm=true, plan_hash, nonce, expires_at)` | | Verify import | `execute_gremlin_read_tool(gremlin_query)` |