feat(mcp): add OpenCode-backed wiki query

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.
This commit is contained in:
Codex
2026-07-05 18:00:53 +00:00
parent c8433a2e2e
commit e7913d4c6b
9 changed files with 1085 additions and 12 deletions
+209
View File
@@ -0,0 +1,209 @@
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()
+70
View File
@@ -142,6 +142,9 @@ class RemoteMcpServerTests(unittest.TestCase):
with patch.dict(os.environ, {"OBSIDIAN_MCP_ENABLE_INGEST": "1"}, clear=True):
self.assertEqual(mcp_server.enabled_tool_names(), {"file-assets", "ingest"})
with patch.dict(os.environ, {"OBSIDIAN_MCP_ENABLED_TOOLS": "file-assets,query"}, clear=True):
self.assertEqual(mcp_server.enabled_tool_names(), {"file-assets", "query"})
def test_create_http_app_wraps_streamable_http_app_with_auth(self):
class FakeServer:
def __init__(self):
@@ -355,6 +358,73 @@ class RemoteMcpServerTests(unittest.TestCase):
self.assertIn("create_ingest_upload_ticket", FakeMCP.tool_names)
self.assertNotIn("ingest_file_base64", FakeMCP.tool_names)
def test_create_server_registers_query_tools_only_when_enabled(self):
class FakeMCP:
tool_names = []
def __init__(self, _name, **_kwargs):
pass
def tool(self):
def decorator(func):
self.tool_names.append(func.__name__)
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
class FakeQueryManager:
def start_query_job(self, query, mode="normal", public_only=False, note=None):
return {"job_id": "query-job", "state": "queued"}
def get_query_job_status(self, job_id):
return {"job_id": job_id, "state": "running"}
def get_query_job_result(self, job_id):
return {"job_id": job_id, "state": "succeeded", "answer": "answer"}
def cancel_query_job(self, job_id):
return {"job_id": job_id, "state": "cancelled"}
def check_opencode_readiness(self, *, run_smoke_test=False):
return {"ok": True, "runtime_smoke_test": "skipped"}
with (
tempfile.TemporaryDirectory() as tmp,
patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}),
):
root = Path(tmp)
disabled_server = mcp_server.create_server(vault_path=root / "vault")
disabled_tool_names = list(FakeMCP.tool_names)
FakeMCP.tool_names.clear()
enabled_server = mcp_server.create_server(
vault_path=root / "vault",
enable_query=True,
project_root=root / "project",
query_runtime_root=root / "runtime",
query_manager=FakeQueryManager(),
)
self.assertIsInstance(disabled_server, FakeMCP)
self.assertIsInstance(enabled_server, FakeMCP)
self.assertNotIn("start_wiki_query", disabled_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)
def test_create_ingest_upload_ticket_tool_returns_http_only_token(self):
class FakeMCP:
tools = {}
+109
View File
@@ -47,6 +47,7 @@ class McpServiceConfigTests(unittest.TestCase):
self.assertIn("--transport streamable-http", unit)
self.assertIn("--host 0.0.0.0", unit)
self.assertIn("--port 8765", unit)
self.assertIn("PassEnvironment=http_proxy https_proxy all_proxy no_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY", unit)
self.assertNotIn("OBSIDIAN_MCP_TOKEN=", unit)
def test_write_token_env_file_is_private(self):
@@ -279,6 +280,41 @@ class McpServiceConfigTests(unittest.TestCase):
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", env_content)
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_PATH=/custom/bin/opencode\n", env_content)
def test_start_service_can_enable_query_without_ingest(self):
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
config_home = Path(tmp) / "config"
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "generate_token", return_value="created-token"),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.4"]),
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
patch.object(manage_mcp_service.shutil, "which", return_value="/home/nac/.opencode/bin/opencode"),
patch.object(manage_mcp_service, "prepare_runtime") as prepare_runtime,
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
):
output = manage_mcp_service.start_service(
project_root=project_root,
enable_query=True,
)
env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env"
env_content = env_path.read_text(encoding="utf-8")
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,query\n", env_content)
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=0\n", env_content)
self.assertIn("OBSIDIAN_MCP_OPENCODE_PATH=/home/nac/.opencode/bin/opencode\n", env_content)
self.assertNotIn("OBSIDIAN_MCP_INGEST_OPENCODE_PATH=", env_content)
self.assertIn("Enabled tool groups:\nfile-assets,query", output)
prepare_runtime.assert_called_once_with(project_root, uv_path=Path("/home/nac/.local/bin/uv"))
run_systemctl.assert_has_calls(
[
call(["daemon-reload"]),
call(["enable", manage_mcp_service.SERVICE_NAME]),
call(["restart", manage_mcp_service.SERVICE_NAME]),
]
)
def test_main_tools_enable_disable_updates_enabled_tools_in_config(self):
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
@@ -328,6 +364,67 @@ class McpServiceConfigTests(unittest.TestCase):
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=\n", env_content)
self.assertIn("file-assets: disabled", print_fn.call_args.args[0])
def test_main_tools_enable_query_updates_enabled_tools_in_config(self):
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
config_home = Path(tmp) / "config"
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "generate_token", return_value="created-token"),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
patch.object(manage_mcp_service.shutil, "which", return_value="/home/nac/.opencode/bin/opencode"),
patch("builtins.print") as print_fn,
):
enable_status = manage_mcp_service.main(
["tools", "enable", "query", "--project-root", str(project_root)]
)
self.assertEqual(enable_status, 0)
env_content = (project_root / ".runtime" / "obsidian-wiki-mcp.env").read_text(encoding="utf-8")
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,query\n", env_content)
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=0\n", env_content)
self.assertIn("OBSIDIAN_MCP_OPENCODE_PATH=/home/nac/.opencode/bin/opencode\n", env_content)
self.assertIn("query: enabled", print_fn.call_args.args[0])
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch("builtins.print") as print_fn,
):
disable_status = manage_mcp_service.main(
["tools", "disable", "query", "--project-root", str(project_root)]
)
self.assertEqual(disable_status, 0)
env_content = (project_root / ".runtime" / "obsidian-wiki-mcp.env").read_text(encoding="utf-8")
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets\n", env_content)
self.assertIn("query: disabled", print_fn.call_args.args[0])
def test_set_tool_enabled_imports_proxy_environment_when_restarting_query_service(self):
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
config_home = Path(tmp) / "config"
paths = manage_mcp_service.resolve_service_paths(project_root, config_home=config_home)
paths.unit_path.parent.mkdir(parents=True)
paths.unit_path.write_text("unit\n", encoding="utf-8")
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home), "https_proxy": "http://proxy.local:8080"}, clear=True),
patch.object(manage_mcp_service.shutil, "which", return_value="/home/nac/.opencode/bin/opencode"),
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
):
output = manage_mcp_service.set_tool_enabled(project_root, "query", True)
self.assertIn("query: enabled", output)
run_systemctl.assert_has_calls(
[
call(["import-environment", "https_proxy"], check=False),
call(["restart", manage_mcp_service.SERVICE_NAME]),
]
)
def test_main_start_passes_enable_ingest_to_start_service(self):
with (
patch.object(manage_mcp_service, "start_service", return_value="config\n") as start_service,
@@ -340,6 +437,18 @@ class McpServiceConfigTests(unittest.TestCase):
self.assertTrue(start_service.call_args.kwargs["enable_ingest"])
print_fn.assert_called_once_with("config\n", end="")
def test_main_start_passes_enable_query_to_start_service(self):
with (
patch.object(manage_mcp_service, "start_service", return_value="config\n") as start_service,
patch("builtins.print") as print_fn,
):
status = manage_mcp_service.main(["start", "--enable-query"])
self.assertEqual(status, 0)
start_service.assert_called_once()
self.assertTrue(start_service.call_args.kwargs["enable_query"])
print_fn.assert_called_once_with("config\n", end="")
def test_main_new_token_does_not_expect_enable_ingest_argument(self):
with (
patch.object(manage_mcp_service, "new_token", return_value="config\n") as new_token,