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.
761 lines
27 KiB
Python
Executable File
761 lines
27 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"
|
|
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
|
|
|
|
|
|
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,
|
|
) -> 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)
|
|
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")
|
|
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 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()
|
|
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
|
|
|
|
|
|
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 = 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 = ""
|
|
try:
|
|
_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,
|
|
opencode_path=opencode_path,
|
|
)
|
|
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 = 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)
|
|
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,
|
|
)
|
|
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]:
|
|
paths = resolve_service_paths(project_root)
|
|
try:
|
|
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, opencode_path
|
|
|
|
|
|
def list_tools(project_root: Path) -> str:
|
|
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, 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)
|
|
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,
|
|
)
|
|
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())
|