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"))