Files
obsidian-wiki/tests/test_mcp_query.py
T
Codex 7eb9b987ab feat(mcp): harden remote assets and job execution
Gate source-file downloads and verify registry paths and content hashes. Stream multipart uploads with bounded limits, supervise OpenCode jobs with explicit result markers and timeouts, and preserve managed endpoint security settings. Document the trusted-client boundary and add focused regression coverage.

Validation: PYTHONPATH=<staged-tree> python -m unittest discover -s tests (182 tests)
2026-07-09 21:44:48 +00:00

487 lines
21 KiB
Python

import subprocess
import json
import os
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from obsidian_wiki.mcp_query import QueryError, QueryManager
class McpQueryTests(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.TemporaryDirectory()
self.root = Path(self.tempdir.name)
self.vault = self.root / "vault"
self.project = self.root / "project"
self.runtime = self.project / ".runtime" / "mcp-query"
self.vault.mkdir()
self.project.mkdir()
self._write_opencode_skill_fixture(self.project)
self.manager = QueryManager(
vault_path=self.vault,
project_root=self.project,
runtime_root=self.runtime,
opencode_path=Path("/usr/bin/opencode"),
max_query_chars=80,
)
def tearDown(self):
self.manager.close()
self.tempdir.cleanup()
def test_check_opencode_readiness_static_success(self):
with patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run) as run_mock:
result = self.manager.check_opencode_readiness()
self.assertTrue(result["ok"])
self.assertTrue(result["static_preflight"])
self.assertEqual(result["runtime_smoke_test"], "skipped")
self.assertEqual(result["opencode_version"], "1.17.13")
self.assertFalse(result["errors"])
self.assertTrue(any(path.endswith(".agents/skills/wiki-query/SKILL.md") for path in result["checked_paths"]))
self.assertEqual(run_mock.call_count, 2)
def test_check_opencode_readiness_reports_missing_skill_files(self):
missing_project = self.root / "missing-project"
missing_project.mkdir()
(missing_project / "AGENTS.md").write_text("# Agent rules\n", encoding="utf-8")
manager = QueryManager(
vault_path=self.vault,
project_root=missing_project,
runtime_root=self.root / "runtime-missing",
opencode_path=Path("/usr/bin/opencode"),
)
with patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run):
result = manager.check_opencode_readiness()
self.assertFalse(result["ok"])
self.assertFalse(result["static_preflight"])
self.assertIn(".agents/skills/wiki-query/SKILL.md", "\n".join(result["errors"]))
self.assertIn(".agents/skills/llm-wiki/SKILL.md", "\n".join(result["errors"]))
def test_check_opencode_readiness_smoke_test_success(self):
self.manager.opencode_model = "openai/gpt-5.5"
self.manager.opencode_variant = "minimal"
with patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_smoke_success_run) as run_mock:
result = self.manager.check_opencode_readiness(run_smoke_test=True)
self.assertTrue(result["ok"])
self.assertEqual(result["runtime_smoke_test"], "passed")
self.assertEqual(run_mock.call_count, 3)
smoke_args = run_mock.call_args_list[-1].args[0]
self.assertEqual(smoke_args[:4], ["/usr/bin/opencode", "run", "--format", "json"])
self.assertEqual(smoke_args[smoke_args.index("--model") + 1], "openai/gpt-5.5")
self.assertEqual(smoke_args[smoke_args.index("--variant") + 1], "minimal")
self.assertIn("--auto", smoke_args)
self.assertIn("--dir", smoke_args)
self.assertIn("MCP query readiness smoke test", smoke_args)
def test_query_rejects_empty_oversized_and_invalid_mode(self):
with self.assertRaises(QueryError):
self.manager.start_query_job("")
with self.assertRaises(QueryError):
self.manager.start_query_job("x" * 81)
with self.assertRaises(QueryError):
self.manager.start_query_job("what do I know?", mode="full")
def test_close_still_closes_supervisor_when_queued_job_write_fails(self):
queued = {"state": "queued"}
with (
patch.object(self.manager, "_iter_jobs", return_value=[queued]),
patch.object(self.manager, "_write_job", side_effect=OSError("disk full")),
patch.object(self.manager._supervisor, "close") as supervisor_close,
):
with self.assertRaises(OSError):
self.manager.close()
supervisor_close.assert_called_once_with()
def test_start_query_job_constructs_restricted_opencode_command(self):
self.manager.opencode_model = "openai/gpt-5.5"
self.manager.opencode_variant = "minimal"
popen = FakePopen()
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) as popen_mock,
):
job = self.manager.start_query_job(
"what do I know about Mini FAKRA?",
mode="index_only",
public_only=True,
note="remote agent asked",
)
args = popen_mock.call_args.args[0]
self.assertEqual(args[:5], ["/usr/bin/opencode", "run", "--format", "json", "--auto"])
self.assertEqual(args[args.index("--model") + 1], "openai/gpt-5.5")
self.assertEqual(args[args.index("--variant") + 1], "minimal")
self.assertIn("--dir", args)
self.assertEqual(args[args.index("--dir") + 1], str(self.project))
self.assertIn("--title", args)
self.assertIn(f"MCP query {job['job_id']}", args)
prompt = args[-1]
self.assertIn("/wiki-query", prompt)
self.assertIn("what do I know about Mini FAKRA?", prompt)
self.assertIn("index-only", prompt)
self.assertIn("public only", prompt)
self.assertIn("normal wiki-query log entry is allowed", prompt)
self.assertIn("wiki-ingest, wiki-update, wiki-rebuild", prompt)
self.assertIn("obsidian-wiki business schema --json", prompt)
self.assertIn("obsidian-wiki business template", prompt)
self.assertIn("obsidian-wiki business sql", prompt)
self.assertIn("read-only business facts", prompt)
self.assertIn("hybrid business + wiki questions", prompt)
self.assertIn("two read-only tracks", prompt)
self.assertIn("parallel subagents when the host supports them", prompt)
self.assertIn("run those tracks sequentially", prompt)
self.assertIn("customer, product, salesperson, project, delivery, anomaly, ranking, or quality", prompt)
self.assertNotIn("spawn read-only subagents", prompt)
self.assertIn("Do not run arbitrary shell commands", prompt)
self.assertIn("If Visibility is public only, do not use business facts", prompt)
self.assertNotIn("shell commands, file writes", prompt)
self.assertNotIn("rm -rf", prompt)
def test_query_result_returns_opencode_output_tail(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("what do I know?")
status = self.manager.get_query_job_status(job["job_id"])
self.assertNotIn("log_path", status)
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
log_path.write_text(
self._json_text_event("MCP_QUERY_OK\nBased on the wiki:\nAnswer body"),
encoding="utf-8",
)
popen.returncode = 0
result = self.manager.get_query_job_result(job["job_id"])
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(
self._json_text_event(
"MCP_QUERY_OK\n"
"Based on [[Mini FAKRA]] and [[BJD]].\n"
"Business facts used. Data through 2026-06-02.\n"
"Answer body"
),
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(self._json_text_event("MCP_QUERY_FAILED\nTraceback: failed"), 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_query_result_rejects_skill_failure_with_zero_exit_code(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("what failed?")
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
log_path.write_text(self._json_text_event("MCP_QUERY_FAILED\nSKILL_LOAD_FAILED: missing"), encoding="utf-8")
popen.returncode = 0
result = self.manager.get_query_job_result(job["job_id"])
self.assertEqual(result["state"], "failed")
self.assertEqual(result["confidence"], "low")
self.assertIn("SKILL_LOAD_FAILED", result["error"])
def test_query_result_accepts_only_final_answer_event_and_hides_paths(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("safe answer?")
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
log_path.write_text(
'{"type":"tool_use","part":{"state":{"output":"/home/private/secret"}}}\n'
+ self._json_text_event("MCP_QUERY_OK\nBased on [[BJD]]."),
encoding="utf-8",
)
popen.returncode = 0
result = self.manager.get_query_job_result(job["job_id"])
self.assertEqual(result["state"], "succeeded")
self.assertEqual(result["answer_markdown"], "Based on [[BJD]].")
self.assertNotIn("/home/private", result["opencode_output_tail"])
status = self.manager.get_query_job_status(job["job_id"])
self.assertNotIn("pid", status)
self.assertNotIn("checked_paths", status["preflight"])
internal = self.manager._load_job(job["job_id"])
internal["error"] = f"failed while reading {self.project / 'secret.txt'}"
internal["preflight"]["warnings"] = [f"check {self.runtime / 'private.log'}"]
self.manager._write_job(internal)
redacted = self.manager.get_query_job_status(job["job_id"])
serialized = json.dumps(redacted)
self.assertNotIn(str(self.project), serialized)
self.assertNotIn(str(self.runtime), serialized)
self.assertIn("<project>", serialized)
self.assertIn("<runtime>", serialized)
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)
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),
):
first = self.manager.start_query_job("first query")
second = self.manager.start_query_job("second query")
self.assertEqual(self.manager.get_query_job_status(first["job_id"])["state"], "running")
self.assertEqual(self.manager.get_query_job_status(second["job_id"])["state"], "queued")
cancelled = self.manager.cancel_query_job(second["job_id"])
self.assertEqual(cancelled["state"], "cancelled")
def test_close_cancels_running_and_queued_jobs_and_releases_runtime(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),
):
first = self.manager.start_query_job("first query")
second = self.manager.start_query_job("second query")
self.manager.close()
self.assertEqual(self.manager._load_job(first["job_id"])["state"], "cancelled")
self.assertEqual(self.manager._load_job(second["job_id"])["state"], "cancelled")
replacement = QueryManager(
vault_path=self.vault,
project_root=self.project,
runtime_root=self.runtime,
opencode_path=Path("/usr/bin/opencode"),
)
replacement.close()
with self.assertRaises(QueryError):
self.manager.start_query_job("after close")
def test_completed_job_advances_queue_without_status_polling(self):
first_process = FakePopen(returncode=None)
second_process = 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", side_effect=[first_process, second_process]),
):
first = self.manager.start_query_job("first query")
second = self.manager.start_query_job("second query")
first_log = Path(self.manager._load_job(first["job_id"])["log_path"])
first_log.write_text(self._json_text_event("MCP_QUERY_OK\nfirst answer"), encoding="utf-8")
first_process.returncode = 0
deadline = time.monotonic() + 1
while time.monotonic() < deadline and self.manager._load_job(second["job_id"])["state"] != "running":
time.sleep(0.01)
self.assertEqual(self.manager._load_job(first["job_id"])["state"], "succeeded")
self.assertEqual(self.manager._load_job(second["job_id"])["state"], "running")
def test_failed_preflight_does_not_block_later_queued_job(self):
first_process = FakePopen(returncode=None)
third_process = FakePopen(returncode=None)
failed_readiness = {"ok": False, "errors": ["skill missing"]}
successful_readiness = {"ok": True, "errors": []}
with (
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
patch("obsidian_wiki.mcp_query.subprocess.Popen", side_effect=[first_process, third_process]),
):
first = self.manager.start_query_job("first query")
second = self.manager.start_query_job("second query")
third = self.manager.start_query_job("third query")
first_log = Path(self.manager._load_job(first["job_id"])["log_path"])
first_log.write_text(self._json_text_event("MCP_QUERY_OK\nfirst answer"), encoding="utf-8")
with patch.object(
self.manager,
"check_opencode_readiness",
side_effect=[failed_readiness, successful_readiness],
):
completed = self.manager._supervisor._complete_naturally(first["job_id"], 0)
self.assertTrue(completed)
self.assertEqual(self.manager._load_job(second["job_id"])["state"], "failed")
self.assertEqual(self.manager._load_job(third["job_id"])["state"], "running")
def test_cleanup_runtime_prunes_old_terminal_query_jobs(self):
popen = FakePopen(returncode=0)
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("old query")
self.manager.refresh_jobs()
job_dir = self.manager.jobs_root / job["job_id"]
old_epoch = time.time() - 40 * 24 * 60 * 60
self._touch_tree(job_dir, old_epoch)
result = self.manager.cleanup_runtime(max_age_days=30)
self.assertEqual(result["old_jobs_deleted"], 1)
self.assertFalse(job_dir.exists())
def _touch_tree(self, path: Path, epoch: float) -> None:
for child in path.rglob("*"):
os.utime(child, (epoch, epoch))
os.utime(path, (epoch, epoch))
def _write_opencode_skill_fixture(self, root: Path) -> None:
(root / "AGENTS.md").write_text("# Agent rules\n", encoding="utf-8")
wiki_query = root / ".agents" / "skills" / "wiki-query"
llm_wiki = root / ".agents" / "skills" / "llm-wiki"
wiki_query.mkdir(parents=True)
llm_wiki.mkdir(parents=True)
(wiki_query / "SKILL.md").write_text(
"---\nname: wiki-query\ndescription: Answer questions from the wiki.\n---\n",
encoding="utf-8",
)
(llm_wiki / "SKILL.md").write_text(
"---\nname: llm-wiki\ndescription: Shared wiki framework instructions.\n---\n",
encoding="utf-8",
)
def _fake_static_readiness_run(self, args, **_kwargs):
if args == ["/usr/bin/opencode", "--version"]:
return subprocess.CompletedProcess(args, 0, stdout="1.17.13\n", stderr="")
if args == ["/usr/bin/opencode", "run", "--help"]:
return subprocess.CompletedProcess(
args,
0,
stdout="--format\n--auto\n--dir\n--title\n",
stderr="",
)
raise AssertionError(f"unexpected subprocess.run args: {args}")
def _fake_smoke_success_run(self, args, **kwargs):
if args in (["/usr/bin/opencode", "--version"], ["/usr/bin/opencode", "run", "--help"]):
return self._fake_static_readiness_run(args, **kwargs)
if args[:3] == ["/usr/bin/opencode", "run", "--format"]:
return subprocess.CompletedProcess(
args,
0,
stdout=self._json_text_event("WIKI_QUERY_SKILL_LOADED"),
stderr="",
)
raise AssertionError(f"unexpected subprocess.run args: {args}")
def _json_text_event(self, text):
import json
return json.dumps(
{
"type": "text",
"part": {
"type": "text",
"text": text,
"metadata": {"openai": {"phase": "final_answer"}},
},
}
) + "\n"
class FakePopen:
def __init__(self, returncode=None):
self.returncode = returncode
self.pid = 12345
self.terminated = False
def poll(self):
return self.returncode
def wait(self, timeout=None):
deadline = time.monotonic() + (timeout or 0)
while self.returncode is None and (timeout is None or time.monotonic() < deadline):
time.sleep(0.01)
if self.returncode is None:
raise subprocess.TimeoutExpired("fake", timeout)
return self.returncode
def terminate(self):
self.terminated = True
self.returncode = -15
def kill(self):
self.returncode = -9
if __name__ == "__main__":
unittest.main()