feat(mcp): add OpenCode-backed wiki query

Add a query tool group for the wiki-file-assets MCP server that queues fixed /wiki-query OpenCode jobs, exposes status/result/cancel/readiness tools, and keeps query disabled until explicitly enabled.

Document the new service switches and config, keep remote query gated in AGENTS.md, preserve token behavior, and pass proxy environment through the managed service so OpenCode smoke/query jobs can reach the model provider.

Validation: python3 -m unittest discover -v; python3 -m py_compile obsidian_wiki/mcp_query.py obsidian_wiki/mcp_server.py scripts/manage_mcp_service.py tests/test_mcp_query.py tests/test_mcp_server_remote.py tests/test_mcp_service.py; git diff --check.
This commit is contained in:
Codex
2026-07-05 18:00:53 +00:00
parent c8433a2e2e
commit e7913d4c6b
9 changed files with 1085 additions and 12 deletions
+69 -9
View File
@@ -34,8 +34,10 @@ DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
DEFAULT_OPENCODE_PATH = "opencode"
TOOL_FILE_ASSETS = "file-assets"
TOOL_INGEST = "ingest"
AVAILABLE_TOOL_GROUPS = (TOOL_FILE_ASSETS, TOOL_INGEST)
TOOL_QUERY = "query"
AVAILABLE_TOOL_GROUPS = (TOOL_FILE_ASSETS, TOOL_INGEST, TOOL_QUERY)
DEFAULT_ENABLED_TOOLS = (TOOL_FILE_ASSETS,)
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:
@@ -50,6 +52,11 @@ def normalize_tool_name(name: str) -> str:
"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)
@@ -84,11 +91,20 @@ def format_enabled_tools(enabled_tools: Sequence[str]) -> str:
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))
if enable_ingest is True:
current.add(TOOL_INGEST)
elif enable_ingest is False:
current.discard(TOOL_INGEST)
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)
@@ -186,6 +202,7 @@ def render_service_unit(
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"
@@ -229,10 +246,12 @@ def write_token_env_file(
path: str = DEFAULT_PATH,
enabled_tools: Sequence[str] | None = None,
enable_ingest: bool | None = None,
enable_query: bool | None = None,
opencode_path: str | Path | 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)
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)
@@ -244,7 +263,9 @@ def write_token_env_file(
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")
if opencode_path:
env_file.write(f"OBSIDIAN_MCP_INGEST_OPENCODE_PATH={opencode_path}\n")
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)
@@ -273,7 +294,10 @@ def saved_service_config(project_root: Path) -> tuple[str, str, int, str, tuple[
port = int(values.get("OBSIDIAN_MCP_PORT") or DEFAULT_PORT)
path = values.get("OBSIDIAN_MCP_PATH") or DEFAULT_PATH
enabled_tools = enabled_tools_from_config(values)
opencode_path = values.get("OBSIDIAN_MCP_INGEST_OPENCODE_PATH", "").strip()
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
@@ -357,6 +381,14 @@ def render_remote_config(
f"{upload_alternatives_section}"
"Use the same Authorization header. 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 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"
@@ -366,6 +398,7 @@ def render_remote_config(
f"{config_section}"
f"{enabled_section}"
f"{ingest_upload_section}"
f"{query_section}"
"\n"
"Use this header:\n"
f"Authorization: Bearer {token}\n"
@@ -402,6 +435,12 @@ def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.Comp
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
@@ -454,6 +493,7 @@ def start_service(
path: str = DEFAULT_PATH,
token_bytes: int = DEFAULT_TOKEN_BYTES,
enable_ingest: bool | None = None,
enable_query: bool | None = None,
) -> str:
paths = resolve_service_paths(project_root)
try:
@@ -465,7 +505,8 @@ def start_service(
enabled_tools = DEFAULT_ENABLED_TOOLS
opencode_path = ""
enabled_tools = update_ingest_tool(enabled_tools, enable_ingest)
if TOOL_INGEST in enabled_tools and not opencode_path:
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()
write_token_env_file(
paths.env_path,
@@ -481,6 +522,8 @@ def start_service(
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)
if {TOOL_INGEST, TOOL_QUERY} & set(enabled_tools):
import_proxy_environment()
run_systemctl(["daemon-reload"])
run_systemctl(["enable", SERVICE_NAME])
run_systemctl(["restart", SERVICE_NAME])
@@ -546,7 +589,7 @@ def set_tool_enabled(project_root: Path, tool: str, enabled: bool) -> str:
else:
current.discard(tool_name)
updated_tools = tuple(item for item in AVAILABLE_TOOL_GROUPS if item in current)
if TOOL_INGEST in updated_tools and not 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,
@@ -557,6 +600,8 @@ def set_tool_enabled(project_root: Path, tool: str, enabled: bool) -> str:
enabled_tools=updated_tools,
opencode_path=opencode_path,
)
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)
@@ -616,6 +661,20 @@ def main(argv: Sequence[str] | None = None) -> int:
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.",
)
subparsers.add_parser("stop", help="Stop the user service without deleting service files.")
@@ -665,6 +724,7 @@ def main(argv: Sequence[str] | None = None) -> int:
path=args.path,
token_bytes=args.token_bytes,
enable_ingest=args.enable_ingest,
enable_query=args.enable_query,
),
end="",
)