Rename the Streamable HTTP MCP server and generated client config key from obsidian-wiki to wiki-file-assets so remote agents see the narrower file lookup purpose. Keep existing tool names unchanged and cover the service name in tests.
437 lines
14 KiB
Python
Executable File
437 lines
14 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_TOKEN_BYTES = 32
|
|
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
|
|
|
|
|
|
@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_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"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,
|
|
) -> None:
|
|
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_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]:
|
|
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
|
|
return token, host, port, 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,
|
|
) -> 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 ""
|
|
return (
|
|
"MCP service is running.\n"
|
|
"\n"
|
|
"Use this URL from another machine:\n"
|
|
f"{url}\n"
|
|
f"{alternatives_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:
|
|
token, _host, port, path = saved_service_config(project_root)
|
|
return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path)
|
|
|
|
|
|
def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(["systemctl", "--user", *args], text=True, check=check)
|
|
|
|
|
|
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)
|
|
token = generate_token(token_bytes)
|
|
write_token_env_file(paths.env_path, token, host=host, port=port, path=path)
|
|
restart_service_if_installed(paths)
|
|
return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path)
|
|
|
|
|
|
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,
|
|
) -> str:
|
|
paths = resolve_service_paths(project_root)
|
|
try:
|
|
token, host, port, path = saved_service_config(project_root)
|
|
except RuntimeError:
|
|
if paths.env_path.exists():
|
|
raise
|
|
token = generate_token(token_bytes)
|
|
write_token_env_file(paths.env_path, token, host=host, port=port, path=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)
|
|
|
|
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)
|
|
|
|
|
|
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)
|
|
|
|
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.")
|
|
|
|
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,
|
|
),
|
|
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
|
|
raise AssertionError(f"unhandled command: {args.command}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|