Add a query tool group for the wiki-file-assets MCP server that queues fixed /wiki-query OpenCode jobs, exposes status/result/cancel/readiness tools, and keeps query disabled until explicitly enabled. Document the new service switches and config, keep remote query gated in AGENTS.md, preserve token behavior, and pass proxy environment through the managed service so OpenCode smoke/query jobs can reach the model provider. Validation: python3 -m unittest discover -v; python3 -m py_compile obsidian_wiki/mcp_query.py obsidian_wiki/mcp_server.py scripts/manage_mcp_service.py tests/test_mcp_query.py tests/test_mcp_server_remote.py tests/test_mcp_service.py; git diff --check.
210 lines
8.7 KiB
Python
210 lines
8.7 KiB
Python
import subprocess
|
|
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.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):
|
|
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", "default"])
|
|
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_start_query_job_constructs_restricted_opencode_command(self):
|
|
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", "default", "--auto"])
|
|
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("Do not run wiki-ingest, wiki-update, wiki-rebuild", 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?")
|
|
|
|
log_path = Path(self.manager.get_query_job_status(job["job_id"])["log_path"])
|
|
log_path.write_text("Based on the wiki:\nAnswer body\n", 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.assertIn("Answer body", result["opencode_output_tail"])
|
|
|
|
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 _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="WIKI_QUERY_SKILL_LOADED\n", stderr="")
|
|
raise AssertionError(f"unexpected subprocess.run args: {args}")
|
|
|
|
|
|
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()
|