Default ticket, multipart, and text-note ingest entry points to staging without launching a job. Propagate WIKI_ARCHIVE_SOURCES from project configuration into the managed service environment.\n\nValidated with the full 206-test suite.
1326 lines
49 KiB
Python
Executable File
1326 lines
49 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 json
|
|
import os
|
|
import secrets
|
|
import shlex
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass, replace
|
|
from pathlib import Path
|
|
from typing import Sequence
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
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 load_config, 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"
|
|
PUBLIC_BASE_URL_ENV = "OBSIDIAN_MCP_PUBLIC_BASE_URL"
|
|
PUBLIC_UPLOAD_URL_ENV = "OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL"
|
|
ALLOW_SOURCE_FILES_ENV = "OBSIDIAN_MCP_ALLOW_SOURCE_FILES"
|
|
ALLOWED_HOSTS_ENV = "OBSIDIAN_MCP_ALLOWED_HOSTS"
|
|
ALLOWED_ORIGINS_ENV = "OBSIDIAN_MCP_ALLOWED_ORIGINS"
|
|
ARCHIVE_SOURCES_ENV = "WIKI_ARCHIVE_SOURCES"
|
|
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"
|
|
DEFAULT_INGEST_JOB_TIMEOUT_SECONDS = 30 * 60
|
|
DEFAULT_QUERY_JOB_TIMEOUT_SECONDS = 5 * 60
|
|
DEFAULT_INGEST_MAX_REQUEST_BYTES = 0
|
|
DEFAULT_SERVICE_MEMORY_MAX = "2G"
|
|
DEFAULT_RUNTIME_CLEANUP_DAYS = 30
|
|
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,)
|
|
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
|
|
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 project_archive_sources(project_root: Path, *, default: bool = False) -> bool:
|
|
value = load_config(project_root).get(ARCHIVE_SOURCES_ENV)
|
|
return default if value in (None, "") else parse_bool(value)
|
|
|
|
|
|
def parse_config_list(value: str | None) -> tuple[str, ...]:
|
|
return tuple(item.strip() for item in str(value or "").split(",") if item.strip())
|
|
|
|
|
|
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
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ServiceConfig:
|
|
token: str
|
|
host: str = DEFAULT_HOST
|
|
port: int = DEFAULT_PORT
|
|
path: str = DEFAULT_PATH
|
|
public_base_url: str = ""
|
|
public_upload_url: str = ""
|
|
enabled_tools: tuple[str, ...] = DEFAULT_ENABLED_TOOLS
|
|
allow_source_files: bool = False
|
|
archive_sources: bool = False
|
|
allowed_hosts: tuple[str, ...] = ()
|
|
allowed_origins: tuple[str, ...] = ()
|
|
ingest_job_timeout_seconds: int = DEFAULT_INGEST_JOB_TIMEOUT_SECONDS
|
|
query_job_timeout_seconds: int = DEFAULT_QUERY_JOB_TIMEOUT_SECONDS
|
|
ingest_max_request_bytes: int = DEFAULT_INGEST_MAX_REQUEST_BYTES
|
|
opencode_path: str = ""
|
|
model_settings: OpenCodeModelSettings = OpenCodeModelSettings()
|
|
|
|
|
|
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,
|
|
memory_max: str = DEFAULT_SERVICE_MEMORY_MAX,
|
|
) -> 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",
|
|
]
|
|
)
|
|
return (
|
|
"[Unit]\n"
|
|
"Description=obsidian-wiki 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"
|
|
f"MemoryMax={memory_max}\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,
|
|
public_base_url: str | None = None,
|
|
public_upload_url: str | None = None,
|
|
enabled_tools: Sequence[str] | None = None,
|
|
enable_ingest: bool | None = None,
|
|
enable_query: bool | None = None,
|
|
allow_source_files: bool | None = None,
|
|
archive_sources: bool = False,
|
|
allowed_hosts: Sequence[str] | None = None,
|
|
allowed_origins: Sequence[str] | None = None,
|
|
ingest_job_timeout_seconds: int = DEFAULT_INGEST_JOB_TIMEOUT_SECONDS,
|
|
query_job_timeout_seconds: int = DEFAULT_QUERY_JOB_TIMEOUT_SECONDS,
|
|
ingest_max_request_bytes: int = DEFAULT_INGEST_MAX_REQUEST_BYTES,
|
|
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)
|
|
source_files_allowed = bool(allow_source_files)
|
|
archive_sources_enabled = bool(archive_sources)
|
|
models = model_settings or OpenCodeModelSettings()
|
|
normalized_public_base_url = normalize_public_base_url(public_base_url)
|
|
normalized_public_upload_url = normalize_public_upload_url(public_upload_url)
|
|
effective_allowed_hosts = tuple(
|
|
derive_allowed_hosts(
|
|
[host],
|
|
public_base_url=normalized_public_base_url,
|
|
public_upload_url=normalized_public_upload_url,
|
|
)
|
|
if allowed_hosts is None
|
|
else allowed_hosts
|
|
)
|
|
effective_allowed_origins = tuple(
|
|
derive_allowed_origins(normalized_public_base_url, normalized_public_upload_url)
|
|
if allowed_origins is None
|
|
else allowed_origins
|
|
)
|
|
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")
|
|
if normalized_public_base_url:
|
|
env_file.write(f"{PUBLIC_BASE_URL_ENV}={normalized_public_base_url}\n")
|
|
if normalized_public_upload_url:
|
|
env_file.write(f"{PUBLIC_UPLOAD_URL_ENV}={normalized_public_upload_url}\n")
|
|
env_file.write(f"{ALLOW_SOURCE_FILES_ENV}={1 if source_files_allowed else 0}\n")
|
|
env_file.write(f"{ARCHIVE_SOURCES_ENV}={1 if archive_sources_enabled else 0}\n")
|
|
env_file.write(f"{ALLOWED_HOSTS_ENV}={','.join(effective_allowed_hosts)}\n")
|
|
env_file.write(f"{ALLOWED_ORIGINS_ENV}={','.join(effective_allowed_origins)}\n")
|
|
env_file.write(f"OBSIDIAN_MCP_INGEST_JOB_TIMEOUT_SECONDS={max(1, int(ingest_job_timeout_seconds))}\n")
|
|
env_file.write(f"OBSIDIAN_MCP_QUERY_JOB_TIMEOUT_SECONDS={max(1, int(query_job_timeout_seconds))}\n")
|
|
env_file.write(f"OBSIDIAN_MCP_INGEST_MAX_REQUEST_BYTES={max(0, int(ingest_max_request_bytes))}\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/obsidian-wiki-ops.py mcp 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) -> ServiceConfig:
|
|
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
|
|
public_base_url = normalize_public_base_url(values.get(PUBLIC_BASE_URL_ENV))
|
|
public_upload_url = normalize_public_upload_url(values.get(PUBLIC_UPLOAD_URL_ENV))
|
|
enabled_tools = enabled_tools_from_config(values)
|
|
if ALLOW_SOURCE_FILES_ENV in values:
|
|
allow_source_files = parse_bool(values[ALLOW_SOURCE_FILES_ENV])
|
|
else:
|
|
allow_source_files = TOOL_FILE_ASSETS in enabled_tools
|
|
archive_sources = parse_bool(values.get(ARCHIVE_SOURCES_ENV))
|
|
allowed_hosts = parse_config_list(values.get(ALLOWED_HOSTS_ENV))
|
|
if not allowed_hosts:
|
|
allowed_hosts = derive_allowed_hosts(
|
|
[host, *detect_remote_hosts()],
|
|
public_base_url=public_base_url,
|
|
public_upload_url=public_upload_url,
|
|
)
|
|
allowed_origins = parse_config_list(values.get(ALLOWED_ORIGINS_ENV))
|
|
if ALLOWED_ORIGINS_ENV not in values:
|
|
allowed_origins = derive_allowed_origins(public_base_url, public_upload_url)
|
|
opencode_path = (
|
|
values.get("OBSIDIAN_MCP_OPENCODE_PATH", "").strip()
|
|
or values.get("OBSIDIAN_MCP_INGEST_OPENCODE_PATH", "").strip()
|
|
)
|
|
return ServiceConfig(
|
|
token=token,
|
|
host=host,
|
|
port=port,
|
|
path=path,
|
|
public_base_url=public_base_url,
|
|
public_upload_url=public_upload_url,
|
|
enabled_tools=enabled_tools,
|
|
allow_source_files=allow_source_files,
|
|
archive_sources=archive_sources,
|
|
allowed_hosts=allowed_hosts,
|
|
allowed_origins=allowed_origins,
|
|
ingest_job_timeout_seconds=max(
|
|
1,
|
|
int(values.get("OBSIDIAN_MCP_INGEST_JOB_TIMEOUT_SECONDS") or DEFAULT_INGEST_JOB_TIMEOUT_SECONDS),
|
|
),
|
|
query_job_timeout_seconds=max(
|
|
1,
|
|
int(values.get("OBSIDIAN_MCP_QUERY_JOB_TIMEOUT_SECONDS") or DEFAULT_QUERY_JOB_TIMEOUT_SECONDS),
|
|
),
|
|
ingest_max_request_bytes=max(
|
|
0,
|
|
int(values.get("OBSIDIAN_MCP_INGEST_MAX_REQUEST_BYTES") or DEFAULT_INGEST_MAX_REQUEST_BYTES),
|
|
),
|
|
opencode_path=opencode_path,
|
|
model_settings=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 public_base_url_from_hosts(hosts: Sequence[str], *, port: int = DEFAULT_PORT, path: str = DEFAULT_PATH) -> str:
|
|
host = hosts[0] if hosts else ""
|
|
return f"http://{_url_host(host)}:{port}{path}" if host else ""
|
|
|
|
|
|
def public_hosts_for_bind(bind_host: str, detected_hosts: Sequence[str]) -> tuple[str, ...]:
|
|
normalized = str(bind_host).strip()
|
|
if normalized.strip("[]") in {"0.0.0.0", "::", ""}:
|
|
return tuple(detected_hosts)
|
|
return (normalized,)
|
|
|
|
|
|
def _url_host(host: str) -> str:
|
|
value = str(host).strip()
|
|
if ":" in value and not value.startswith("["):
|
|
return f"[{value}]"
|
|
return value
|
|
|
|
|
|
def normalize_public_base_url(public_base_url: str | None) -> str:
|
|
value = str(public_base_url or "").strip().rstrip("/")
|
|
if not value:
|
|
return ""
|
|
parsed = urlsplit(value)
|
|
if not parsed.scheme or not parsed.netloc:
|
|
return ""
|
|
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", ""))
|
|
|
|
|
|
def upload_url_from_public_base(
|
|
public_base_url: str,
|
|
*,
|
|
fallback_host: str,
|
|
port: int = DEFAULT_PORT,
|
|
public_upload_url: str | None = None,
|
|
) -> str:
|
|
explicit_upload_url = normalize_public_upload_url(public_upload_url)
|
|
if explicit_upload_url:
|
|
return explicit_upload_url
|
|
parsed = urlsplit(public_base_url)
|
|
if parsed.scheme and parsed.netloc:
|
|
return urlunsplit((parsed.scheme, parsed.netloc, DEFAULT_INGEST_UPLOAD_PATH, "", ""))
|
|
return f"http://{_url_host(fallback_host)}:{port}{DEFAULT_INGEST_UPLOAD_PATH}"
|
|
|
|
|
|
def normalize_public_upload_url(public_upload_url: str | None) -> str:
|
|
value = str(public_upload_url or "").strip().rstrip("/")
|
|
if not value:
|
|
return ""
|
|
parsed = urlsplit(value)
|
|
if not parsed.scheme or not parsed.netloc:
|
|
return ""
|
|
path = parsed.path.rstrip("/") or DEFAULT_INGEST_UPLOAD_PATH
|
|
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
|
|
|
|
|
def derive_allowed_hosts(
|
|
hosts: Sequence[str],
|
|
*,
|
|
public_base_url: str | None = None,
|
|
public_upload_url: str | None = None,
|
|
) -> tuple[str, ...]:
|
|
patterns = {"localhost", "localhost:*", "127.0.0.1", "127.0.0.1:*", "[::1]", "[::1]:*"}
|
|
candidates = [str(host).strip() for host in hosts]
|
|
for url in (public_base_url, public_upload_url):
|
|
parsed = urlsplit(str(url or ""))
|
|
if parsed.hostname:
|
|
candidates.append(parsed.hostname)
|
|
for candidate in candidates:
|
|
value = candidate.strip().strip("[]")
|
|
if not value or value in {"0.0.0.0", "::"}:
|
|
continue
|
|
if ":" in value:
|
|
patterns.update({f"[{value}]", f"[{value}]:*"})
|
|
else:
|
|
patterns.update({value, f"{value}:*"})
|
|
return tuple(sorted(patterns))
|
|
|
|
|
|
def derive_allowed_origins(*urls: str | None) -> tuple[str, ...]:
|
|
origins = set()
|
|
for url in urls:
|
|
parsed = urlsplit(str(url or ""))
|
|
if parsed.scheme and parsed.netloc:
|
|
origins.add(urlunsplit((parsed.scheme, parsed.netloc, "", "", "")))
|
|
return tuple(sorted(origins))
|
|
|
|
|
|
def merge_allowlist(existing: Sequence[str], required: Sequence[str]) -> tuple[str, ...]:
|
|
return tuple(sorted(set(existing) | set(required)))
|
|
|
|
|
|
def render_remote_config(
|
|
*,
|
|
hosts: Sequence[str],
|
|
token: str,
|
|
port: int = DEFAULT_PORT,
|
|
path: str = DEFAULT_PATH,
|
|
public_base_url: str | None = None,
|
|
public_upload_url: str | None = None,
|
|
config_path: Path | None = None,
|
|
enabled_tools: Sequence[str] | None = None,
|
|
) -> str:
|
|
host = hosts[0] if hosts else "<server-ip>"
|
|
detected_urls = [f"http://{_url_host(candidate)}:{port}{path}" for candidate in hosts]
|
|
fallback_url = detected_urls[0] if detected_urls else f"http://{_url_host(host)}:{port}{path}"
|
|
public_url = normalize_public_base_url(public_base_url)
|
|
url = public_url or fallback_url
|
|
upload_url = upload_url_from_public_base(
|
|
url,
|
|
fallback_host=host,
|
|
port=port,
|
|
public_upload_url=public_upload_url,
|
|
)
|
|
alternatives = "\n".join(f"- {candidate}" for candidate in detected_urls if candidate != url)
|
|
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):
|
|
detected_upload_urls = [
|
|
f"http://{_url_host(candidate)}:{port}{DEFAULT_INGEST_UPLOAD_PATH}" for candidate in hosts[1:]
|
|
]
|
|
upload_alternatives = "\n".join(
|
|
f"- {candidate}" for candidate in detected_upload_urls if candidate != upload_url
|
|
)
|
|
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}"
|
|
"Remote agents should upload with create_ingest_upload_ticket and X-Obsidian-Wiki-Upload-Token.\n"
|
|
"Bearer upload is for local/admin use. 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 ask_wiki for ordinary wiki, business facts, or hybrid questions.\n"
|
|
"For long-running jobs, 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)
|
|
config = saved_service_config(project_root)
|
|
return render_remote_config(
|
|
hosts=detect_remote_hosts(),
|
|
token=config.token,
|
|
port=config.port,
|
|
path=config.path,
|
|
public_base_url=config.public_base_url,
|
|
public_upload_url=config.public_upload_url,
|
|
config_path=paths.env_path,
|
|
enabled_tools=config.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 write_service_config(env_path: Path, config: ServiceConfig) -> None:
|
|
write_token_env_file(
|
|
env_path,
|
|
config.token,
|
|
host=config.host,
|
|
port=config.port,
|
|
path=config.path,
|
|
public_base_url=config.public_base_url,
|
|
public_upload_url=config.public_upload_url,
|
|
enabled_tools=config.enabled_tools,
|
|
allow_source_files=config.allow_source_files,
|
|
archive_sources=config.archive_sources,
|
|
allowed_hosts=config.allowed_hosts,
|
|
allowed_origins=config.allowed_origins,
|
|
ingest_job_timeout_seconds=config.ingest_job_timeout_seconds,
|
|
query_job_timeout_seconds=config.query_job_timeout_seconds,
|
|
ingest_max_request_bytes=config.ingest_max_request_bytes,
|
|
opencode_path=config.opencode_path,
|
|
model_settings=config.model_settings,
|
|
)
|
|
|
|
|
|
def new_token(
|
|
*,
|
|
project_root: Path,
|
|
host: str | None = None,
|
|
port: int | None = None,
|
|
path: str | None = None,
|
|
public_base_url: str | None = None,
|
|
public_upload_url: str | None = None,
|
|
token_bytes: int = DEFAULT_TOKEN_BYTES,
|
|
) -> str:
|
|
paths = resolve_service_paths(project_root)
|
|
try:
|
|
saved = saved_service_config(project_root)
|
|
has_saved_config = True
|
|
except RuntimeError:
|
|
saved = ServiceConfig(token="")
|
|
has_saved_config = False
|
|
hosts = detect_remote_hosts()
|
|
effective_host = saved.host if host is None else host
|
|
effective_port = saved.port if port is None else port
|
|
effective_path = saved.path if path is None else path
|
|
address_overridden = any(value is not None for value in (host, port, path))
|
|
if public_base_url is not None:
|
|
effective_public_base_url = normalize_public_base_url(public_base_url)
|
|
elif has_saved_config and not address_overridden:
|
|
effective_public_base_url = saved.public_base_url
|
|
else:
|
|
effective_public_base_url = public_base_url_from_hosts(
|
|
public_hosts_for_bind(effective_host, hosts),
|
|
port=effective_port,
|
|
path=effective_path,
|
|
)
|
|
effective_public_upload_url = (
|
|
saved.public_upload_url
|
|
if public_upload_url is None
|
|
else normalize_public_upload_url(public_upload_url)
|
|
)
|
|
endpoint_overridden = any(
|
|
value is not None
|
|
for value in (host, port, path, public_base_url, public_upload_url)
|
|
)
|
|
if has_saved_config and not endpoint_overridden:
|
|
allowed_hosts = saved.allowed_hosts
|
|
allowed_origins = saved.allowed_origins
|
|
else:
|
|
allowed_hosts = merge_allowlist(
|
|
saved.allowed_hosts if has_saved_config else (),
|
|
derive_allowed_hosts(
|
|
[effective_host, *hosts],
|
|
public_base_url=effective_public_base_url,
|
|
public_upload_url=effective_public_upload_url,
|
|
),
|
|
)
|
|
allowed_origins = merge_allowlist(
|
|
saved.allowed_origins if has_saved_config else (),
|
|
derive_allowed_origins(effective_public_base_url, effective_public_upload_url),
|
|
)
|
|
archive_sources = project_archive_sources(
|
|
paths.project_root,
|
|
default=saved.archive_sources,
|
|
)
|
|
config = replace(
|
|
saved,
|
|
token=generate_token(token_bytes),
|
|
host=effective_host,
|
|
port=effective_port,
|
|
path=effective_path,
|
|
public_base_url=effective_public_base_url,
|
|
public_upload_url=effective_public_upload_url,
|
|
archive_sources=archive_sources,
|
|
allowed_hosts=allowed_hosts,
|
|
allowed_origins=allowed_origins,
|
|
)
|
|
write_service_config(paths.env_path, config)
|
|
restart_service_if_installed(paths)
|
|
return render_remote_config(
|
|
hosts=hosts,
|
|
token=config.token,
|
|
port=config.port,
|
|
path=config.path,
|
|
public_base_url=config.public_base_url,
|
|
public_upload_url=config.public_upload_url,
|
|
config_path=paths.env_path,
|
|
enabled_tools=config.enabled_tools,
|
|
)
|
|
|
|
|
|
def start_service(
|
|
*,
|
|
project_root: Path,
|
|
vault_path: Path | str | None = None,
|
|
host: str | None = None,
|
|
port: int | None = None,
|
|
path: str | None = None,
|
|
public_base_url: str | None = None,
|
|
public_upload_url: str | None = None,
|
|
token_bytes: int = DEFAULT_TOKEN_BYTES,
|
|
enable_ingest: bool | None = None,
|
|
enable_query: bool | None = None,
|
|
allow_source_files: bool | None = None,
|
|
) -> str:
|
|
paths = resolve_service_paths(project_root)
|
|
try:
|
|
saved = saved_service_config(project_root)
|
|
has_saved_config = True
|
|
except RuntimeError:
|
|
if paths.env_path.exists():
|
|
raise
|
|
saved = ServiceConfig(token=generate_token(token_bytes))
|
|
has_saved_config = False
|
|
effective_host = saved.host if host is None else host
|
|
effective_port = saved.port if port is None else port
|
|
effective_path = saved.path if path is None else path
|
|
address_overridden = any(value is not None for value in (host, port, path))
|
|
enabled_tools = update_ingest_tool(saved.enabled_tools, enable_ingest)
|
|
enabled_tools = update_query_tool(enabled_tools, enable_query)
|
|
opencode_path = saved.opencode_path
|
|
if ({TOOL_INGEST, TOOL_QUERY} & set(enabled_tools)) and not opencode_path:
|
|
opencode_path = resolve_opencode_path()
|
|
hosts = detect_remote_hosts()
|
|
if public_base_url is not None:
|
|
effective_public_base_url = normalize_public_base_url(public_base_url)
|
|
elif has_saved_config and not address_overridden:
|
|
effective_public_base_url = saved.public_base_url
|
|
else:
|
|
effective_public_base_url = public_base_url_from_hosts(
|
|
public_hosts_for_bind(effective_host, hosts),
|
|
port=effective_port,
|
|
path=effective_path,
|
|
)
|
|
effective_public_upload_url = (
|
|
saved.public_upload_url
|
|
if public_upload_url is None
|
|
else normalize_public_upload_url(public_upload_url)
|
|
)
|
|
endpoint_overridden = any(
|
|
value is not None
|
|
for value in (host, port, path, public_base_url, public_upload_url)
|
|
)
|
|
if has_saved_config and not endpoint_overridden:
|
|
allowed_hosts = saved.allowed_hosts
|
|
allowed_origins = saved.allowed_origins
|
|
else:
|
|
allowed_hosts = merge_allowlist(
|
|
saved.allowed_hosts if has_saved_config else (),
|
|
derive_allowed_hosts(
|
|
[effective_host, *hosts],
|
|
public_base_url=effective_public_base_url,
|
|
public_upload_url=effective_public_upload_url,
|
|
),
|
|
)
|
|
allowed_origins = merge_allowlist(
|
|
saved.allowed_origins if has_saved_config else (),
|
|
derive_allowed_origins(effective_public_base_url, effective_public_upload_url),
|
|
)
|
|
archive_sources = project_archive_sources(
|
|
paths.project_root,
|
|
default=saved.archive_sources,
|
|
)
|
|
config = replace(
|
|
saved,
|
|
host=effective_host,
|
|
port=effective_port,
|
|
path=effective_path,
|
|
public_base_url=effective_public_base_url,
|
|
public_upload_url=effective_public_upload_url,
|
|
enabled_tools=enabled_tools,
|
|
opencode_path=opencode_path,
|
|
allow_source_files=(
|
|
saved.allow_source_files if allow_source_files is None else allow_source_files
|
|
),
|
|
archive_sources=archive_sources,
|
|
allowed_hosts=allowed_hosts,
|
|
allowed_origins=allowed_origins,
|
|
)
|
|
write_service_config(paths.env_path, config)
|
|
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=config.host,
|
|
port=config.port,
|
|
path=config.path,
|
|
uv_path=uv_path,
|
|
)
|
|
|
|
if {TOOL_INGEST, TOOL_QUERY} & set(config.enabled_tools):
|
|
import_proxy_environment()
|
|
run_systemctl(["daemon-reload"])
|
|
run_systemctl(["enable", SERVICE_NAME])
|
|
run_systemctl(["restart", SERVICE_NAME])
|
|
return render_remote_config(
|
|
hosts=hosts,
|
|
token=config.token,
|
|
port=config.port,
|
|
path=config.path,
|
|
public_base_url=config.public_base_url,
|
|
public_upload_url=config.public_upload_url,
|
|
config_path=paths.env_path,
|
|
enabled_tools=config.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, ServiceConfig]:
|
|
paths = resolve_service_paths(project_root)
|
|
try:
|
|
config = saved_service_config(project_root)
|
|
except RuntimeError:
|
|
hosts = detect_remote_hosts()
|
|
public_base_url = public_base_url_from_hosts(
|
|
hosts,
|
|
port=DEFAULT_PORT,
|
|
path=DEFAULT_PATH,
|
|
)
|
|
config = ServiceConfig(
|
|
token=generate_token(),
|
|
public_base_url=public_base_url,
|
|
allowed_hosts=derive_allowed_hosts(
|
|
[DEFAULT_HOST, *hosts],
|
|
public_base_url=public_base_url,
|
|
),
|
|
allowed_origins=derive_allowed_origins(public_base_url),
|
|
)
|
|
write_service_config(paths.env_path, config)
|
|
return paths, config
|
|
|
|
|
|
def list_tools(project_root: Path) -> str:
|
|
paths, config = ensure_service_config(project_root)
|
|
return render_tool_status(config_path=paths.env_path, enabled_tools=config.enabled_tools)
|
|
|
|
|
|
def set_tool_enabled(project_root: Path, tool: str, enabled: bool) -> str:
|
|
tool_name = normalize_tool_name(tool)
|
|
paths, config = ensure_service_config(project_root)
|
|
current = set(config.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)
|
|
opencode_path = config.opencode_path
|
|
if ({TOOL_INGEST, TOOL_QUERY} & set(updated_tools)) and not opencode_path:
|
|
opencode_path = resolve_opencode_path()
|
|
config = replace(
|
|
config,
|
|
enabled_tools=updated_tools,
|
|
opencode_path=opencode_path,
|
|
)
|
|
write_service_config(paths.env_path, config)
|
|
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 cleanup_runtime(project_root: Path, *, max_age_days: int = DEFAULT_RUNTIME_CLEANUP_DAYS) -> str:
|
|
if max_age_days <= 0:
|
|
raise RuntimeError("max_age_days must be positive")
|
|
root = project_root.expanduser().resolve()
|
|
cutoff = time.time() - (max_age_days * 24 * 60 * 60)
|
|
ingest_root = root / ".runtime" / "mcp-ingest"
|
|
query_root = root / ".runtime" / "mcp-query"
|
|
active_upload_ids = _active_ingest_upload_ids(ingest_root / "jobs")
|
|
result = {
|
|
"expired_upload_tickets_deleted": _delete_expired_upload_tickets(ingest_root / "upload-tickets"),
|
|
"old_ingest_uploads_deleted": _delete_old_ingest_uploads(
|
|
ingest_root / "uploads",
|
|
cutoff=cutoff,
|
|
active_upload_ids=active_upload_ids,
|
|
),
|
|
"old_ingest_jobs_deleted": _delete_old_terminal_jobs(ingest_root / "jobs", cutoff=cutoff),
|
|
"old_query_jobs_deleted": _delete_old_terminal_jobs(query_root / "jobs", cutoff=cutoff),
|
|
}
|
|
return "\n".join(
|
|
[
|
|
"MCP runtime cleanup.",
|
|
"",
|
|
f"Project root: {root}",
|
|
f"Max age: {max_age_days} days",
|
|
f"Expired upload tickets deleted: {result['expired_upload_tickets_deleted']}",
|
|
f"Old ingest uploads deleted: {result['old_ingest_uploads_deleted']}",
|
|
f"Old ingest jobs/logs deleted: {result['old_ingest_jobs_deleted']}",
|
|
f"Old query jobs/logs deleted: {result['old_query_jobs_deleted']}",
|
|
]
|
|
) + "\n"
|
|
|
|
|
|
def _active_ingest_upload_ids(jobs_root: Path) -> set[str]:
|
|
upload_ids: set[str] = set()
|
|
for job in _iter_job_records(jobs_root):
|
|
if job.get("state") in TERMINAL_JOB_STATES:
|
|
continue
|
|
upload_ids.update(str(upload_id) for upload_id in job.get("upload_ids", []))
|
|
return upload_ids
|
|
|
|
|
|
def _delete_expired_upload_tickets(ticket_root: Path) -> int:
|
|
if not ticket_root.exists():
|
|
return 0
|
|
deleted = 0
|
|
now = time.time()
|
|
for ticket_path in ticket_root.glob("*.json"):
|
|
meta = _read_json(ticket_path)
|
|
if meta is None:
|
|
continue
|
|
try:
|
|
expired = now > float(meta.get("expires_epoch", 0))
|
|
except (TypeError, ValueError):
|
|
expired = False
|
|
if not expired:
|
|
continue
|
|
ticket_path.unlink(missing_ok=True)
|
|
deleted += 1
|
|
return deleted
|
|
|
|
|
|
def _delete_old_ingest_uploads(upload_root: Path, *, cutoff: float, active_upload_ids: set[str]) -> int:
|
|
if not upload_root.exists():
|
|
return 0
|
|
deleted = 0
|
|
for meta_path in upload_root.glob("*/*/upload.json"):
|
|
upload_dir = meta_path.parent
|
|
meta = _read_json(meta_path)
|
|
if meta is None:
|
|
continue
|
|
if str(meta.get("upload_id", "")) in active_upload_ids:
|
|
continue
|
|
if _tree_mtime(upload_dir) >= cutoff:
|
|
continue
|
|
shutil.rmtree(upload_dir, ignore_errors=True)
|
|
deleted += 1
|
|
return deleted
|
|
|
|
|
|
def _delete_old_terminal_jobs(jobs_root: Path, *, cutoff: float) -> int:
|
|
if not jobs_root.exists():
|
|
return 0
|
|
deleted = 0
|
|
for job in _iter_job_records(jobs_root):
|
|
if job.get("state") not in TERMINAL_JOB_STATES:
|
|
continue
|
|
job_id = str(job.get("job_id", ""))
|
|
job_dir = jobs_root / job_id
|
|
if not job_dir.exists() or _tree_mtime(job_dir) >= cutoff:
|
|
continue
|
|
shutil.rmtree(job_dir, ignore_errors=True)
|
|
deleted += 1
|
|
return deleted
|
|
|
|
|
|
def _iter_job_records(jobs_root: Path) -> list[dict[str, object]]:
|
|
jobs: list[dict[str, object]] = []
|
|
if not jobs_root.exists():
|
|
return jobs
|
|
for job_path in jobs_root.glob("*/job.json"):
|
|
record = _read_json(job_path)
|
|
if record is not None:
|
|
jobs.append(record)
|
|
return jobs
|
|
|
|
|
|
def _read_json(path: Path) -> dict[str, object] | None:
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
def _tree_mtime(path: Path) -> float:
|
|
if not path.exists():
|
|
return time.time()
|
|
latest = path.stat().st_mtime
|
|
for child in path.rglob("*"):
|
|
try:
|
|
latest = max(latest, child.stat().st_mtime)
|
|
except OSError:
|
|
continue
|
|
return latest
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description="Manage the 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")
|
|
new_token_parser.add_argument("--port", type=int)
|
|
new_token_parser.add_argument("--path")
|
|
new_token_parser.add_argument("--public-base-url")
|
|
new_token_parser.add_argument("--public-upload-url")
|
|
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")
|
|
start_parser.add_argument("--port", type=int)
|
|
start_parser.add_argument("--path")
|
|
start_parser.add_argument("--public-base-url")
|
|
start_parser.add_argument("--public-upload-url")
|
|
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.",
|
|
)
|
|
source_files_group = start_parser.add_mutually_exclusive_group()
|
|
source_files_group.add_argument(
|
|
"--allow-source-files",
|
|
dest="allow_source_files",
|
|
action="store_true",
|
|
default=None,
|
|
help="Allow file-assets tools to return source file content.",
|
|
)
|
|
source_files_group.add_argument(
|
|
"--deny-source-files",
|
|
dest="allow_source_files",
|
|
action="store_false",
|
|
help="Deny source file content while retaining file-assets metadata 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.")
|
|
|
|
cleanup_parser = subparsers.add_parser("cleanup", help="Prune expired tickets and old MCP runtime uploads/jobs/logs.")
|
|
cleanup_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
|
cleanup_parser.add_argument("--max-age-days", default=DEFAULT_RUNTIME_CLEANUP_DAYS, type=int)
|
|
|
|
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,
|
|
public_base_url=args.public_base_url,
|
|
public_upload_url=args.public_upload_url,
|
|
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,
|
|
public_base_url=args.public_base_url,
|
|
public_upload_url=args.public_upload_url,
|
|
token_bytes=args.token_bytes,
|
|
enable_ingest=args.enable_ingest,
|
|
enable_query=args.enable_query,
|
|
allow_source_files=args.allow_source_files,
|
|
),
|
|
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 == "cleanup":
|
|
print(cleanup_runtime(args.project_root, max_age_days=args.max_age_days), end="")
|
|
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())
|