Files
obsidian-wiki/scripts/manage_mcp_service.py
T
Codex 3d0203fb9e feat(mcp): configure OpenCode models per workflow
Add server-side model and variant settings for MCP ingest and query OpenCode jobs. Ingest defaults to openai/gpt-5.5 high, while query defaults to openai/gpt-5.5 minimal, and readiness/status surfaces the active settings.

Persist the settings through the managed service env file and preserve local overrides across start, tools, and token rotations. Remote MCP clients still cannot choose arbitrary models per call.

Validation: python3 -m unittest discover -v; python3 -m py_compile obsidian_wiki/mcp_ingest.py obsidian_wiki/mcp_query.py obsidian_wiki/mcp_server.py scripts/manage_mcp_service.py tests/test_mcp_ingest.py tests/test_mcp_query.py tests/test_mcp_server_remote.py tests/test_mcp_service.py; git diff --check.
2026-07-05 18:20:06 +00:00

806 lines
29 KiB
Python
Executable File

#!/usr/bin/env python3
"""Manage the user-level HTTP MCP service for obsidian-wiki source files."""
from __future__ import annotations
import argparse
import os
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from obsidian_wiki.config import resolve_vault_path
SERVICE_NAME = "obsidian-wiki-mcp.service"
ENV_RELATIVE_PATH = Path(".runtime") / "obsidian-wiki-mcp.env"
MCP_CLIENT_NAME = "wiki-file-assets"
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8765
DEFAULT_PATH = "/mcp"
DEFAULT_INGEST_UPLOAD_PATH = "/mcp-ingest/uploads"
DEFAULT_TOKEN_BYTES = 32
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
DEFAULT_OPENCODE_PATH = "opencode"
DEFAULT_INGEST_OPENCODE_MODEL = "openai/gpt-5.5"
DEFAULT_INGEST_OPENCODE_VARIANT = "high"
DEFAULT_QUERY_OPENCODE_MODEL = "openai/gpt-5.5"
DEFAULT_QUERY_OPENCODE_VARIANT = "minimal"
TOOL_FILE_ASSETS = "file-assets"
TOOL_INGEST = "ingest"
TOOL_QUERY = "query"
AVAILABLE_TOOL_GROUPS = (TOOL_FILE_ASSETS, TOOL_INGEST, TOOL_QUERY)
DEFAULT_ENABLED_TOOLS = (TOOL_FILE_ASSETS,)
PROXY_ENV_NAMES = ("http_proxy", "https_proxy", "all_proxy", "no_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY")
def normalize_tool_name(name: str) -> str:
normalized = name.strip().lower().replace("_", "-")
aliases = {
"file": TOOL_FILE_ASSETS,
"files": TOOL_FILE_ASSETS,
"file-assets": TOOL_FILE_ASSETS,
"source-file": TOOL_FILE_ASSETS,
"source-files": TOOL_FILE_ASSETS,
"ingest": TOOL_INGEST,
"upload": TOOL_INGEST,
"uploads": TOOL_INGEST,
"opencode-ingest": TOOL_INGEST,
"query": TOOL_QUERY,
"wiki-query": TOOL_QUERY,
"ask": TOOL_QUERY,
"question": TOOL_QUERY,
"questions": TOOL_QUERY,
}
if normalized not in aliases:
available = ", ".join(AVAILABLE_TOOL_GROUPS)
raise ValueError(f"unknown MCP tool group: {name}. Available: {available}")
return aliases[normalized]
def parse_bool(value: str | None) -> bool:
return str(value or "").strip().lower() in {"1", "true", "yes", "on", "enabled"}
def normalize_enabled_tools(tools: Sequence[str]) -> tuple[str, ...]:
enabled = {normalize_tool_name(tool) for tool in tools if str(tool).strip()}
return tuple(tool for tool in AVAILABLE_TOOL_GROUPS if tool in enabled)
def enabled_tools_from_config(values: dict[str, str]) -> tuple[str, ...]:
raw_tools = values.get("OBSIDIAN_MCP_ENABLED_TOOLS", "").strip()
if raw_tools:
if raw_tools.lower() in {"none", "off", "disabled"}:
return ()
return normalize_enabled_tools(raw_tools.split(","))
enabled = set(DEFAULT_ENABLED_TOOLS)
if parse_bool(values.get("OBSIDIAN_MCP_ENABLE_INGEST")):
enabled.add(TOOL_INGEST)
return tuple(tool for tool in AVAILABLE_TOOL_GROUPS if tool in enabled)
def format_enabled_tools(enabled_tools: Sequence[str]) -> str:
return ",".join(normalize_enabled_tools(enabled_tools))
def update_ingest_tool(enabled_tools: Sequence[str], enable_ingest: bool | None) -> tuple[str, ...]:
return update_tool_group(enabled_tools, TOOL_INGEST, enable_ingest)
def update_query_tool(enabled_tools: Sequence[str], enable_query: bool | None) -> tuple[str, ...]:
return update_tool_group(enabled_tools, TOOL_QUERY, enable_query)
def update_tool_group(enabled_tools: Sequence[str], tool: str, enabled: bool | None) -> tuple[str, ...]:
current = set(normalize_enabled_tools(enabled_tools))
tool_name = normalize_tool_name(tool)
if enabled is True:
current.add(tool_name)
elif enabled is False:
current.discard(tool_name)
return tuple(tool for tool in AVAILABLE_TOOL_GROUPS if tool in current)
@dataclass(frozen=True)
class ServicePaths:
project_root: Path
unit_path: Path
env_path: Path
@dataclass(frozen=True)
class OpenCodeModelSettings:
ingest_model: str = DEFAULT_INGEST_OPENCODE_MODEL
ingest_variant: str = DEFAULT_INGEST_OPENCODE_VARIANT
query_model: str = DEFAULT_QUERY_OPENCODE_MODEL
query_variant: str = DEFAULT_QUERY_OPENCODE_VARIANT
def generate_token(num_bytes: int = DEFAULT_TOKEN_BYTES) -> str:
return secrets.token_urlsafe(num_bytes)
def systemd_quote(value: str | Path) -> str:
return shlex.quote(str(value))
def find_uv_path() -> Path:
configured = os.environ.get("UV_PATH", "").strip()
discovered = configured or shutil.which("uv")
if not discovered:
raise RuntimeError("Could not find uv on PATH. Install uv or set UV_PATH to its absolute 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"))
unit_path = base_config.expanduser() / "systemd" / "user" / SERVICE_NAME
return ServicePaths(project_root=root, unit_path=unit_path, env_path=root / ENV_RELATIVE_PATH)
def resolve_vault_for_service(project_root: Path, vault_path: Path | str | None = None) -> Path:
if vault_path is not None:
return Path(vault_path).expanduser().resolve()
configured = resolve_vault_path(None, start=project_root)
if configured:
return Path(configured).expanduser().resolve()
return project_root.expanduser().resolve()
def render_service_unit(
*,
project_root: Path,
vault_path: Path,
env_file: Path,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
uv_cache_dir: str = DEFAULT_UV_CACHE_DIR,
uv_path: Path | None = None,
) -> str:
resolved_uv_path = uv_path or find_uv_path()
command = " ".join(
[
systemd_quote(resolved_uv_path),
"run",
"--no-sync",
"python",
"-m",
"obsidian_wiki.mcp_server",
"--vault",
systemd_quote(vault_path),
"--transport",
"streamable-http",
"--host",
host,
"--port",
str(port),
"--path",
path,
]
)
return (
"[Unit]\n"
"Description=obsidian-wiki readonly HTTP MCP server\n"
"After=network-online.target\n"
"Wants=network-online.target\n"
"\n"
"[Service]\n"
"Type=simple\n"
f"WorkingDirectory={systemd_quote(project_root)}\n"
f"EnvironmentFile={systemd_quote(env_file)}\n"
f"Environment=UV_CACHE_DIR={systemd_quote(uv_cache_dir)}\n"
f"PassEnvironment={' '.join(PROXY_ENV_NAMES)}\n"
f"ExecStart={command}\n"
"Restart=on-failure\n"
"RestartSec=5\n"
"\n"
"[Install]\n"
"WantedBy=default.target\n"
)
def prepare_runtime(
project_root: Path,
*,
uv_path: Path | None = None,
uv_cache_dir: str = DEFAULT_UV_CACHE_DIR,
) -> None:
resolved_uv_path = uv_path or find_uv_path()
env = {**os.environ, "UV_CACHE_DIR": uv_cache_dir}
subprocess.run(
[
str(resolved_uv_path),
"run",
"--extra",
"mcp",
"python",
"-c",
"import mcp.server.fastmcp",
],
cwd=project_root,
env=env,
text=True,
check=True,
)
def write_token_env_file(
env_path: Path,
token: str,
*,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
enabled_tools: Sequence[str] | None = None,
enable_ingest: bool | None = None,
enable_query: bool | None = None,
opencode_path: str | Path | None = None,
model_settings: OpenCodeModelSettings | 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)
tools = update_query_tool(tools, enable_query)
models = model_settings or OpenCodeModelSettings()
env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.parent.chmod(0o700)
fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as env_file:
env_file.write(f"OBSIDIAN_MCP_TOKEN={token}\n")
env_file.write(f"OBSIDIAN_MCP_HOST={host}\n")
env_file.write(f"OBSIDIAN_MCP_PORT={port}\n")
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")
env_file.write(f"OBSIDIAN_MCP_INGEST_OPENCODE_MODEL={models.ingest_model}\n")
env_file.write(f"OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT={models.ingest_variant}\n")
env_file.write(f"OBSIDIAN_MCP_QUERY_OPENCODE_MODEL={models.query_model}\n")
env_file.write(f"OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT={models.query_variant}\n")
if opencode_path:
env_file.write(f"OBSIDIAN_MCP_OPENCODE_PATH={opencode_path}\n")
if TOOL_INGEST in tools:
env_file.write(f"OBSIDIAN_MCP_INGEST_OPENCODE_PATH={opencode_path}\n")
env_path.chmod(0o600)
def read_service_env_file(env_path: Path) -> dict[str, str]:
if not env_path.exists():
raise RuntimeError(f"Missing MCP service env file: {env_path}. Run scripts/mcp_service.sh start to create it.")
values: dict[str, str] = {}
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
key, separator, value = line.partition("=")
if separator:
values[key.strip()] = value.strip()
return values
def model_settings_from_config(values: dict[str, str]) -> OpenCodeModelSettings:
return OpenCodeModelSettings(
ingest_model=values.get("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL", DEFAULT_INGEST_OPENCODE_MODEL).strip()
or DEFAULT_INGEST_OPENCODE_MODEL,
ingest_variant=values.get("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT", DEFAULT_INGEST_OPENCODE_VARIANT).strip()
or DEFAULT_INGEST_OPENCODE_VARIANT,
query_model=values.get("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL", DEFAULT_QUERY_OPENCODE_MODEL).strip()
or DEFAULT_QUERY_OPENCODE_MODEL,
query_variant=values.get("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT", DEFAULT_QUERY_OPENCODE_VARIANT).strip()
or DEFAULT_QUERY_OPENCODE_VARIANT,
)
def saved_service_config(project_root: Path) -> tuple[str, str, int, str, tuple[str, ...], str, OpenCodeModelSettings]:
paths = resolve_service_paths(project_root)
values = read_service_env_file(paths.env_path)
token = values.get("OBSIDIAN_MCP_TOKEN", "").strip()
if not token:
raise RuntimeError(f"Missing OBSIDIAN_MCP_TOKEN in {paths.env_path}")
host = values.get("OBSIDIAN_MCP_HOST") or DEFAULT_HOST
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)
opencode_path = (
values.get("OBSIDIAN_MCP_OPENCODE_PATH", "").strip()
or values.get("OBSIDIAN_MCP_INGEST_OPENCODE_PATH", "").strip()
)
return token, host, port, path, enabled_tools, opencode_path, model_settings_from_config(values)
def write_service_unit(
paths: ServicePaths,
*,
vault_path: Path,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
uv_path: Path | None = None,
) -> None:
paths.unit_path.parent.mkdir(parents=True, exist_ok=True)
unit = render_service_unit(
project_root=paths.project_root,
vault_path=vault_path,
env_file=paths.env_path,
host=host,
port=port,
path=path,
uv_path=uv_path,
)
paths.unit_path.write_text(unit, encoding="utf-8")
def detect_remote_hosts() -> list[str]:
hosts: list[str] = []
try:
result = subprocess.run(["hostname", "-I"], capture_output=True, text=True, check=False)
except OSError:
result = None
if result and result.returncode == 0:
hosts.extend(result.stdout.split())
try:
for info in socket.getaddrinfo(socket.gethostname(), None):
hosts.append(info[4][0])
except OSError:
pass
cleaned: list[str] = []
for host in hosts:
normalized = host.strip()
if not normalized or normalized.startswith("127.") or normalized == "::1" or normalized.startswith("fe80:"):
continue
if normalized not in cleaned:
cleaned.append(normalized)
return cleaned
def render_remote_config(
*,
hosts: Sequence[str],
token: str,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
config_path: Path | None = None,
enabled_tools: Sequence[str] | None = None,
) -> str:
host = hosts[0] if hosts else "<server-ip>"
url = f"http://{host}:{port}{path}"
alternatives = "\n".join(f"- http://{candidate}:{port}{path}" for candidate in hosts[1:])
alternatives_section = f"\nOther detected addresses:\n{alternatives}\n" if alternatives else ""
config_section = f"\nConfig file:\n{config_path}\n" if config_path is not None else ""
enabled_section = ""
if enabled_tools is not None:
enabled_section = f"\nEnabled tool groups:\n{format_enabled_tools(enabled_tools) or 'none'}\n"
ingest_upload_section = ""
if enabled_tools is not None and TOOL_INGEST in normalize_enabled_tools(enabled_tools):
upload_url = f"http://{host}:{port}{DEFAULT_INGEST_UPLOAD_PATH}"
upload_alternatives = "\n".join(
f"- http://{candidate}:{port}{DEFAULT_INGEST_UPLOAD_PATH}" for candidate in hosts[1:]
)
upload_alternatives_section = (
f"\nOther ingest upload addresses:\n{upload_alternatives}\n" if upload_alternatives else ""
)
ingest_upload_section = (
"\n"
"Ingest file upload endpoint:\n"
f"{upload_url}\n"
f"{upload_alternatives_section}"
"Use the same Authorization header. POST multipart/form-data with one or more `files` fields.\n"
)
query_section = ""
if enabled_tools is not None and TOOL_QUERY in normalize_enabled_tools(enabled_tools):
query_section = (
"\n"
"Wiki query tools:\n"
"Use start_wiki_query, get_wiki_query_status, get_wiki_query_result, "
"cancel_wiki_query, and check_query_opencode_readiness.\n"
)
return (
"MCP service is running.\n"
"\n"
"Use this URL from another machine:\n"
f"{url}\n"
f"{alternatives_section}"
f"{config_section}"
f"{enabled_section}"
f"{ingest_upload_section}"
f"{query_section}"
"\n"
"Use this header:\n"
f"Authorization: Bearer {token}\n"
"\n"
"Example MCP client config:\n"
"{\n"
' "mcpServers": {\n'
f' "{MCP_CLIENT_NAME}": {{\n'
' "type": "streamable-http",\n'
f' "url": "{url}",\n'
' "headers": {\n'
f' "Authorization": "Bearer {token}"\n'
" }\n"
" }\n"
" }\n"
"}\n"
)
def show_config(project_root: Path) -> str:
paths = resolve_service_paths(project_root)
token, _host, port, path, enabled_tools, _opencode_path, _models = saved_service_config(project_root)
return render_remote_config(
hosts=detect_remote_hosts(),
token=token,
port=port,
path=path,
config_path=paths.env_path,
enabled_tools=enabled_tools,
)
def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(["systemctl", "--user", *args], text=True, check=check)
def import_proxy_environment() -> None:
names = [name for name in PROXY_ENV_NAMES if os.environ.get(name)]
if names:
run_systemctl(["import-environment", *names], check=False)
def restart_service_if_installed(paths: ServicePaths) -> bool:
if not paths.unit_path.exists():
return False
run_systemctl(["restart", SERVICE_NAME])
return True
def new_token(
*,
project_root: Path,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
token_bytes: int = DEFAULT_TOKEN_BYTES,
) -> str:
paths = resolve_service_paths(project_root)
enabled_tools = DEFAULT_ENABLED_TOOLS
opencode_path = ""
model_settings = OpenCodeModelSettings()
try:
_old_token, _old_host, _old_port, _old_path, enabled_tools, opencode_path, model_settings = 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,
opencode_path=opencode_path,
model_settings=model_settings,
)
restart_service_if_installed(paths)
return render_remote_config(
hosts=detect_remote_hosts(),
token=token,
port=port,
path=path,
config_path=paths.env_path,
enabled_tools=enabled_tools,
)
def start_service(
*,
project_root: Path,
vault_path: Path | str | None = None,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
token_bytes: int = DEFAULT_TOKEN_BYTES,
enable_ingest: bool | None = None,
enable_query: bool | None = None,
) -> str:
paths = resolve_service_paths(project_root)
try:
token, host, port, path, enabled_tools, opencode_path, model_settings = 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 = ""
model_settings = OpenCodeModelSettings()
enabled_tools = update_ingest_tool(enabled_tools, enable_ingest)
enabled_tools = update_query_tool(enabled_tools, enable_query)
if ({TOOL_INGEST, TOOL_QUERY} & set(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,
model_settings=model_settings,
)
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)
write_service_unit(paths, vault_path=resolved_vault, host=host, port=port, path=path, uv_path=uv_path)
if {TOOL_INGEST, TOOL_QUERY} & set(enabled_tools):
import_proxy_environment()
run_systemctl(["daemon-reload"])
run_systemctl(["enable", SERVICE_NAME])
run_systemctl(["restart", SERVICE_NAME])
return render_remote_config(
hosts=detect_remote_hosts(),
token=token,
port=port,
path=path,
config_path=paths.env_path,
enabled_tools=enabled_tools,
)
def render_tool_status(
*,
config_path: Path,
enabled_tools: Sequence[str],
restarted: bool = False,
) -> str:
enabled = set(normalize_enabled_tools(enabled_tools))
lines = [
"MCP tool configuration.",
"",
f"Config file: {config_path}",
f"Enabled tool groups: {format_enabled_tools(enabled_tools) or 'none'}",
"",
"Available tool groups:",
]
for tool in AVAILABLE_TOOL_GROUPS:
state = "enabled" if tool in enabled else "disabled"
lines.append(f"- {tool}: {state}")
lines.append("")
lines.append(f"Service restarted: {'yes' if restarted else 'no'}")
return "\n".join(lines) + "\n"
def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, int, str, tuple[str, ...], str, OpenCodeModelSettings]:
paths = resolve_service_paths(project_root)
try:
token, host, port, path, enabled_tools, opencode_path, model_settings = 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 = ""
model_settings = OpenCodeModelSettings()
write_token_env_file(
paths.env_path,
token,
host=host,
port=port,
path=path,
enabled_tools=enabled_tools,
model_settings=model_settings,
)
return paths, token, host, port, path, enabled_tools, opencode_path, model_settings
def list_tools(project_root: Path) -> str:
paths, _token, _host, _port, _path, enabled_tools, _opencode_path, _models = 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, opencode_path, model_settings = 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)
if ({TOOL_INGEST, TOOL_QUERY} & set(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,
model_settings=model_settings,
)
if paths.unit_path.exists() and {TOOL_INGEST, TOOL_QUERY} & set(updated_tools):
import_proxy_environment()
restarted = restart_service_if_installed(paths)
return render_tool_status(config_path=paths.env_path, enabled_tools=updated_tools, restarted=restarted)
def stop_service() -> None:
run_systemctl(["stop", SERVICE_NAME])
def uninstall_service(project_root: Path) -> None:
paths = resolve_service_paths(project_root)
run_systemctl(["disable", "--now", SERVICE_NAME], check=False)
if paths.unit_path.exists():
paths.unit_path.unlink()
if paths.env_path.exists():
paths.env_path.unlink()
try:
paths.env_path.parent.rmdir()
except OSError:
pass
run_systemctl(["daemon-reload"], check=False)
run_systemctl(["reset-failed", SERVICE_NAME], check=False)
def service_status() -> None:
run_systemctl(["status", SERVICE_NAME], check=False)
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Manage the readonly HTTP obsidian-wiki MCP user service.")
subparsers = parser.add_subparsers(dest="command", required=True)
new_token_parser = subparsers.add_parser("new-token", help="Generate a token and print remote MCP client configuration.")
new_token_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
new_token_parser.add_argument("--host", default=DEFAULT_HOST)
new_token_parser.add_argument("--port", default=DEFAULT_PORT, type=int)
new_token_parser.add_argument("--path", default=DEFAULT_PATH)
new_token_parser.add_argument("--token-bytes", default=DEFAULT_TOKEN_BYTES, type=int)
start_parser = subparsers.add_parser("start", help="Install or update the user service and print remote MCP config.")
start_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
start_parser.add_argument("--vault", dest="vault_path", type=Path, help="Vault path to serve. Defaults to OBSIDIAN_VAULT_PATH config.")
start_parser.add_argument("--host", default=DEFAULT_HOST)
start_parser.add_argument("--port", default=DEFAULT_PORT, type=int)
start_parser.add_argument("--path", default=DEFAULT_PATH)
start_parser.add_argument("--token-bytes", default=DEFAULT_TOKEN_BYTES, type=int)
ingest_group = start_parser.add_mutually_exclusive_group()
ingest_group.add_argument(
"--enable-ingest",
dest="enable_ingest",
action="store_true",
default=None,
help="Expose write-capable upload and local OpenCode ingest MCP tools.",
)
ingest_group.add_argument(
"--disable-ingest",
dest="enable_ingest",
action="store_false",
help="Disable write-capable upload and local OpenCode ingest MCP tools.",
)
query_group = start_parser.add_mutually_exclusive_group()
query_group.add_argument(
"--enable-query",
dest="enable_query",
action="store_true",
default=None,
help="Expose local OpenCode wiki-query MCP tools.",
)
query_group.add_argument(
"--disable-query",
dest="enable_query",
action="store_false",
help="Disable local OpenCode wiki-query MCP tools.",
)
subparsers.add_parser("stop", help="Stop the user service without deleting service files.")
uninstall_parser = subparsers.add_parser("uninstall", help="Stop and remove the managed user service and token file.")
uninstall_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
show_config_parser = subparsers.add_parser("show-config", help="Print the saved token and remote MCP client configuration.")
show_config_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
subparsers.add_parser("status", help="Show systemd status for the managed service.")
tools_parser = subparsers.add_parser("tools", help="List or update enabled MCP tool groups in the service config.")
tools_subparsers = tools_parser.add_subparsers(dest="tools_command", required=True)
tools_list_parser = tools_subparsers.add_parser("list", help="List configured MCP tool groups.")
tools_list_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
tools_enable_parser = tools_subparsers.add_parser("enable", help="Enable an MCP tool group.")
tools_enable_parser.add_argument("tool", choices=AVAILABLE_TOOL_GROUPS)
tools_enable_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
tools_disable_parser = tools_subparsers.add_parser("disable", help="Disable an MCP tool group.")
tools_disable_parser.add_argument("tool", choices=AVAILABLE_TOOL_GROUPS)
tools_disable_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
args = parser.parse_args(argv)
if args.command == "new-token":
print(
new_token(
project_root=args.project_root,
host=args.host,
port=args.port,
path=args.path,
token_bytes=args.token_bytes,
),
end="",
)
return 0
if args.command == "start":
print(
start_service(
project_root=args.project_root,
vault_path=args.vault_path,
host=args.host,
port=args.port,
path=args.path,
token_bytes=args.token_bytes,
enable_ingest=args.enable_ingest,
enable_query=args.enable_query,
),
end="",
)
return 0
if args.command == "stop":
stop_service()
print(f"Stopped {SERVICE_NAME}.")
return 0
if args.command == "uninstall":
uninstall_service(args.project_root)
print(f"Uninstalled {SERVICE_NAME}.")
return 0
if args.command == "show-config":
print(show_config(args.project_root), end="")
return 0
if args.command == "status":
service_status()
return 0
if args.command == "tools":
if args.tools_command == "list":
print(list_tools(args.project_root), end="")
return 0
if args.tools_command == "enable":
print(set_tool_enabled(args.project_root, args.tool, True), end="")
return 0
if args.tools_command == "disable":
print(set_tool_enabled(args.project_root, args.tool, False), end="")
return 0
raise AssertionError(f"unhandled command: {args.command}")
if __name__ == "__main__":
raise SystemExit(main())