feat(mcp): improve remote ingest guidance

Make upload-ticket auth the default remote-agent path by returning complete upload URLs and updating service output/docs.

Add validate_upload and preview_upload_metadata for lightweight file checks and exact sha256 duplicate warnings before ingest.

Expose version, capability schema, file-index freshness, queue state, and known limitations through get_mcp_capabilities.

Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests scripts; git diff --check.
This commit is contained in:
Codex
2026-07-08 17:12:51 +00:00
parent ee7defe8bf
commit d47bb2c549
8 changed files with 549 additions and 36 deletions
+49 -14
View File
@@ -16,6 +16,7 @@ import time
from dataclasses import dataclass
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:
@@ -31,6 +32,7 @@ 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"
DEFAULT_TOKEN_BYTES = 32
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
DEFAULT_OPENCODE_PATH = "opencode"
@@ -263,6 +265,7 @@ def write_token_env_file(
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
public_base_url: str | None = None,
enabled_tools: Sequence[str] | None = None,
enable_ingest: bool | None = None,
enable_query: bool | None = None,
@@ -281,6 +284,8 @@ 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")
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")
@@ -325,7 +330,7 @@ def model_settings_from_config(values: dict[str, str]) -> OpenCodeModelSettings:
)
def saved_service_config(project_root: Path) -> tuple[str, str, int, str, tuple[str, ...], str, OpenCodeModelSettings]:
def saved_service_config(project_root: Path) -> tuple[str, str, int, str, str, tuple[str, ...], str, OpenCodeModelSettings]:
paths = resolve_service_paths(project_root)
values = read_service_env_file(paths.env_path)
token = values.get("OBSIDIAN_MCP_TOKEN", "").strip()
@@ -334,12 +339,13 @@ def saved_service_config(project_root: Path) -> tuple[str, str, int, str, tuple[
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()
enabled_tools = enabled_tools_from_config(values)
opencode_path = (
values.get("OBSIDIAN_MCP_OPENCODE_PATH", "").strip()
or values.get("OBSIDIAN_MCP_INGEST_OPENCODE_PATH", "").strip()
)
return token, host, port, path, enabled_tools, opencode_path, model_settings_from_config(values)
return token, host, port, path, public_base_url, enabled_tools, opencode_path, model_settings_from_config(values)
def write_service_unit(
@@ -389,17 +395,32 @@ def detect_remote_hosts() -> list[str]:
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://{host}:{port}{path}" if host else ""
def upload_url_from_public_base(public_base_url: str, *, fallback_host: str, port: int = DEFAULT_PORT) -> str:
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}"
def render_remote_config(
*,
hosts: Sequence[str],
token: str,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
public_base_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:])
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 ""
@@ -408,7 +429,6 @@ 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):
upload_url = f"http://{host}:{port}{DEFAULT_INGEST_UPLOAD_PATH}"
upload_alternatives = "\n".join(
f"- http://{candidate}:{port}{DEFAULT_INGEST_UPLOAD_PATH}" for candidate in hosts[1:]
)
@@ -420,7 +440,8 @@ def render_remote_config(
"Ingest file upload endpoint:\n"
f"{upload_url}\n"
f"{upload_alternatives_section}"
"Use the same Authorization header. POST multipart/form-data with one or more `files` fields.\n"
"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):
@@ -462,12 +483,13 @@ def render_remote_config(
def show_config(project_root: Path) -> str:
paths = resolve_service_paths(project_root)
token, _host, port, path, enabled_tools, _opencode_path, _models = saved_service_config(project_root)
token, _host, port, path, public_base_url, enabled_tools, _opencode_path, _models = 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,
config_path=paths.env_path,
enabled_tools=enabled_tools,
)
@@ -502,10 +524,13 @@ def new_token(
enabled_tools = DEFAULT_ENABLED_TOOLS
opencode_path = ""
model_settings = OpenCodeModelSettings()
public_base_url = ""
try:
_old_token, _old_host, _old_port, _old_path, enabled_tools, opencode_path, model_settings = saved_service_config(project_root)
_old_token, _old_host, _old_port, _old_path, public_base_url, enabled_tools, opencode_path, model_settings = saved_service_config(project_root)
except RuntimeError:
pass
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,
@@ -513,16 +538,18 @@ def new_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,
)
restart_service_if_installed(paths)
return render_remote_config(
hosts=detect_remote_hosts(),
hosts=hosts,
token=token,
port=port,
path=path,
public_base_url=public_base_url,
config_path=paths.env_path,
enabled_tools=enabled_tools,
)
@@ -541,7 +568,7 @@ def start_service(
) -> str:
paths = resolve_service_paths(project_root)
try:
token, host, port, path, enabled_tools, opencode_path, model_settings = saved_service_config(project_root)
token, host, port, path, public_base_url, enabled_tools, opencode_path, model_settings = saved_service_config(project_root)
except RuntimeError:
if paths.env_path.exists():
raise
@@ -549,16 +576,20 @@ def start_service(
enabled_tools = DEFAULT_ENABLED_TOOLS
opencode_path = ""
model_settings = OpenCodeModelSettings()
public_base_url = ""
enabled_tools = update_ingest_tool(enabled_tools, enable_ingest)
enabled_tools = update_query_tool(enabled_tools, enable_query)
if ({TOOL_INGEST, TOOL_QUERY} & set(enabled_tools)) and not opencode_path:
opencode_path = resolve_opencode_path()
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,
enabled_tools=enabled_tools,
opencode_path=opencode_path,
model_settings=model_settings,
@@ -574,10 +605,11 @@ def start_service(
run_systemctl(["enable", SERVICE_NAME])
run_systemctl(["restart", SERVICE_NAME])
return render_remote_config(
hosts=detect_remote_hosts(),
hosts=hosts,
token=token,
port=port,
path=path,
public_base_url=public_base_url,
config_path=paths.env_path,
enabled_tools=enabled_tools,
)
@@ -606,15 +638,16 @@ def render_tool_status(
return "\n".join(lines) + "\n"
def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, int, str, tuple[str, ...], str, OpenCodeModelSettings]:
def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, int, str, str, tuple[str, ...], str, OpenCodeModelSettings]:
paths = resolve_service_paths(project_root)
try:
token, host, port, path, enabled_tools, opencode_path, model_settings = saved_service_config(project_root)
token, host, port, path, public_base_url, enabled_tools, opencode_path, model_settings = 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()
@@ -624,20 +657,21 @@ def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, i
host=host,
port=port,
path=path,
public_base_url=public_base_url,
enabled_tools=enabled_tools,
model_settings=model_settings,
)
return paths, token, host, port, path, enabled_tools, opencode_path, model_settings
return paths, token, host, port, path, public_base_url, enabled_tools, opencode_path, model_settings
def list_tools(project_root: Path) -> str:
paths, _token, _host, _port, _path, enabled_tools, _opencode_path, _models = ensure_service_config(project_root)
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)
def set_tool_enabled(project_root: Path, tool: str, enabled: bool) -> str:
tool_name = normalize_tool_name(tool)
paths, token, host, port, path, enabled_tools, opencode_path, model_settings = ensure_service_config(project_root)
paths, token, host, port, path, public_base_url, enabled_tools, opencode_path, model_settings = ensure_service_config(project_root)
current = set(enabled_tools)
if enabled:
current.add(tool_name)
@@ -652,6 +686,7 @@ def set_tool_enabled(project_root: Path, tool: str, enabled: bool) -> str:
host=host,
port=port,
path=path,
public_base_url=public_base_url,
enabled_tools=updated_tools,
opencode_path=opencode_path,
model_settings=model_settings,