diff --git a/AGENTS.md b/AGENTS.md index 34e6ac8..250f1ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,7 +131,7 @@ See `wiki-query` and `wiki-export` skills for how the filter is applied. - **Merge, do not overwrite.** Automatic identifier extraction may add terms, but it must not discard curated registry metadata. Registry values are the durable source for future index rebuilds and remote MCP lookup. - **Prefer precise identifiers in search ranking.** Exact or prefix matches in structured part-number and series fields should rank above incidental filename substrings such as timestamps or archive hashes. - **Keep remote ingest gated.** MCP ingest tools are write-capable and must remain disabled unless the service config explicitly enables the `ingest` tool group via `OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest`, `mcp_service.sh tools enable ingest`, or `mcp_service.sh start --enable-ingest`. Uploaded files and direct text notes must stage outside the vault under the MCP runtime root; `wiki-ingest` is responsible for accepting, archiving, and indexing sources inside the vault. Use `ingest_text_note` only for short remote-agent thoughts/notes; it must wrap the text as a temporary markdown upload and then use the fixed `/wiki-ingest` job path. Use the HTTP multipart endpoint `/mcp-ingest/uploads` for file transfer; if the remote agent cannot read the main MCP bearer token, it must call `create_ingest_upload_ticket` and send the returned single-use `X-Obsidian-Wiki-Upload-Token` header. Upload tickets are consumed only by authorized `POST` uploads, the upload endpoint must enforce configured host/origin restrictions, and oversized request bodies must fail with HTTP `413`. Do not expose base64 or chunked upload tools in the MCP tool surface. They may launch local OpenCode only through the fixed `/wiki-ingest` prompt; never expose arbitrary prompt or shell execution through MCP. Remote ingest responses must not expose local runtime paths; internal job records may keep paths for the fixed local prompt. Remote ingest must keep the OpenCode readiness preflight in front of job launch, and `check_ingest_opencode_readiness(run_smoke_test=true)` should stay available for operators to verify that OpenCode can load the project `wiki-ingest` skill before accepting uploads. -- **Keep remote query gated.** MCP query tools must remain disabled unless the service config explicitly enables the `query` tool group via `OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,query`, `mcp_service.sh tools enable query`, or `mcp_service.sh start --enable-query`. They may launch local OpenCode only through the fixed `/wiki-query` prompt, with no arbitrary prompt or shell execution. `public_only` defaults to false, meaning remote answers may use the full local vault visibility; enable the tool group only for trusted clients and set `public_only=true` per call for filtered user-facing answers. Remote query responses may expose log tails but must not expose local log file paths. +- **Keep remote query gated.** MCP query tools must remain disabled unless the service config explicitly enables the `query` tool group via `OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,query`, `mcp_service.sh tools enable query`, or `mcp_service.sh start --enable-query`. They may launch local OpenCode only through the fixed `/wiki-query` prompt, with no arbitrary prompt or shell execution. `ask_wiki` is the preferred remote-agent query entrypoint; the async `start_wiki_query`/status/result tools remain available for long jobs. `public_only` defaults to false, meaning remote answers may use the full local vault visibility; enable the tool group only for trusted clients and set `public_only=true` per call for filtered user-facing answers. Remote query responses may expose log tails but must not expose local log file paths, and structured fields such as `answer_markdown`, `citations`, `warnings`, and `confidence` must remain backward-compatible with the legacy `answer` field. - **Keep OpenCode model choice server-side.** MCP clients must not pass arbitrary model names through ingest/query tools. Configure local defaults in `.runtime/obsidian-wiki-mcp.env`: ingest uses `OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5` with `OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high`; query uses `OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5` with `OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal`. ## Core Principles diff --git a/README.md b/README.md index 766201b..589cb07 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,7 @@ Configure your agent to run `obsidian-wiki-mcp` as a stdio MCP server. The serve - `list_file_assets` — browses safe source files with optional filters. - `get_file_asset_metadata` — returns one file's safe metadata and `asset://source/sha256/...` URI. - `file_index_status` — shows vault/index/profile status. +- `get_mcp_capabilities` — tells remote agents which tool groups are enabled and which tool to use first. - `asset://source/sha256/{digest}` — binary resource for an allowed archived file. By default the MCP layer is stricter than local CLI lookup: it only serves engineering files such as drawings, 3D/STEP models, specifications, product parameters, and test reports. Customer-shareable engineering deliverables, such as PDF customer drawings, are allowed for source-file lookup when the active profile already allows technical engineering data. It blocks raw orders, prices, email correspondence, complaint evidence, claims, personal data, and files missing from `_meta/source-registry.jsonl`. Use `OBSIDIAN_MCP_BOT_PROFILE`, `OBSIDIAN_MCP_MAX_FILE_BYTES`, and `OBSIDIAN_MCP_EXPOSE_PATHS` to tune the runtime behavior. @@ -500,12 +501,13 @@ After `query` is enabled once, later `scripts/obsidian-wiki-ops.py mcp start` pr This adds read-oriented OpenCode-backed query tools to the same `wiki-file-assets` server and bearer token: - `check_query_opencode_readiness` — verifies the local OpenCode binary, required run flags, project `wiki-query`/`llm-wiki` skills, and optionally runs a no-write smoke test. +- `ask_wiki` — preferred one-call query path for remote agents. It starts the local job, polls briefly, and returns either a structured answer or a `job_id` to continue polling. - `start_wiki_query` — queues a local `opencode run --auto --format default` job with a fixed `/wiki-query` prompt. Parameters: `query`, `mode` (`normal` or `index_only`), `public_only` (default `false`), and optional `note`. - `get_wiki_query_status`, `get_wiki_query_result`, `cancel_wiki_query` — inspect, retrieve, or cancel the local query job. The managed service config sets query to `OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5` and `OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal` by default, prioritizing response speed over maximum reasoning depth. Tune the server env file if a different balance is needed. -`public_only` defaults to `false`, so the answer may use the same full vault visibility that a local operator sees. Set `public_only=true` when the remote answer must exclude `visibility/internal` and `visibility/pii` pages. Job state and logs live under `.runtime/mcp-query/jobs/`, outside the vault; remote query status/result responses include log tails but omit local log file paths. A normal `wiki-query` log entry is allowed. The generated prompt forbids ingest/update/rebuild workflows, writes, sync/config/timer operations, and arbitrary shell execution; it only allows the `obsidian-wiki business schema/template/sql` read-only commands needed by the wiki-query business facts route. Hybrid business + wiki questions may run a wiki context track and a business facts track, using parallel subagents only when the host supports them and otherwise running sequentially. When `public_only=true`, business facts remain disabled. +`public_only` defaults to `false`, so the answer may use the same full vault visibility that a local operator sees. Set `public_only=true` when the remote answer must exclude `visibility/internal` and `visibility/pii` pages. Job state and logs live under `.runtime/mcp-query/jobs/`, outside the vault; remote query status/result responses include log tails but omit local log file paths. Query results keep the legacy `answer` field and also include `answer_markdown`, `citations`, `used_wiki`, `used_business_facts`, `data_freshness`, `warnings`, and `confidence` so agents can parse the response without scraping the log tail. A normal `wiki-query` log entry is allowed. The generated prompt forbids ingest/update/rebuild workflows, writes, sync/config/timer operations, and arbitrary shell execution; it only allows the `obsidian-wiki business schema/template/sql` read-only commands needed by the wiki-query business facts route. Hybrid business + wiki questions may run a wiki context track and a business facts track, using parallel subagents only when the host supports them and otherwise running sequentially. When `public_only=true`, business facts remain disabled. Clean old MCP runtime staging data with the default 30-day retention window: diff --git a/obsidian_wiki/mcp_query.py b/obsidian_wiki/mcp_query.py index 6a795cf..7fea372 100644 --- a/obsidian_wiki/mcp_query.py +++ b/obsidian_wiki/mcp_query.py @@ -140,17 +140,34 @@ class QueryManager: def get_query_job_result(self, job_id: str) -> dict[str, Any]: with self._lock: status = self.get_query_job_status(job_id) - output_tail = status.get("log_tail", "") - return { - "job_id": status["job_id"], - "state": status["state"], - "answer": output_tail.strip(), - "mode": status.get("mode"), - "public_only": status.get("public_only"), - "exit_code": status.get("exit_code"), - "opencode_output_tail": output_tail, - "error": status.get("error", ""), - } + return self._query_result_from_status(status) + + def ask_wiki( + self, + query: str, + *, + mode: str = "normal", + public_only: bool = False, + note: str | None = None, + timeout_seconds: int = 90, + poll_interval_seconds: int = 2, + ) -> dict[str, Any]: + timeout = max(0, int(timeout_seconds)) + poll_interval = max(1, int(poll_interval_seconds)) + status = self.start_query_job(query, mode=mode, public_only=public_only, note=note) + job_id = status["job_id"] + deadline = time.monotonic() + timeout + while status.get("state") in {"queued", "running"}: + if time.monotonic() >= deadline: + result = self._query_result_from_status(status) + result["next_poll_after_seconds"] = poll_interval + result["warnings"].append( + f"Query timed out after {timeout} seconds; poll get_wiki_query_status or get_wiki_query_result with job_id." + ) + return result + time.sleep(min(poll_interval, max(0, deadline - time.monotonic()))) + status = self.get_query_job_status(job_id) + return self.get_query_job_result(job_id) def cancel_query_job(self, job_id: str) -> dict[str, Any]: with self._lock: @@ -348,6 +365,65 @@ class QueryManager: "log_tail": self._read_tail(Path(job["log_path"])), } + def _query_result_from_status(self, status: dict[str, Any]) -> dict[str, Any]: + output_tail = status.get("log_tail", "") + answer = output_tail.strip() + structured = self._structured_answer_metadata(answer, status=status) + return { + "job_id": status["job_id"], + "state": status["state"], + "answer": answer, + "answer_markdown": answer, + "citations": structured["citations"], + "used_wiki": structured["used_wiki"], + "used_business_facts": structured["used_business_facts"], + "data_freshness": structured["data_freshness"], + "warnings": structured["warnings"], + "confidence": structured["confidence"], + "mode": status.get("mode"), + "public_only": status.get("public_only"), + "exit_code": status.get("exit_code"), + "opencode_output_tail": output_tail, + "error": status.get("error", ""), + } + + def _structured_answer_metadata(self, answer: str, *, status: dict[str, Any]) -> dict[str, Any]: + citations = self._extract_wikilink_citations(answer) + lower = answer.lower() + used_business_facts = bool( + re.search(r"\bbusiness facts?\b|\bbusiness data\b|业务数据|业务查询|业务事实", answer, flags=re.IGNORECASE) + ) + used_wiki = bool(citations or "wiki" in lower or "wikilink" in lower) + data_freshness = list(dict.fromkeys(re.findall(r"\b20\d{2}-\d{2}-\d{2}\b", answer))) + warnings: list[str] = [] + state = status.get("state") + error = str(status.get("error") or "").strip() + if state == "failed": + warnings.append("Query job failed.") + if error: + warnings.append(error) + elif state in {"queued", "running"}: + warnings.append(f"Query job is still {state}.") + if not answer: + warnings.append("Query returned no answer text yet.") + confidence = "low" if state == "failed" else "medium" if state in {"queued", "running"} or not answer else "high" + return { + "citations": citations, + "used_wiki": used_wiki, + "used_business_facts": used_business_facts, + "data_freshness": data_freshness, + "warnings": warnings, + "confidence": confidence, + } + + def _extract_wikilink_citations(self, answer: str) -> list[str]: + citations: list[str] = [] + for raw in re.findall(r"\[\[([^\]]+)\]\]", answer): + target = raw.split("|", 1)[0].split("#", 1)[0].strip() + if target and target not in citations: + citations.append(target) + return citations + def _tree_mtime(self, path: Path) -> float: if not path.exists(): return time.time() diff --git a/obsidian_wiki/mcp_server.py b/obsidian_wiki/mcp_server.py index 3ab38ba..d1c63b5 100644 --- a/obsidian_wiki/mcp_server.py +++ b/obsidian_wiki/mcp_server.py @@ -278,6 +278,98 @@ def create_server( ) return status + @server.tool() + def get_mcp_capabilities() -> dict[str, Any]: + """Tell remote agents which MCP capabilities are enabled and which tool to use first.""" + enabled = sorted( + ([TOOL_FILE_ASSETS] if enable_file_assets else []) + + ([TOOL_INGEST] if enable_ingest else []) + + ([TOOL_QUERY] if enable_query else []) + ) + return { + "server_name": MCP_SERVER_NAME, + "enabled_tools": enabled, + "recommended_first_steps": [ + "Use ask_wiki for ordinary wiki, business facts, or hybrid questions when query is enabled.", + "Use search_file_assets before reading source file resources.", + "Use ingest_text_note for short text notes when ingest is enabled.", + "Use get_ingest_upload_instructions before HTTP file upload.", + ], + "file_assets": { + "enabled": enable_file_assets, + "tools": ( + [ + "search_file_assets", + "list_file_assets", + "get_file_asset_metadata", + "asset://source/sha256/{digest}", + ] + if enable_file_assets + else [] + ), + "best_for": "safe engineering source files such as drawings, CAD/3D models, specifications, and test reports", + "blocked_content": "raw orders, prices, email correspondence, complaint evidence, claims, and personal data", + "max_file_bytes": max_file_bytes, + }, + "query": { + "enabled": enable_query, + "preferred_tool": "ask_wiki" if enable_query else None, + "async_tools": ( + [ + "start_wiki_query", + "get_wiki_query_status", + "get_wiki_query_result", + "cancel_wiki_query", + ] + if enable_query + else [] + ), + "tools": ( + [ + "ask_wiki", + "start_wiki_query", + "get_wiki_query_status", + "get_wiki_query_result", + "cancel_wiki_query", + "check_query_opencode_readiness", + ] + if enable_query + else [] + ), + "supports_business_facts": enable_query, + "supports_hybrid_questions": enable_query, + "public_only_disables_business_facts": True, + "max_query_chars": query_max_chars, + }, + "ingest": { + "enabled": enable_ingest, + "preferred_text_tool": "ingest_text_note" if enable_ingest else None, + "http_upload_path": DEFAULT_INGEST_UPLOAD_PATH if enable_ingest else None, + "tools": ( + [ + "get_ingest_upload_instructions", + "ingest_text_note", + "create_ingest_upload_ticket", + "start_ingest_job", + "get_ingest_job_status", + "get_ingest_job_result", + "cancel_ingest_job", + "check_ingest_opencode_readiness", + ] + if enable_ingest + else [] + ), + "max_file_bytes": ingest_max_file_bytes, + "max_files_per_job": ingest_max_files_per_job, + }, + "safety": { + "arbitrary_shell": False, + "arbitrary_prompt": False, + "local_paths_hidden_by_default": not expose_paths, + "model_choice_server_side": True, + }, + } + if enable_ingest: if ingest_manager is None: ingest_manager = IngestManager( @@ -426,6 +518,25 @@ def create_server( max_query_chars=query_max_chars, ) + @server.tool() + def ask_wiki( + query: str, + mode: str = "normal", + public_only: bool = False, + note: str | None = None, + timeout_seconds: int = 90, + poll_interval_seconds: int = 2, + ) -> dict[str, Any]: + """Ask the wiki in one call; returns a final structured answer or a job_id if it times out.""" + return query_manager.ask_wiki( + query, + mode=mode, + public_only=public_only, + note=note, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + ) + @server.tool() def start_wiki_query( query: str, diff --git a/scripts/manage_mcp_service.py b/scripts/manage_mcp_service.py index e228c51..ed24827 100755 --- a/scripts/manage_mcp_service.py +++ b/scripts/manage_mcp_service.py @@ -427,8 +427,9 @@ def render_remote_config( query_section = ( "\n" "Wiki query tools:\n" - "Use start_wiki_query, get_wiki_query_status, get_wiki_query_result, " - "cancel_wiki_query, and check_query_opencode_readiness.\n" + "Use ask_wiki for ordinary wiki, business facts, or hybrid questions.\n" + "For long-running jobs, use start_wiki_query, get_wiki_query_status, " + "get_wiki_query_result, cancel_wiki_query, and check_query_opencode_readiness.\n" ) return ( "MCP service is running.\n" diff --git a/tests/test_mcp_query.py b/tests/test_mcp_query.py index a5fea86..0ac6bec 100644 --- a/tests/test_mcp_query.py +++ b/tests/test_mcp_query.py @@ -150,8 +150,76 @@ class McpQueryTests(unittest.TestCase): self.assertEqual(result["state"], "succeeded") self.assertIn("Based on the wiki", result["answer"]) + self.assertEqual(result["answer_markdown"], result["answer"]) + self.assertEqual(result["citations"], []) + self.assertFalse(result["used_business_facts"]) + self.assertTrue(result["used_wiki"]) + self.assertEqual(result["data_freshness"], []) + self.assertEqual(result["confidence"], "high") + self.assertEqual(result["warnings"], []) self.assertIn("Answer body", result["opencode_output_tail"]) + def test_query_result_extracts_structured_metadata_from_output(self): + popen = FakePopen(returncode=None) + + with ( + patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run), + patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen), + ): + job = self.manager.start_query_job("customer situation?") + + log_path = Path(self.manager._load_job(job["job_id"])["log_path"]) + log_path.write_text( + "Based on [[Mini FAKRA]] and [[BJD]].\n" + "Business facts used. Data through 2026-06-02.\n" + "Answer body\n", + encoding="utf-8", + ) + popen.returncode = 0 + result = self.manager.get_query_job_result(job["job_id"]) + + self.assertEqual(result["answer_markdown"], result["answer"]) + self.assertEqual(result["citations"], ["Mini FAKRA", "BJD"]) + self.assertTrue(result["used_business_facts"]) + self.assertTrue(result["used_wiki"]) + self.assertEqual(result["data_freshness"], ["2026-06-02"]) + self.assertEqual(result["confidence"], "high") + + def test_query_result_marks_failed_jobs_low_confidence_with_warnings(self): + popen = FakePopen(returncode=1) + + with ( + patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run), + patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen), + ): + job = self.manager.start_query_job("what failed?") + + log_path = Path(self.manager._load_job(job["job_id"])["log_path"]) + log_path.write_text("Traceback: failed\n", encoding="utf-8") + result = self.manager.get_query_job_result(job["job_id"]) + + self.assertEqual(result["state"], "failed") + self.assertEqual(result["confidence"], "low") + self.assertTrue(any("failed" in warning.lower() for warning in result["warnings"])) + + def test_ask_wiki_returns_timeout_payload_with_job_id(self): + popen = FakePopen(returncode=None) + + with ( + patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run), + patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen), + ): + result = self.manager.ask_wiki( + "slow question?", + timeout_seconds=0, + poll_interval_seconds=1, + ) + + self.assertEqual(result["state"], "running") + self.assertRegex(result["job_id"], r"^[0-9a-f]{32}$") + self.assertEqual(result["next_poll_after_seconds"], 1) + self.assertIn("timed out", " ".join(result["warnings"]).lower()) + def test_jobs_queue_one_at_a_time_and_cancel_queued_job(self): popen = FakePopen(returncode=None) diff --git a/tests/test_mcp_server_remote.py b/tests/test_mcp_server_remote.py index 1918fff..b759844 100644 --- a/tests/test_mcp_server_remote.py +++ b/tests/test_mcp_server_remote.py @@ -416,12 +416,14 @@ class RemoteMcpServerTests(unittest.TestCase): self.assertIn("search_file_assets", FakeMCP.tool_names) self.assertIn("list_file_assets", FakeMCP.tool_names) self.assertIn("get_file_asset_metadata", FakeMCP.tool_names) + self.assertIn("get_mcp_capabilities", FakeMCP.tool_names) self.assertIn("how_to_use_wiki_file_assets", FakeMCP.prompt_names) self.assertNotIn("create_ingest_upload", FakeMCP.tool_names) def test_create_server_registers_ingest_tools_when_enabled(self): class FakeMCP: tool_names = [] + tools = {} def __init__(self, _name, **_kwargs): pass @@ -429,6 +431,7 @@ class RemoteMcpServerTests(unittest.TestCase): def tool(self): def decorator(func): self.tool_names.append(func.__name__) + self.tools[func.__name__] = func return func return decorator @@ -474,6 +477,7 @@ class RemoteMcpServerTests(unittest.TestCase): def test_create_server_registers_query_tools_only_when_enabled(self): class FakeMCP: tool_names = [] + tools = {} def __init__(self, _name, **_kwargs): pass @@ -481,6 +485,7 @@ class RemoteMcpServerTests(unittest.TestCase): def tool(self): def decorator(func): self.tool_names.append(func.__name__) + self.tools[func.__name__] = func return func return decorator @@ -498,6 +503,16 @@ class RemoteMcpServerTests(unittest.TestCase): return decorator class FakeQueryManager: + def ask_wiki(self, query, mode="normal", public_only=False, note=None, timeout_seconds=90, poll_interval_seconds=2): + return { + "job_id": "query-job", + "state": "succeeded", + "answer": "answer", + "answer_markdown": "answer", + "citations": [], + "warnings": [], + } + def start_query_job(self, query, mode="normal", public_only=False, note=None): return {"job_id": "query-job", "state": "queued"} @@ -532,11 +547,89 @@ class RemoteMcpServerTests(unittest.TestCase): self.assertIsInstance(disabled_server, FakeMCP) self.assertIsInstance(enabled_server, FakeMCP) self.assertNotIn("start_wiki_query", disabled_tool_names) + self.assertNotIn("ask_wiki", disabled_tool_names) + self.assertIn("ask_wiki", FakeMCP.tool_names) self.assertIn("start_wiki_query", FakeMCP.tool_names) self.assertIn("get_wiki_query_status", FakeMCP.tool_names) self.assertIn("get_wiki_query_result", FakeMCP.tool_names) self.assertIn("cancel_wiki_query", FakeMCP.tool_names) self.assertIn("check_query_opencode_readiness", FakeMCP.tool_names) + self.assertEqual( + FakeMCP.tools["ask_wiki"]("what do I know?", timeout_seconds=3, poll_interval_seconds=1)["answer_markdown"], + "answer", + ) + + def test_get_mcp_capabilities_reports_agent_facing_tools_without_local_paths(self): + class FakeMCP: + tools = {} + + def __init__(self, _name, **_kwargs): + pass + + def tool(self): + def decorator(func): + self.tools[func.__name__] = func + return func + + return decorator + + def resource(self, *_args, **_kwargs): + def decorator(func): + return func + + return decorator + + def prompt(self, *_args, **_kwargs): + def decorator(func): + return func + + return decorator + + with ( + tempfile.TemporaryDirectory() as tmp, + patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}), + ): + root = Path(tmp) + mcp_server.create_server( + vault_path=root / "vault", + enable_ingest=True, + enable_query=True, + project_root=root / "project", + ingest_runtime_root=root / "ingest-runtime", + query_runtime_root=root / "query-runtime", + ingest_manager=IngestManager( + vault_path=root / "vault", + project_root=root / "project", + runtime_root=root / "fake-ingest", + ), + query_manager=type( + "FakeQueryManager", + (), + { + "ask_wiki": lambda self, query, mode="normal", public_only=False, note=None, timeout_seconds=90, poll_interval_seconds=2: {}, + "start_query_job": lambda self, query, mode="normal", public_only=False, note=None: {}, + "get_query_job_status": lambda self, job_id: {}, + "get_query_job_result": lambda self, job_id: {}, + "cancel_query_job": lambda self, job_id: {}, + "check_opencode_readiness": lambda self, run_smoke_test=False: {}, + }, + )(), + ) + capabilities = FakeMCP.tools["get_mcp_capabilities"]() + + self.assertEqual(capabilities["server_name"], "wiki-file-assets") + self.assertEqual(capabilities["enabled_tools"], ["file-assets", "ingest", "query"]) + self.assertTrue(capabilities["query"]["enabled"]) + self.assertEqual(capabilities["query"]["preferred_tool"], "ask_wiki") + self.assertIn("ask_wiki", capabilities["query"]["tools"]) + self.assertTrue(capabilities["ingest"]["enabled"]) + self.assertEqual(capabilities["ingest"]["http_upload_path"], "/mcp-ingest/uploads") + self.assertEqual(capabilities["ingest"]["preferred_text_tool"], "ingest_text_note") + self.assertIn("search_file_assets", capabilities["file_assets"]["tools"]) + self.assertNotIn("vault_path", json.dumps(capabilities)) + self.assertNotIn("runtime", json.dumps(capabilities).lower()) + self.assertNotIn("Bearer ", json.dumps(capabilities)) + self.assertNotIn("upload_token", json.dumps(capabilities)) def test_file_index_status_reports_opencode_model_settings(self): class FakeMCP: @@ -589,6 +682,7 @@ class RemoteMcpServerTests(unittest.TestCase): "FakeQueryManager", (), { + "ask_wiki": lambda self, query, mode="normal", public_only=False, note=None, timeout_seconds=90, poll_interval_seconds=2: {}, "start_query_job": lambda self, query, mode="normal", public_only=False, note=None: {}, "get_query_job_status": lambda self, job_id: {}, "get_query_job_result": lambda self, job_id: {}, diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index aa3a9a0..0ae312f 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -332,6 +332,7 @@ class McpServiceConfigTests(unittest.TestCase): self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal\n", env_content) self.assertNotIn("OBSIDIAN_MCP_INGEST_OPENCODE_PATH=", env_content) self.assertIn("Enabled tool groups:\nfile-assets,query", output) + self.assertIn("Use ask_wiki for ordinary wiki, business facts, or hybrid questions.", output) prepare_runtime.assert_called_once_with(project_root, uv_path=Path("/home/nac/.local/bin/uv")) run_systemctl.assert_has_calls( [