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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user