From ef2e44656dba100f416d628d93432de63f4186c6 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 5 Jul 2026 16:10:09 +0000 Subject: [PATCH] fix(mcp): persist OpenCode path for ingest service Resolve and write an absolute OpenCode path when enabling the ingest tool group so systemd services without an interactive PATH can pass ingest readiness checks. Preserve an existing OBSIDIAN_MCP_INGEST_OPENCODE_PATH across start, token rotation, and tool enable/disable updates instead of dropping it when rewriting the runtime env file. Validation: python3 -m unittest discover -v; python3 -m py_compile scripts/manage_mcp_service.py tests/test_mcp_service.py; git diff --check; live MCP tools/list and check_ingest_opencode_readiness calls passed after setting the service config. --- scripts/manage_mcp_service.py | 74 +++++++++++++++++++++++++++++------ tests/test_mcp_service.py | 9 +++++ 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/scripts/manage_mcp_service.py b/scripts/manage_mcp_service.py index a4a6c91..50d096c 100755 --- a/scripts/manage_mcp_service.py +++ b/scripts/manage_mcp_service.py @@ -30,6 +30,7 @@ DEFAULT_PORT = 8765 DEFAULT_PATH = "/mcp" DEFAULT_TOKEN_BYTES = 32 DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache" +DEFAULT_OPENCODE_PATH = "opencode" TOOL_FILE_ASSETS = "file-assets" TOOL_INGEST = "ingest" AVAILABLE_TOOL_GROUPS = (TOOL_FILE_ASSETS, TOOL_INGEST) @@ -113,6 +114,18 @@ def find_uv_path() -> Path: return Path(discovered).expanduser().resolve() +def resolve_opencode_path(configured: str | Path | None = None) -> str: + value = str(configured or "").strip() + if value: + configured_path = Path(value).expanduser() + if configured_path.is_absolute() or configured_path.parent != Path("."): + return str(configured_path.resolve()) + discovered = shutil.which(value) + return str(Path(discovered).resolve()) if discovered else value + discovered = shutil.which(DEFAULT_OPENCODE_PATH) + return str(Path(discovered).resolve()) if discovered else DEFAULT_OPENCODE_PATH + + def resolve_service_paths(project_root: Path, config_home: Path | None = None) -> ServicePaths: root = project_root.expanduser().resolve() base_config = config_home or Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) @@ -215,6 +228,7 @@ def write_token_env_file( path: str = DEFAULT_PATH, enabled_tools: Sequence[str] | None = None, enable_ingest: bool | None = None, + opencode_path: str | Path | None = None, ) -> None: tools = normalize_enabled_tools(DEFAULT_ENABLED_TOOLS if enabled_tools is None else enabled_tools) tools = update_ingest_tool(tools, enable_ingest) @@ -228,6 +242,8 @@ def write_token_env_file( env_file.write(f"OBSIDIAN_MCP_PATH={path}\n") env_file.write(f"OBSIDIAN_MCP_ENABLED_TOOLS={format_enabled_tools(tools)}\n") env_file.write(f"OBSIDIAN_MCP_ENABLE_INGEST={1 if TOOL_INGEST in tools else 0}\n") + if opencode_path: + env_file.write(f"OBSIDIAN_MCP_INGEST_OPENCODE_PATH={opencode_path}\n") env_path.chmod(0o600) @@ -246,7 +262,7 @@ def read_service_env_file(env_path: Path) -> dict[str, str]: return values -def saved_service_config(project_root: Path) -> tuple[str, str, int, str, tuple[str, ...]]: +def saved_service_config(project_root: Path) -> tuple[str, str, int, str, tuple[str, ...], str]: paths = resolve_service_paths(project_root) values = read_service_env_file(paths.env_path) token = values.get("OBSIDIAN_MCP_TOKEN", "").strip() @@ -256,7 +272,8 @@ def saved_service_config(project_root: Path) -> tuple[str, str, int, str, tuple[ port = int(values.get("OBSIDIAN_MCP_PORT") or DEFAULT_PORT) path = values.get("OBSIDIAN_MCP_PATH") or DEFAULT_PATH enabled_tools = enabled_tools_from_config(values) - return token, host, port, path, enabled_tools + opencode_path = values.get("OBSIDIAN_MCP_INGEST_OPENCODE_PATH", "").strip() + return token, host, port, path, enabled_tools, opencode_path def write_service_unit( @@ -352,7 +369,7 @@ def render_remote_config( def show_config(project_root: Path) -> str: paths = resolve_service_paths(project_root) - token, _host, port, path, enabled_tools = saved_service_config(project_root) + token, _host, port, path, enabled_tools, _opencode_path = saved_service_config(project_root) return render_remote_config( hosts=detect_remote_hosts(), token=token, @@ -384,12 +401,21 @@ def new_token( ) -> str: paths = resolve_service_paths(project_root) enabled_tools = DEFAULT_ENABLED_TOOLS + opencode_path = "" try: - _old_token, _old_host, _old_port, _old_path, enabled_tools = saved_service_config(project_root) + _old_token, _old_host, _old_port, _old_path, enabled_tools, opencode_path = saved_service_config(project_root) except RuntimeError: pass token = generate_token(token_bytes) - write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=enabled_tools) + write_token_env_file( + paths.env_path, + token, + host=host, + port=port, + path=path, + enabled_tools=enabled_tools, + opencode_path=opencode_path, + ) restart_service_if_installed(paths) return render_remote_config( hosts=detect_remote_hosts(), @@ -413,14 +439,25 @@ def start_service( ) -> str: paths = resolve_service_paths(project_root) try: - token, host, port, path, enabled_tools = saved_service_config(project_root) + token, host, port, path, enabled_tools, opencode_path = saved_service_config(project_root) except RuntimeError: if paths.env_path.exists(): raise token = generate_token(token_bytes) enabled_tools = DEFAULT_ENABLED_TOOLS + opencode_path = "" enabled_tools = update_ingest_tool(enabled_tools, enable_ingest) - write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=enabled_tools) + if TOOL_INGEST in enabled_tools and not opencode_path: + opencode_path = resolve_opencode_path() + write_token_env_file( + paths.env_path, + token, + host=host, + port=port, + path=path, + enabled_tools=enabled_tools, + opencode_path=opencode_path, + ) uv_path = find_uv_path() prepare_runtime(paths.project_root, uv_path=uv_path) resolved_vault = resolve_vault_for_service(paths.project_root, vault_path=vault_path) @@ -462,35 +499,46 @@ def render_tool_status( return "\n".join(lines) + "\n" -def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, int, str, tuple[str, ...]]: +def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, int, str, tuple[str, ...], str]: paths = resolve_service_paths(project_root) try: - token, host, port, path, enabled_tools = saved_service_config(project_root) + token, host, port, path, enabled_tools, opencode_path = saved_service_config(project_root) except RuntimeError: token = generate_token() host = DEFAULT_HOST port = DEFAULT_PORT path = DEFAULT_PATH enabled_tools = DEFAULT_ENABLED_TOOLS + opencode_path = "" write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=enabled_tools) - return paths, token, host, port, path, enabled_tools + return paths, token, host, port, path, enabled_tools, opencode_path def list_tools(project_root: Path) -> str: - paths, _token, _host, _port, _path, enabled_tools = ensure_service_config(project_root) + paths, _token, _host, _port, _path, enabled_tools, _opencode_path = ensure_service_config(project_root) return render_tool_status(config_path=paths.env_path, enabled_tools=enabled_tools) def set_tool_enabled(project_root: Path, tool: str, enabled: bool) -> str: tool_name = normalize_tool_name(tool) - paths, token, host, port, path, enabled_tools = ensure_service_config(project_root) + paths, token, host, port, path, enabled_tools, opencode_path = ensure_service_config(project_root) current = set(enabled_tools) if enabled: current.add(tool_name) else: current.discard(tool_name) updated_tools = tuple(item for item in AVAILABLE_TOOL_GROUPS if item in current) - write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=updated_tools) + if TOOL_INGEST in updated_tools and not opencode_path: + opencode_path = resolve_opencode_path() + write_token_env_file( + paths.env_path, + token, + host=host, + port=port, + path=path, + enabled_tools=updated_tools, + opencode_path=opencode_path, + ) restarted = restart_service_if_installed(paths) return render_tool_status(config_path=paths.env_path, enabled_tools=updated_tools, restarted=restarted) diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index bff3390..2446329 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -199,6 +199,7 @@ class McpServiceConfigTests(unittest.TestCase): 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, ): @@ -217,6 +218,7 @@ class McpServiceConfigTests(unittest.TestCase): self.assertIn("OBSIDIAN_MCP_PATH=/wiki-mcp\n", env_content) self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", env_content) self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", env_content) + self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_PATH=/home/nac/.opencode/bin/opencode\n", env_content) self.assertIn("http://10.0.0.4:9876/wiki-mcp", output) self.assertIn("Authorization: Bearer created-token", output) self.assertIn('"type": "streamable-http"', output) @@ -242,6 +244,12 @@ class McpServiceConfigTests(unittest.TestCase): path="/mcp", enabled_tools=("file-assets", "ingest"), ) + env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env" + env_path.write_text( + env_path.read_text(encoding="utf-8") + + "OBSIDIAN_MCP_INGEST_OPENCODE_PATH=/custom/bin/opencode\n", + encoding="utf-8", + ) with ( patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}), @@ -256,6 +264,7 @@ class McpServiceConfigTests(unittest.TestCase): env_content = (project_root / ".runtime" / "obsidian-wiki-mcp.env").read_text(encoding="utf-8") self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", env_content) 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_main_tools_enable_disable_updates_enabled_tools_in_config(self): with tempfile.TemporaryDirectory() as tmp: