From 3d0203fb9ecfd778c610374ab52b5f7d32679811 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 5 Jul 2026 18:20:06 +0000 Subject: [PATCH] feat(mcp): configure OpenCode models per workflow Add server-side model and variant settings for MCP ingest and query OpenCode jobs. Ingest defaults to openai/gpt-5.5 high, while query defaults to openai/gpt-5.5 minimal, and readiness/status surfaces the active settings. Persist the settings through the managed service env file and preserve local overrides across start, tools, and token rotations. Remote MCP clients still cannot choose arbitrary models per call. Validation: python3 -m unittest discover -v; python3 -m py_compile obsidian_wiki/mcp_ingest.py obsidian_wiki/mcp_query.py obsidian_wiki/mcp_server.py scripts/manage_mcp_service.py tests/test_mcp_ingest.py tests/test_mcp_query.py tests/test_mcp_server_remote.py tests/test_mcp_service.py; git diff --check. --- .env.example | 4 ++ AGENTS.md | 1 + README.md | 4 ++ obsidian_wiki/mcp_ingest.py | 18 +++++++++ obsidian_wiki/mcp_query.py | 18 +++++++++ obsidian_wiki/mcp_server.py | 46 +++++++++++++++++++++- scripts/manage_mcp_service.py | 67 +++++++++++++++++++++++++++------ tests/test_mcp_ingest.py | 8 ++++ tests/test_mcp_query.py | 8 ++++ tests/test_mcp_server_remote.py | 67 +++++++++++++++++++++++++++++++++ tests/test_mcp_service.py | 24 ++++++++++++ 11 files changed, 253 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 510ec74..65e3758 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,8 @@ OBSIDIAN_MCP_INGEST_MAX_CHUNK_BYTES=4194304 OBSIDIAN_MCP_INGEST_MAX_FILES_PER_JOB=10 OBSIDIAN_MCP_OPENCODE_PATH=opencode OBSIDIAN_MCP_INGEST_OPENCODE_PATH=opencode +OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5 +OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high OBSIDIAN_MCP_INGEST_RUNTIME_ROOT=.runtime/mcp-ingest # Optional MCP query tools. Default is disabled. @@ -81,6 +83,8 @@ OBSIDIAN_MCP_INGEST_RUNTIME_ROOT=.runtime/mcp-ingest # pages. Use OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,query or: # bash scripts/mcp_service.sh tools enable query # bash scripts/mcp_service.sh start --enable-query +OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5 +OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal OBSIDIAN_MCP_QUERY_MAX_CHARS=8000 OBSIDIAN_MCP_QUERY_RUNTIME_ROOT=.runtime/mcp-query diff --git a/AGENTS.md b/AGENTS.md index 27a26f6..11485f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,7 @@ See `wiki-query` and `wiki-export` skills for how the filter is applied. - **Prefer precise identifiers in search ranking.** Exact or prefix matches in structured part-number and series fields should rank above incidental filename substrings such as timestamps or archive hashes. - **Keep remote ingest gated.** MCP ingest tools are write-capable and must remain disabled unless the service config explicitly enables the `ingest` tool group via `OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest`, `mcp_service.sh tools enable ingest`, or `mcp_service.sh start --enable-ingest`. Uploaded files must stage outside the vault under the MCP runtime root; `wiki-ingest` is responsible for accepting, archiving, and indexing sources inside the vault. Use the HTTP multipart endpoint `/mcp-ingest/uploads` for remote file transfer; if the remote agent cannot read the main MCP bearer token, it must call `create_ingest_upload_ticket` and send the returned single-use `X-Obsidian-Wiki-Upload-Token` header. Do not expose base64 or chunked upload tools in the MCP tool surface. They may launch local OpenCode only through the fixed `/wiki-ingest` prompt; never expose arbitrary prompt or shell execution through MCP. Remote ingest must keep the OpenCode readiness preflight in front of job launch, and `check_ingest_opencode_readiness(run_smoke_test=true)` should stay available for operators to verify that OpenCode can load the project `wiki-ingest` skill before accepting uploads. - **Keep remote query gated.** MCP query tools must remain disabled unless the service config explicitly enables the `query` tool group via `OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,query`, `mcp_service.sh tools enable query`, or `mcp_service.sh start --enable-query`. They may launch local OpenCode only through the fixed `/wiki-query` prompt, with no arbitrary prompt or shell execution. `public_only` defaults to false, meaning remote answers may use the full local vault visibility; enable the tool group only for trusted clients and set `public_only=true` per call for filtered user-facing answers. +- **Keep OpenCode model choice server-side.** MCP clients must not pass arbitrary model names through ingest/query tools. Configure local defaults in `.runtime/obsidian-wiki-mcp.env`: ingest uses `OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5` with `OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high`; query uses `OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5` with `OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal`. ## Core Principles diff --git a/README.md b/README.md index 4346f94..8a7e121 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,8 @@ This adds write-capable MCP tools to the same `wiki-file-assets` server and bear - `start_ingest_job` — queues a local `opencode run --auto --format json` job with a fixed `/wiki-ingest` prompt for the completed uploads. - `get_ingest_job_status`, `get_ingest_job_result`, `cancel_ingest_job` — inspect or cancel the local job. +The managed service config sets the OpenCode model server-side. By default ingest uses `OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5` and `OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high` for stronger file processing. Change those in `.runtime/obsidian-wiki-mcp.env` on the server if needed; remote MCP clients do not choose the model per call. + For binary files, use the HTTP upload endpoint. If the remote agent cannot read the main MCP bearer token, first call `create_ingest_upload_ticket`, then upload with the returned token: ```bash @@ -439,6 +441,8 @@ This adds read-oriented OpenCode-backed query tools to the same `wiki-file-asset - `start_wiki_query` — queues a local `opencode run --auto --format default` job with a fixed `/wiki-query` prompt. Parameters: `query`, `mode` (`normal` or `index_only`), `public_only` (default `false`), and optional `note`. - `get_wiki_query_status`, `get_wiki_query_result`, `cancel_wiki_query` — inspect, retrieve, or cancel the local query job. +The managed service config sets query to `OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5` and `OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal` by default, prioritizing response speed over maximum reasoning depth. Tune the server env file if a different balance is needed. + `public_only` defaults to `false`, so the answer may use the same full vault visibility that a local operator sees. Set `public_only=true` when the remote answer must exclude `visibility/internal` and `visibility/pii` pages. Job state and logs live under `.runtime/mcp-query/jobs/`, outside the vault. A normal `wiki-query` log entry is allowed, but the generated prompt forbids ingest/update/rebuild workflows and arbitrary shell execution. Rotate the token later with: diff --git a/obsidian_wiki/mcp_ingest.py b/obsidian_wiki/mcp_ingest.py index 22bb739..54ac6db 100644 --- a/obsidian_wiki/mcp_ingest.py +++ b/obsidian_wiki/mcp_ingest.py @@ -25,6 +25,8 @@ DEFAULT_INGEST_MAX_FILES_PER_JOB = 10 DEFAULT_UPLOAD_TICKET_SECONDS = 30 * 60 MAX_UPLOAD_TICKET_SECONDS = 24 * 60 * 60 DEFAULT_OPENCODE_PATH = "opencode" +DEFAULT_INGEST_OPENCODE_MODEL = "openai/gpt-5.5" +DEFAULT_INGEST_OPENCODE_VARIANT = "high" DEFAULT_OPENCODE_SMOKE_TIMEOUT_SECONDS = 120 SHA256_RE = re.compile(r"^(?:sha256:)?([0-9a-fA-F]{64})$") @@ -63,6 +65,8 @@ class IngestManager: project_root: str | Path, runtime_root: str | Path, opencode_path: str | Path = DEFAULT_OPENCODE_PATH, + opencode_model: str | None = DEFAULT_INGEST_OPENCODE_MODEL, + opencode_variant: str | None = DEFAULT_INGEST_OPENCODE_VARIANT, max_file_bytes: int = DEFAULT_INGEST_MAX_FILE_BYTES, max_chunk_bytes: int = DEFAULT_INGEST_MAX_CHUNK_BYTES, max_files_per_job: int = DEFAULT_INGEST_MAX_FILES_PER_JOB, @@ -76,6 +80,8 @@ class IngestManager: f"got {self.runtime_root}" ) self.opencode_path = str(opencode_path) + self.opencode_model = clean_config_value(str(opencode_model or "")).strip() + self.opencode_variant = clean_config_value(str(opencode_variant or "")).strip() self.max_file_bytes = max_file_bytes self.max_chunk_bytes = max_chunk_bytes self.max_files_per_job = max_files_per_job @@ -285,6 +291,8 @@ class IngestManager: "static_preflight": True, "runtime_smoke_test": "skipped", "opencode_version": "", + "opencode_model": self.opencode_model, + "opencode_variant": self.opencode_variant, "checked_paths": [], "warnings": [], "errors": [], @@ -385,6 +393,7 @@ class IngestManager: "--format", "json", "--auto", + *self._opencode_model_args(), "--dir", str(self.project_root), "--title", @@ -662,6 +671,7 @@ class IngestManager: "--format", "json", "--auto", + *self._opencode_model_args(), "--dir", str(self.project_root), "--title", @@ -718,6 +728,14 @@ class IngestManager: text = str(value).replace("\x00", "").strip() return text[:2000] if text else None + def _opencode_model_args(self) -> list[str]: + args = [] + if self.opencode_model: + args.extend(["--model", self.opencode_model]) + if self.opencode_variant: + args.extend(["--variant", self.opencode_variant]) + return args + def _sha256_file(self, path: Path) -> str: hasher = hashlib.sha256() with path.open("rb") as fh: diff --git a/obsidian_wiki/mcp_query.py b/obsidian_wiki/mcp_query.py index fb0ed1e..4bbc891 100644 --- a/obsidian_wiki/mcp_query.py +++ b/obsidian_wiki/mcp_query.py @@ -17,6 +17,8 @@ from obsidian_wiki.file_assets import clean_config_value DEFAULT_QUERY_MAX_CHARS = 8000 DEFAULT_OPENCODE_PATH = "opencode" +DEFAULT_QUERY_OPENCODE_MODEL = "openai/gpt-5.5" +DEFAULT_QUERY_OPENCODE_VARIANT = "minimal" DEFAULT_OPENCODE_SMOKE_TIMEOUT_SECONDS = 120 QUERY_MODES = {"normal", "index_only"} @@ -49,6 +51,8 @@ class QueryManager: project_root: str | Path, runtime_root: str | Path, opencode_path: str | Path = DEFAULT_OPENCODE_PATH, + opencode_model: str | None = DEFAULT_QUERY_OPENCODE_MODEL, + opencode_variant: str | None = DEFAULT_QUERY_OPENCODE_VARIANT, max_query_chars: int = DEFAULT_QUERY_MAX_CHARS, ) -> None: self.vault = Path(clean_config_value(str(vault_path))).expanduser().resolve() @@ -60,6 +64,8 @@ class QueryManager: f"got {self.runtime_root}" ) self.opencode_path = str(opencode_path) + self.opencode_model = clean_config_value(str(opencode_model or "")).strip() + self.opencode_variant = clean_config_value(str(opencode_variant or "")).strip() self.max_query_chars = max_query_chars self.jobs_root = self.runtime_root / "jobs" self._lock = RLock() @@ -108,6 +114,8 @@ class QueryManager: "static_preflight": True, "runtime_smoke_test": "skipped", "opencode_version": "", + "opencode_model": self.opencode_model, + "opencode_variant": self.opencode_variant, "checked_paths": [], "warnings": [], "errors": [], @@ -205,6 +213,7 @@ class QueryManager: "--format", "default", "--auto", + *self._opencode_model_args(), "--dir", str(self.project_root), "--title", @@ -418,6 +427,7 @@ class QueryManager: "--format", "default", "--auto", + *self._opencode_model_args(), "--dir", str(self.project_root), "--title", @@ -474,6 +484,14 @@ class QueryManager: text = str(value).replace("\x00", "").strip() return text[:2000] if text else None + def _opencode_model_args(self) -> list[str]: + args = [] + if self.opencode_model: + args.extend(["--model", self.opencode_model]) + if self.opencode_variant: + args.extend(["--variant", self.opencode_variant]) + return args + def _read_json(self, path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) diff --git a/obsidian_wiki/mcp_server.py b/obsidian_wiki/mcp_server.py index b3add51..8a60244 100644 --- a/obsidian_wiki/mcp_server.py +++ b/obsidian_wiki/mcp_server.py @@ -18,6 +18,8 @@ from obsidian_wiki.mcp_ingest import ( DEFAULT_INGEST_MAX_CHUNK_BYTES, DEFAULT_INGEST_MAX_FILE_BYTES, DEFAULT_INGEST_MAX_FILES_PER_JOB, + DEFAULT_INGEST_OPENCODE_MODEL, + DEFAULT_INGEST_OPENCODE_VARIANT, DEFAULT_UPLOAD_TICKET_SECONDS, DEFAULT_OPENCODE_PATH, MAX_UPLOAD_TICKET_SECONDS, @@ -25,7 +27,13 @@ from obsidian_wiki.mcp_ingest import ( IngestManager, parse_bool, ) -from obsidian_wiki.mcp_query import DEFAULT_QUERY_MAX_CHARS, QueryError, QueryManager +from obsidian_wiki.mcp_query import ( + DEFAULT_QUERY_MAX_CHARS, + DEFAULT_QUERY_OPENCODE_MODEL, + DEFAULT_QUERY_OPENCODE_VARIANT, + QueryError, + QueryManager, +) from obsidian_wiki.mcp_assets import ( DEFAULT_MAX_FILE_BYTES, DEFAULT_PROFILE_NAME, @@ -153,6 +161,10 @@ def create_server( ingest_runtime_root: str | Path | None = None, query_runtime_root: str | Path | None = None, opencode_path: str | Path = DEFAULT_OPENCODE_PATH, + ingest_opencode_model: str | None = DEFAULT_INGEST_OPENCODE_MODEL, + ingest_opencode_variant: str | None = DEFAULT_INGEST_OPENCODE_VARIANT, + query_opencode_model: str | None = DEFAULT_QUERY_OPENCODE_MODEL, + query_opencode_variant: str | None = DEFAULT_QUERY_OPENCODE_VARIANT, ingest_max_file_bytes: int = DEFAULT_INGEST_MAX_FILE_BYTES, ingest_max_chunk_bytes: int = DEFAULT_INGEST_MAX_CHUNK_BYTES, ingest_max_files_per_job: int = DEFAULT_INGEST_MAX_FILES_PER_JOB, @@ -219,6 +231,10 @@ def create_server( ), "ingest_enabled": enable_ingest, "query_enabled": enable_query, + "ingest_opencode_model": ingest_opencode_model or "", + "ingest_opencode_variant": ingest_opencode_variant or "", + "query_opencode_model": query_opencode_model or "", + "query_opencode_variant": query_opencode_variant or "", } if enable_ingest: @@ -228,6 +244,8 @@ def create_server( project_root=project, runtime_root=ingest_runtime, opencode_path=opencode_path, + opencode_model=ingest_opencode_model, + opencode_variant=ingest_opencode_variant, max_file_bytes=ingest_max_file_bytes, max_chunk_bytes=ingest_max_chunk_bytes, max_files_per_job=ingest_max_files_per_job, @@ -338,6 +356,8 @@ def create_server( project_root=project, runtime_root=query_runtime, opencode_path=opencode_path, + opencode_model=query_opencode_model, + opencode_variant=query_opencode_variant, max_query_chars=query_max_chars, ) @@ -704,6 +724,10 @@ def run_http_server( ingest_runtime_root: str | Path | None = None, query_runtime_root: str | Path | None = None, opencode_path: str | Path = DEFAULT_OPENCODE_PATH, + ingest_opencode_model: str | None = DEFAULT_INGEST_OPENCODE_MODEL, + ingest_opencode_variant: str | None = DEFAULT_INGEST_OPENCODE_VARIANT, + query_opencode_model: str | None = DEFAULT_QUERY_OPENCODE_MODEL, + query_opencode_variant: str | None = DEFAULT_QUERY_OPENCODE_VARIANT, ingest_max_file_bytes: int = DEFAULT_INGEST_MAX_FILE_BYTES, ingest_max_chunk_bytes: int = DEFAULT_INGEST_MAX_CHUNK_BYTES, ingest_max_files_per_job: int = DEFAULT_INGEST_MAX_FILES_PER_JOB, @@ -728,6 +752,8 @@ def run_http_server( project_root=project, runtime_root=ingest_runtime, opencode_path=opencode_path, + opencode_model=ingest_opencode_model, + opencode_variant=ingest_opencode_variant, max_file_bytes=ingest_max_file_bytes, max_chunk_bytes=ingest_max_chunk_bytes, max_files_per_job=ingest_max_files_per_job, @@ -746,6 +772,8 @@ def run_http_server( project_root=project, runtime_root=query_runtime, opencode_path=opencode_path, + opencode_model=query_opencode_model, + opencode_variant=query_opencode_variant, max_query_chars=query_max_chars, ) @@ -767,6 +795,10 @@ def run_http_server( ingest_runtime_root=ingest_runtime_root, query_runtime_root=query_runtime_root, opencode_path=opencode_path, + ingest_opencode_model=ingest_opencode_model, + ingest_opencode_variant=ingest_opencode_variant, + query_opencode_model=query_opencode_model, + query_opencode_variant=query_opencode_variant, ingest_max_file_bytes=ingest_max_file_bytes, ingest_max_chunk_bytes=ingest_max_chunk_bytes, ingest_max_files_per_job=ingest_max_files_per_job, @@ -844,6 +876,10 @@ def main(argv: list[str] | None = None) -> int: "OBSIDIAN_MCP_QUERY_RUNTIME_ROOT", str(Path.cwd() / ".runtime" / "mcp-query"), ) + ingest_opencode_model = runtime_config_value("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL", DEFAULT_INGEST_OPENCODE_MODEL) + ingest_opencode_variant = runtime_config_value("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT", DEFAULT_INGEST_OPENCODE_VARIANT) + query_opencode_model = runtime_config_value("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL", DEFAULT_QUERY_OPENCODE_MODEL) + query_opencode_variant = runtime_config_value("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT", DEFAULT_QUERY_OPENCODE_VARIANT) query_max_chars = runtime_config_int("OBSIDIAN_MCP_QUERY_MAX_CHARS", DEFAULT_QUERY_MAX_CHARS) try: if transport == "stdio": @@ -860,6 +896,10 @@ def main(argv: list[str] | None = None) -> int: ingest_runtime_root=ingest_runtime_root, query_runtime_root=query_runtime_root, opencode_path=opencode_path, + ingest_opencode_model=ingest_opencode_model, + ingest_opencode_variant=ingest_opencode_variant, + query_opencode_model=query_opencode_model, + query_opencode_variant=query_opencode_variant, ingest_max_file_bytes=ingest_max_file_bytes, ingest_max_chunk_bytes=ingest_max_chunk_bytes, ingest_max_files_per_job=ingest_max_files_per_job, @@ -891,6 +931,10 @@ def main(argv: list[str] | None = None) -> int: ingest_runtime_root=ingest_runtime_root, query_runtime_root=query_runtime_root, opencode_path=opencode_path, + ingest_opencode_model=ingest_opencode_model, + ingest_opencode_variant=ingest_opencode_variant, + query_opencode_model=query_opencode_model, + query_opencode_variant=query_opencode_variant, ingest_max_file_bytes=ingest_max_file_bytes, ingest_max_chunk_bytes=ingest_max_chunk_bytes, ingest_max_files_per_job=ingest_max_files_per_job, diff --git a/scripts/manage_mcp_service.py b/scripts/manage_mcp_service.py index 700b527..2ff0695 100755 --- a/scripts/manage_mcp_service.py +++ b/scripts/manage_mcp_service.py @@ -32,6 +32,10 @@ DEFAULT_INGEST_UPLOAD_PATH = "/mcp-ingest/uploads" 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" TOOL_FILE_ASSETS = "file-assets" TOOL_INGEST = "ingest" TOOL_QUERY = "query" @@ -115,6 +119,14 @@ class ServicePaths: 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 + + def generate_token(num_bytes: int = DEFAULT_TOKEN_BYTES) -> str: return secrets.token_urlsafe(num_bytes) @@ -248,10 +260,12 @@ def write_token_env_file( enable_ingest: bool | None = None, enable_query: bool | None = None, 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) + models = model_settings or OpenCodeModelSettings() 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) @@ -262,6 +276,10 @@ def write_token_env_file( env_file.write(f"OBSIDIAN_MCP_PATH={path}\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: @@ -284,7 +302,20 @@ def read_service_env_file(env_path: Path) -> dict[str, str]: return values -def saved_service_config(project_root: Path) -> tuple[str, str, int, str, tuple[str, ...], str]: +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) -> tuple[str, str, int, 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() @@ -298,7 +329,7 @@ def saved_service_config(project_root: Path) -> tuple[str, str, int, str, tuple[ 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 + return token, host, port, path, enabled_tools, opencode_path, model_settings_from_config(values) def write_service_unit( @@ -420,7 +451,7 @@ 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 = saved_service_config(project_root) + token, _host, port, path, enabled_tools, _opencode_path, _models = saved_service_config(project_root) return render_remote_config( hosts=detect_remote_hosts(), token=token, @@ -459,8 +490,9 @@ def new_token( paths = resolve_service_paths(project_root) enabled_tools = DEFAULT_ENABLED_TOOLS opencode_path = "" + model_settings = OpenCodeModelSettings() try: - _old_token, _old_host, _old_port, _old_path, enabled_tools, opencode_path = saved_service_config(project_root) + _old_token, _old_host, _old_port, _old_path, enabled_tools, opencode_path, model_settings = saved_service_config(project_root) except RuntimeError: pass token = generate_token(token_bytes) @@ -472,6 +504,7 @@ def new_token( path=path, enabled_tools=enabled_tools, opencode_path=opencode_path, + model_settings=model_settings, ) restart_service_if_installed(paths) return render_remote_config( @@ -497,13 +530,14 @@ def start_service( ) -> str: paths = resolve_service_paths(project_root) try: - token, host, port, path, enabled_tools, opencode_path = saved_service_config(project_root) + token, host, port, path, enabled_tools, opencode_path, model_settings = saved_service_config(project_root) except RuntimeError: if paths.env_path.exists(): raise token = generate_token(token_bytes) enabled_tools = DEFAULT_ENABLED_TOOLS opencode_path = "" + model_settings = OpenCodeModelSettings() 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: @@ -516,6 +550,7 @@ def start_service( path=path, enabled_tools=enabled_tools, opencode_path=opencode_path, + model_settings=model_settings, ) uv_path = find_uv_path() prepare_runtime(paths.project_root, uv_path=uv_path) @@ -560,10 +595,10 @@ 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]: +def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, int, str, tuple[str, ...], str, OpenCodeModelSettings]: paths = resolve_service_paths(project_root) try: - token, host, port, path, enabled_tools, opencode_path = saved_service_config(project_root) + token, host, port, path, enabled_tools, opencode_path, model_settings = saved_service_config(project_root) except RuntimeError: token = generate_token() host = DEFAULT_HOST @@ -571,18 +606,27 @@ def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, i path = DEFAULT_PATH enabled_tools = DEFAULT_ENABLED_TOOLS opencode_path = "" - write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=enabled_tools) - return paths, token, host, port, path, enabled_tools, opencode_path + model_settings = OpenCodeModelSettings() + write_token_env_file( + paths.env_path, + token, + host=host, + port=port, + path=path, + enabled_tools=enabled_tools, + model_settings=model_settings, + ) + return paths, token, host, port, path, enabled_tools, opencode_path, model_settings def list_tools(project_root: Path) -> str: - paths, _token, _host, _port, _path, enabled_tools, _opencode_path = ensure_service_config(project_root) + paths, _token, _host, _port, _path, 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 = ensure_service_config(project_root) + paths, token, host, port, path, enabled_tools, opencode_path, model_settings = ensure_service_config(project_root) current = set(enabled_tools) if enabled: current.add(tool_name) @@ -599,6 +643,7 @@ def set_tool_enabled(project_root: Path, tool: str, enabled: bool) -> str: path=path, enabled_tools=updated_tools, opencode_path=opencode_path, + model_settings=model_settings, ) if paths.unit_path.exists() and {TOOL_INGEST, TOOL_QUERY} & set(updated_tools): import_proxy_environment() diff --git a/tests/test_mcp_ingest.py b/tests/test_mcp_ingest.py index 7bcfec5..667f103 100644 --- a/tests/test_mcp_ingest.py +++ b/tests/test_mcp_ingest.py @@ -91,6 +91,8 @@ class McpIngestTests(unittest.TestCase): self.assertIn(".agents/skills/llm-wiki/SKILL.md", "\n".join(result["errors"])) def test_check_opencode_readiness_smoke_test_success(self): + self.manager.opencode_model = "openai/gpt-5.5" + self.manager.opencode_variant = "high" with patch("obsidian_wiki.mcp_ingest.subprocess.run", side_effect=self._fake_smoke_success_run) as run_mock: result = self.manager.check_opencode_readiness(run_smoke_test=True) @@ -99,6 +101,8 @@ class McpIngestTests(unittest.TestCase): self.assertEqual(run_mock.call_count, 3) smoke_args = run_mock.call_args_list[-1].args[0] self.assertEqual(smoke_args[:4], ["/usr/bin/opencode", "run", "--format", "json"]) + self.assertEqual(smoke_args[smoke_args.index("--model") + 1], "openai/gpt-5.5") + self.assertEqual(smoke_args[smoke_args.index("--variant") + 1], "high") self.assertIn("--auto", smoke_args) self.assertIn("--dir", smoke_args) self.assertIn("MCP ingest readiness smoke test", smoke_args) @@ -231,6 +235,8 @@ class McpIngestTests(unittest.TestCase): def test_start_ingest_job_constructs_restricted_opencode_command(self): payload = b"source data" upload = self._complete_upload("source.pdf", payload) + self.manager.opencode_model = "openai/gpt-5.5" + self.manager.opencode_variant = "high" popen = FakePopen() with ( @@ -241,6 +247,8 @@ class McpIngestTests(unittest.TestCase): args = popen_mock.call_args.args[0] self.assertEqual(args[:5], ["/usr/bin/opencode", "run", "--format", "json", "--auto"]) + self.assertEqual(args[args.index("--model") + 1], "openai/gpt-5.5") + self.assertEqual(args[args.index("--variant") + 1], "high") self.assertIn("--dir", args) self.assertEqual(args[args.index("--dir") + 1], str(self.project)) self.assertIn("--title", args) diff --git a/tests/test_mcp_query.py b/tests/test_mcp_query.py index 7fa9aa7..ef393a7 100644 --- a/tests/test_mcp_query.py +++ b/tests/test_mcp_query.py @@ -61,6 +61,8 @@ class McpQueryTests(unittest.TestCase): self.assertIn(".agents/skills/llm-wiki/SKILL.md", "\n".join(result["errors"])) def test_check_opencode_readiness_smoke_test_success(self): + self.manager.opencode_model = "openai/gpt-5.5" + self.manager.opencode_variant = "minimal" with patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_smoke_success_run) as run_mock: result = self.manager.check_opencode_readiness(run_smoke_test=True) @@ -69,6 +71,8 @@ class McpQueryTests(unittest.TestCase): self.assertEqual(run_mock.call_count, 3) smoke_args = run_mock.call_args_list[-1].args[0] self.assertEqual(smoke_args[:4], ["/usr/bin/opencode", "run", "--format", "default"]) + self.assertEqual(smoke_args[smoke_args.index("--model") + 1], "openai/gpt-5.5") + self.assertEqual(smoke_args[smoke_args.index("--variant") + 1], "minimal") self.assertIn("--auto", smoke_args) self.assertIn("--dir", smoke_args) self.assertIn("MCP query readiness smoke test", smoke_args) @@ -82,6 +86,8 @@ class McpQueryTests(unittest.TestCase): self.manager.start_query_job("what do I know?", mode="full") def test_start_query_job_constructs_restricted_opencode_command(self): + self.manager.opencode_model = "openai/gpt-5.5" + self.manager.opencode_variant = "minimal" popen = FakePopen() with ( @@ -97,6 +103,8 @@ class McpQueryTests(unittest.TestCase): args = popen_mock.call_args.args[0] self.assertEqual(args[:5], ["/usr/bin/opencode", "run", "--format", "default", "--auto"]) + self.assertEqual(args[args.index("--model") + 1], "openai/gpt-5.5") + self.assertEqual(args[args.index("--variant") + 1], "minimal") self.assertIn("--dir", args) self.assertEqual(args[args.index("--dir") + 1], str(self.project)) self.assertIn("--title", args) diff --git a/tests/test_mcp_server_remote.py b/tests/test_mcp_server_remote.py index 9aac8f6..b50247a 100644 --- a/tests/test_mcp_server_remote.py +++ b/tests/test_mcp_server_remote.py @@ -425,6 +425,73 @@ class RemoteMcpServerTests(unittest.TestCase): self.assertIn("cancel_wiki_query", FakeMCP.tool_names) self.assertIn("check_query_opencode_readiness", FakeMCP.tool_names) + def test_file_index_status_reports_opencode_model_settings(self): + class FakeMCP: + tools = {} + + def __init__(self, _name, **_kwargs): + pass + + def tool(self): + def decorator(func): + self.tools[func.__name__] = func + return func + + return decorator + + def resource(self, *_args, **_kwargs): + def decorator(func): + return func + + return decorator + + def prompt(self, *_args, **_kwargs): + def decorator(func): + return func + + return decorator + + with ( + tempfile.TemporaryDirectory() as tmp, + patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}), + ): + root = Path(tmp) + mcp_server.create_server( + vault_path=root / "vault", + enable_ingest=True, + enable_query=True, + project_root=root / "project", + ingest_runtime_root=root / "ingest-runtime", + query_runtime_root=root / "query-runtime", + ingest_opencode_model="openai/gpt-5.5", + ingest_opencode_variant="high", + query_opencode_model="openai/gpt-5.5", + query_opencode_variant="minimal", + ingest_manager=IngestManager( + vault_path=root / "vault", + project_root=root / "project", + runtime_root=root / "fake-ingest", + ), + query_manager=type( + "FakeQueryManager", + (), + { + "start_query_job": lambda self, query, mode="normal", public_only=False, note=None: {}, + "get_query_job_status": lambda self, job_id: {}, + "get_query_job_result": lambda self, job_id: {}, + "cancel_query_job": lambda self, job_id: {}, + "check_opencode_readiness": lambda self, run_smoke_test=False: {}, + }, + )(), + ) + status = FakeMCP.tools["file_index_status"]() + + self.assertEqual(status["enabled_tools"], ["file-assets", "ingest", "query"]) + self.assertEqual(status["ingest_opencode_model"], "openai/gpt-5.5") + self.assertEqual(status["ingest_opencode_variant"], "high") + self.assertEqual(status["query_opencode_model"], "openai/gpt-5.5") + self.assertEqual(status["query_opencode_variant"], "minimal") + def test_create_ingest_upload_ticket_tool_returns_http_only_token(self): class FakeMCP: tools = {} diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index 4ed77a4..f7fbf04 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -70,6 +70,10 @@ class McpServiceConfigTests(unittest.TestCase): self.assertIn("OBSIDIAN_MCP_PATH=/mcp\n", content) self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", content) self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", content) + self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5\n", content) + self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high\n", content) + self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5\n", content) + self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal\n", content) self.assertEqual(stat.S_IMODE(os.stat(env_path).st_mode), 0o600) self.assertEqual(stat.S_IMODE(os.stat(env_path.parent).st_mode), 0o700) @@ -233,6 +237,10 @@ class McpServiceConfigTests(unittest.TestCase): self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", env_content) self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", env_content) self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_PATH=/home/nac/.opencode/bin/opencode\n", env_content) + self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5\n", env_content) + self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high\n", env_content) + self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5\n", env_content) + self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal\n", env_content) self.assertIn("http://10.0.0.4:9876/wiki-mcp", output) self.assertIn("Authorization: Bearer created-token", output) self.assertIn('"type": "streamable-http"', output) @@ -264,6 +272,14 @@ class McpServiceConfigTests(unittest.TestCase): + "OBSIDIAN_MCP_INGEST_OPENCODE_PATH=/custom/bin/opencode\n", encoding="utf-8", ) + env_path.write_text( + env_path.read_text(encoding="utf-8") + + "OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/custom-strong\n" + + "OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=max\n" + + "OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/custom-fast\n" + + "OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=low\n", + encoding="utf-8", + ) with ( patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}), @@ -279,6 +295,10 @@ class McpServiceConfigTests(unittest.TestCase): self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", env_content) self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", env_content) self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_PATH=/custom/bin/opencode\n", env_content) + self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/custom-strong\n", env_content) + self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=max\n", env_content) + self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/custom-fast\n", env_content) + self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=low\n", env_content) def test_start_service_can_enable_query_without_ingest(self): with tempfile.TemporaryDirectory() as tmp: @@ -304,6 +324,10 @@ class McpServiceConfigTests(unittest.TestCase): self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,query\n", env_content) self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=0\n", env_content) self.assertIn("OBSIDIAN_MCP_OPENCODE_PATH=/home/nac/.opencode/bin/opencode\n", env_content) + self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5\n", env_content) + self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high\n", env_content) + self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5\n", env_content) + self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal\n", env_content) self.assertNotIn("OBSIDIAN_MCP_INGEST_OPENCODE_PATH=", env_content) self.assertIn("Enabled tool groups:\nfile-assets,query", output) prepare_runtime.assert_called_once_with(project_root, uv_path=Path("/home/nac/.local/bin/uv"))