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:
+436
-118
@@ -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="",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user