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.
This commit is contained in:
Codex
2026-07-05 16:10:09 +00:00
parent 937d9a002f
commit ef2e44656d
2 changed files with 70 additions and 13 deletions
+61 -13
View File
@@ -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)
+9
View File
@@ -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: