feat(mcp): harden remote assets and job execution

Gate source-file downloads and verify registry paths and content hashes. Stream multipart uploads with bounded limits, supervise OpenCode jobs with explicit result markers and timeouts, and preserve managed endpoint security settings. Document the trusted-client boundary and add focused regression coverage.

Validation: PYTHONPATH=<staged-tree> python -m unittest discover -s tests (182 tests)
This commit is contained in:
Codex
2026-07-09 21:44:48 +00:00
parent d47bb2c549
commit 7eb9b987ab
16 changed files with 3709 additions and 500 deletions
+436 -118
View File
@@ -13,7 +13,7 @@ import socket
import subprocess
import sys
import time
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Sequence
from urllib.parse import urlsplit, urlunsplit
@@ -33,6 +33,10 @@ 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"
DEFAULT_TOKEN_BYTES = 32
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
DEFAULT_OPENCODE_PATH = "opencode"
@@ -40,6 +44,9 @@ 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"
@@ -79,6 +86,10 @@ def parse_bool(value: str | None) -> bool:
return str(value or "").strip().lower() in {"1", "true", "yes", "on", "enabled"}
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)
@@ -134,6 +145,25 @@ class OpenCodeModelSettings:
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
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)
@@ -203,17 +233,11 @@ def render_service_unit(
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"
"Description=obsidian-wiki HTTP MCP server\n"
"After=network-online.target\n"
"Wants=network-online.target\n"
"\n"
@@ -266,16 +290,40 @@ def write_token_env_file(
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,
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)
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)
@@ -284,8 +332,16 @@ def write_token_env_file(
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 public_base_url:
env_file.write(f"{PUBLIC_BASE_URL_ENV}={public_base_url.rstrip('/')}\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"{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")
@@ -330,7 +386,7 @@ def model_settings_from_config(values: dict[str, str]) -> OpenCodeModelSettings:
)
def saved_service_config(project_root: Path) -> tuple[str, str, int, str, str, tuple[str, ...], str, OpenCodeModelSettings]:
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()
@@ -339,13 +395,53 @@ def saved_service_config(project_root: Path) -> tuple[str, str, int, str, str, t
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 = values.get(PUBLIC_BASE_URL_ENV, "").strip()
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
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 token, host, port, path, public_base_url, enabled_tools, opencode_path, model_settings_from_config(values)
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,
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(
@@ -397,14 +493,94 @@ def detect_remote_hosts() -> list[str]:
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://{host}:{port}{path}" if host else ""
return f"http://{_url_host(host)}:{port}{path}" if host else ""
def upload_url_from_public_base(public_base_url: str, *, fallback_host: str, port: int = DEFAULT_PORT) -> str:
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://{fallback_host}:{port}{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(
@@ -414,14 +590,22 @@ def render_remote_config(
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>"
url = f"http://{host}:{port}{path}"
public_url = (public_base_url or url).rstrip("/")
upload_url = upload_url_from_public_base(public_url, fallback_host=host, port=port)
alternatives = "\n".join(f"- http://{candidate}:{port}{path}" for candidate in hosts[1:])
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 = ""
@@ -429,8 +613,11 @@ def render_remote_config(
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"- http://{candidate}:{port}{DEFAULT_INGEST_UPLOAD_PATH}" for candidate in hosts[1:]
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 ""
@@ -483,15 +670,16 @@ def render_remote_config(
def show_config(project_root: Path) -> str:
paths = resolve_service_paths(project_root)
token, _host, port, path, public_base_url, enabled_tools, _opencode_path, _models = saved_service_config(project_root)
config = saved_service_config(project_root)
return render_remote_config(
hosts=detect_remote_hosts(),
token=token,
port=port,
path=path,
public_base_url=public_base_url,
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=enabled_tools,
enabled_tools=config.enabled_tools,
)
@@ -512,46 +700,106 @@ def restart_service_if_installed(paths: ServicePaths) -> bool:
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,
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 = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_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)
enabled_tools = DEFAULT_ENABLED_TOOLS
opencode_path = ""
model_settings = OpenCodeModelSettings()
public_base_url = ""
try:
_old_token, _old_host, _old_port, _old_path, public_base_url, enabled_tools, opencode_path, model_settings = saved_service_config(project_root)
saved = saved_service_config(project_root)
has_saved_config = True
except RuntimeError:
pass
saved = ServiceConfig(token="")
has_saved_config = False
hosts = detect_remote_hosts()
public_base_url = public_base_url or public_base_url_from_hosts(hosts, port=port, path=path)
token = generate_token(token_bytes)
write_token_env_file(
paths.env_path,
token,
host=host,
port=port,
path=path,
public_base_url=public_base_url,
enabled_tools=enabled_tools,
opencode_path=opencode_path,
model_settings=model_settings,
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),
)
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,
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=token,
port=port,
path=path,
public_base_url=public_base_url,
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=enabled_tools,
enabled_tools=config.enabled_tools,
)
@@ -559,59 +807,112 @@ def start_service(
*,
project_root: Path,
vault_path: Path | str | None = None,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_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,
enable_ingest: bool | None = None,
enable_query: bool | None = None,
allow_source_files: bool | None = None,
) -> str:
paths = resolve_service_paths(project_root)
try:
token, host, port, path, public_base_url, enabled_tools, opencode_path, model_settings = saved_service_config(project_root)
saved = saved_service_config(project_root)
has_saved_config = True
except RuntimeError:
if paths.env_path.exists():
raise
token = generate_token(token_bytes)
enabled_tools = DEFAULT_ENABLED_TOOLS
opencode_path = ""
model_settings = OpenCodeModelSettings()
public_base_url = ""
enabled_tools = update_ingest_tool(enabled_tools, enable_ingest)
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()
public_base_url = public_base_url or public_base_url_from_hosts(hosts, port=port, path=path)
write_token_env_file(
paths.env_path,
token,
host=host,
port=port,
path=path,
public_base_url=public_base_url,
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),
)
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,
model_settings=model_settings,
allow_source_files=(
saved.allow_source_files if allow_source_files is None else allow_source_files
),
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=host, port=port, path=path, uv_path=uv_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(enabled_tools):
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=token,
port=port,
path=path,
public_base_url=public_base_url,
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=enabled_tools,
enabled_tools=config.enabled_tools,
)
@@ -638,59 +939,53 @@ def render_tool_status(
return "\n".join(lines) + "\n"
def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, int, str, str, tuple[str, ...], str, OpenCodeModelSettings]:
def ensure_service_config(project_root: Path) -> tuple[ServicePaths, ServiceConfig]:
paths = resolve_service_paths(project_root)
try:
token, host, port, path, public_base_url, enabled_tools, opencode_path, model_settings = saved_service_config(project_root)
config = saved_service_config(project_root)
except RuntimeError:
token = generate_token()
host = DEFAULT_HOST
port = DEFAULT_PORT
path = DEFAULT_PATH
public_base_url = public_base_url_from_hosts(detect_remote_hosts(), port=port, path=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,
public_base_url=public_base_url,
enabled_tools=enabled_tools,
model_settings=model_settings,
hosts = detect_remote_hosts()
public_base_url = public_base_url_from_hosts(
hosts,
port=DEFAULT_PORT,
path=DEFAULT_PATH,
)
return paths, token, host, port, path, public_base_url, enabled_tools, opencode_path, model_settings
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, _token, _host, _port, _path, _public_base_url, enabled_tools, _opencode_path, _models = ensure_service_config(project_root)
return render_tool_status(config_path=paths.env_path, enabled_tools=enabled_tools)
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, token, host, port, path, public_base_url, enabled_tools, opencode_path, model_settings = ensure_service_config(project_root)
current = set(enabled_tools)
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()
write_token_env_file(
paths.env_path,
token,
host=host,
port=port,
path=path,
public_base_url=public_base_url,
config = replace(
config,
enabled_tools=updated_tools,
opencode_path=opencode_path,
model_settings=model_settings,
)
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)
@@ -847,22 +1142,26 @@ def _tree_mtime(path: Path) -> float:
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Manage the readonly HTTP obsidian-wiki MCP user service.")
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", 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("--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", 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("--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(
@@ -892,6 +1191,20 @@ def main(argv: Sequence[str] | None = None) -> int:
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.")
@@ -930,6 +1243,8 @@ def main(argv: Sequence[str] | None = None) -> int:
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="",
@@ -943,9 +1258,12 @@ def main(argv: Sequence[str] | None = None) -> int:
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="",
)