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.
This commit is contained in:
Codex
2026-07-05 18:20:06 +00:00
parent e7913d4c6b
commit 3d0203fb9e
11 changed files with 253 additions and 12 deletions
+4
View File
@@ -71,6 +71,8 @@ OBSIDIAN_MCP_INGEST_MAX_CHUNK_BYTES=4194304
OBSIDIAN_MCP_INGEST_MAX_FILES_PER_JOB=10 OBSIDIAN_MCP_INGEST_MAX_FILES_PER_JOB=10
OBSIDIAN_MCP_OPENCODE_PATH=opencode OBSIDIAN_MCP_OPENCODE_PATH=opencode
OBSIDIAN_MCP_INGEST_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 OBSIDIAN_MCP_INGEST_RUNTIME_ROOT=.runtime/mcp-ingest
# Optional MCP query tools. Default is disabled. # 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: # pages. Use OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,query or:
# bash scripts/mcp_service.sh tools enable query # bash scripts/mcp_service.sh tools enable query
# bash scripts/mcp_service.sh start --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_MAX_CHARS=8000
OBSIDIAN_MCP_QUERY_RUNTIME_ROOT=.runtime/mcp-query OBSIDIAN_MCP_QUERY_RUNTIME_ROOT=.runtime/mcp-query
+1
View File
@@ -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. - **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 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 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 ## Core Principles
+4
View File
@@ -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. - `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. - `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: 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 ```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`. - `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. - `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. `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: Rotate the token later with:
+18
View File
@@ -25,6 +25,8 @@ DEFAULT_INGEST_MAX_FILES_PER_JOB = 10
DEFAULT_UPLOAD_TICKET_SECONDS = 30 * 60 DEFAULT_UPLOAD_TICKET_SECONDS = 30 * 60
MAX_UPLOAD_TICKET_SECONDS = 24 * 60 * 60 MAX_UPLOAD_TICKET_SECONDS = 24 * 60 * 60
DEFAULT_OPENCODE_PATH = "opencode" DEFAULT_OPENCODE_PATH = "opencode"
DEFAULT_INGEST_OPENCODE_MODEL = "openai/gpt-5.5"
DEFAULT_INGEST_OPENCODE_VARIANT = "high"
DEFAULT_OPENCODE_SMOKE_TIMEOUT_SECONDS = 120 DEFAULT_OPENCODE_SMOKE_TIMEOUT_SECONDS = 120
SHA256_RE = re.compile(r"^(?:sha256:)?([0-9a-fA-F]{64})$") SHA256_RE = re.compile(r"^(?:sha256:)?([0-9a-fA-F]{64})$")
@@ -63,6 +65,8 @@ class IngestManager:
project_root: str | Path, project_root: str | Path,
runtime_root: str | Path, runtime_root: str | Path,
opencode_path: str | Path = DEFAULT_OPENCODE_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_file_bytes: int = DEFAULT_INGEST_MAX_FILE_BYTES,
max_chunk_bytes: int = DEFAULT_INGEST_MAX_CHUNK_BYTES, max_chunk_bytes: int = DEFAULT_INGEST_MAX_CHUNK_BYTES,
max_files_per_job: int = DEFAULT_INGEST_MAX_FILES_PER_JOB, max_files_per_job: int = DEFAULT_INGEST_MAX_FILES_PER_JOB,
@@ -76,6 +80,8 @@ class IngestManager:
f"got {self.runtime_root}" f"got {self.runtime_root}"
) )
self.opencode_path = str(opencode_path) 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_file_bytes = max_file_bytes
self.max_chunk_bytes = max_chunk_bytes self.max_chunk_bytes = max_chunk_bytes
self.max_files_per_job = max_files_per_job self.max_files_per_job = max_files_per_job
@@ -285,6 +291,8 @@ class IngestManager:
"static_preflight": True, "static_preflight": True,
"runtime_smoke_test": "skipped", "runtime_smoke_test": "skipped",
"opencode_version": "", "opencode_version": "",
"opencode_model": self.opencode_model,
"opencode_variant": self.opencode_variant,
"checked_paths": [], "checked_paths": [],
"warnings": [], "warnings": [],
"errors": [], "errors": [],
@@ -385,6 +393,7 @@ class IngestManager:
"--format", "--format",
"json", "json",
"--auto", "--auto",
*self._opencode_model_args(),
"--dir", "--dir",
str(self.project_root), str(self.project_root),
"--title", "--title",
@@ -662,6 +671,7 @@ class IngestManager:
"--format", "--format",
"json", "json",
"--auto", "--auto",
*self._opencode_model_args(),
"--dir", "--dir",
str(self.project_root), str(self.project_root),
"--title", "--title",
@@ -718,6 +728,14 @@ class IngestManager:
text = str(value).replace("\x00", "").strip() text = str(value).replace("\x00", "").strip()
return text[:2000] if text else None 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: def _sha256_file(self, path: Path) -> str:
hasher = hashlib.sha256() hasher = hashlib.sha256()
with path.open("rb") as fh: with path.open("rb") as fh:
+18
View File
@@ -17,6 +17,8 @@ from obsidian_wiki.file_assets import clean_config_value
DEFAULT_QUERY_MAX_CHARS = 8000 DEFAULT_QUERY_MAX_CHARS = 8000
DEFAULT_OPENCODE_PATH = "opencode" DEFAULT_OPENCODE_PATH = "opencode"
DEFAULT_QUERY_OPENCODE_MODEL = "openai/gpt-5.5"
DEFAULT_QUERY_OPENCODE_VARIANT = "minimal"
DEFAULT_OPENCODE_SMOKE_TIMEOUT_SECONDS = 120 DEFAULT_OPENCODE_SMOKE_TIMEOUT_SECONDS = 120
QUERY_MODES = {"normal", "index_only"} QUERY_MODES = {"normal", "index_only"}
@@ -49,6 +51,8 @@ class QueryManager:
project_root: str | Path, project_root: str | Path,
runtime_root: str | Path, runtime_root: str | Path,
opencode_path: str | Path = DEFAULT_OPENCODE_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, max_query_chars: int = DEFAULT_QUERY_MAX_CHARS,
) -> None: ) -> None:
self.vault = Path(clean_config_value(str(vault_path))).expanduser().resolve() self.vault = Path(clean_config_value(str(vault_path))).expanduser().resolve()
@@ -60,6 +64,8 @@ class QueryManager:
f"got {self.runtime_root}" f"got {self.runtime_root}"
) )
self.opencode_path = str(opencode_path) 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.max_query_chars = max_query_chars
self.jobs_root = self.runtime_root / "jobs" self.jobs_root = self.runtime_root / "jobs"
self._lock = RLock() self._lock = RLock()
@@ -108,6 +114,8 @@ class QueryManager:
"static_preflight": True, "static_preflight": True,
"runtime_smoke_test": "skipped", "runtime_smoke_test": "skipped",
"opencode_version": "", "opencode_version": "",
"opencode_model": self.opencode_model,
"opencode_variant": self.opencode_variant,
"checked_paths": [], "checked_paths": [],
"warnings": [], "warnings": [],
"errors": [], "errors": [],
@@ -205,6 +213,7 @@ class QueryManager:
"--format", "--format",
"default", "default",
"--auto", "--auto",
*self._opencode_model_args(),
"--dir", "--dir",
str(self.project_root), str(self.project_root),
"--title", "--title",
@@ -418,6 +427,7 @@ class QueryManager:
"--format", "--format",
"default", "default",
"--auto", "--auto",
*self._opencode_model_args(),
"--dir", "--dir",
str(self.project_root), str(self.project_root),
"--title", "--title",
@@ -474,6 +484,14 @@ class QueryManager:
text = str(value).replace("\x00", "").strip() text = str(value).replace("\x00", "").strip()
return text[:2000] if text else None 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]: def _read_json(self, path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8")) return json.loads(path.read_text(encoding="utf-8"))
+45 -1
View File
@@ -18,6 +18,8 @@ from obsidian_wiki.mcp_ingest import (
DEFAULT_INGEST_MAX_CHUNK_BYTES, DEFAULT_INGEST_MAX_CHUNK_BYTES,
DEFAULT_INGEST_MAX_FILE_BYTES, DEFAULT_INGEST_MAX_FILE_BYTES,
DEFAULT_INGEST_MAX_FILES_PER_JOB, DEFAULT_INGEST_MAX_FILES_PER_JOB,
DEFAULT_INGEST_OPENCODE_MODEL,
DEFAULT_INGEST_OPENCODE_VARIANT,
DEFAULT_UPLOAD_TICKET_SECONDS, DEFAULT_UPLOAD_TICKET_SECONDS,
DEFAULT_OPENCODE_PATH, DEFAULT_OPENCODE_PATH,
MAX_UPLOAD_TICKET_SECONDS, MAX_UPLOAD_TICKET_SECONDS,
@@ -25,7 +27,13 @@ from obsidian_wiki.mcp_ingest import (
IngestManager, IngestManager,
parse_bool, 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 ( from obsidian_wiki.mcp_assets import (
DEFAULT_MAX_FILE_BYTES, DEFAULT_MAX_FILE_BYTES,
DEFAULT_PROFILE_NAME, DEFAULT_PROFILE_NAME,
@@ -153,6 +161,10 @@ def create_server(
ingest_runtime_root: str | Path | None = None, ingest_runtime_root: str | Path | None = None,
query_runtime_root: str | Path | None = None, query_runtime_root: str | Path | None = None,
opencode_path: str | Path = DEFAULT_OPENCODE_PATH, 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_file_bytes: int = DEFAULT_INGEST_MAX_FILE_BYTES,
ingest_max_chunk_bytes: int = DEFAULT_INGEST_MAX_CHUNK_BYTES, ingest_max_chunk_bytes: int = DEFAULT_INGEST_MAX_CHUNK_BYTES,
ingest_max_files_per_job: int = DEFAULT_INGEST_MAX_FILES_PER_JOB, ingest_max_files_per_job: int = DEFAULT_INGEST_MAX_FILES_PER_JOB,
@@ -219,6 +231,10 @@ def create_server(
), ),
"ingest_enabled": enable_ingest, "ingest_enabled": enable_ingest,
"query_enabled": enable_query, "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: if enable_ingest:
@@ -228,6 +244,8 @@ def create_server(
project_root=project, project_root=project,
runtime_root=ingest_runtime, runtime_root=ingest_runtime,
opencode_path=opencode_path, opencode_path=opencode_path,
opencode_model=ingest_opencode_model,
opencode_variant=ingest_opencode_variant,
max_file_bytes=ingest_max_file_bytes, max_file_bytes=ingest_max_file_bytes,
max_chunk_bytes=ingest_max_chunk_bytes, max_chunk_bytes=ingest_max_chunk_bytes,
max_files_per_job=ingest_max_files_per_job, max_files_per_job=ingest_max_files_per_job,
@@ -338,6 +356,8 @@ def create_server(
project_root=project, project_root=project,
runtime_root=query_runtime, runtime_root=query_runtime,
opencode_path=opencode_path, opencode_path=opencode_path,
opencode_model=query_opencode_model,
opencode_variant=query_opencode_variant,
max_query_chars=query_max_chars, max_query_chars=query_max_chars,
) )
@@ -704,6 +724,10 @@ def run_http_server(
ingest_runtime_root: str | Path | None = None, ingest_runtime_root: str | Path | None = None,
query_runtime_root: str | Path | None = None, query_runtime_root: str | Path | None = None,
opencode_path: str | Path = DEFAULT_OPENCODE_PATH, 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_file_bytes: int = DEFAULT_INGEST_MAX_FILE_BYTES,
ingest_max_chunk_bytes: int = DEFAULT_INGEST_MAX_CHUNK_BYTES, ingest_max_chunk_bytes: int = DEFAULT_INGEST_MAX_CHUNK_BYTES,
ingest_max_files_per_job: int = DEFAULT_INGEST_MAX_FILES_PER_JOB, ingest_max_files_per_job: int = DEFAULT_INGEST_MAX_FILES_PER_JOB,
@@ -728,6 +752,8 @@ def run_http_server(
project_root=project, project_root=project,
runtime_root=ingest_runtime, runtime_root=ingest_runtime,
opencode_path=opencode_path, opencode_path=opencode_path,
opencode_model=ingest_opencode_model,
opencode_variant=ingest_opencode_variant,
max_file_bytes=ingest_max_file_bytes, max_file_bytes=ingest_max_file_bytes,
max_chunk_bytes=ingest_max_chunk_bytes, max_chunk_bytes=ingest_max_chunk_bytes,
max_files_per_job=ingest_max_files_per_job, max_files_per_job=ingest_max_files_per_job,
@@ -746,6 +772,8 @@ def run_http_server(
project_root=project, project_root=project,
runtime_root=query_runtime, runtime_root=query_runtime,
opencode_path=opencode_path, opencode_path=opencode_path,
opencode_model=query_opencode_model,
opencode_variant=query_opencode_variant,
max_query_chars=query_max_chars, max_query_chars=query_max_chars,
) )
@@ -767,6 +795,10 @@ def run_http_server(
ingest_runtime_root=ingest_runtime_root, ingest_runtime_root=ingest_runtime_root,
query_runtime_root=query_runtime_root, query_runtime_root=query_runtime_root,
opencode_path=opencode_path, 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_file_bytes=ingest_max_file_bytes,
ingest_max_chunk_bytes=ingest_max_chunk_bytes, ingest_max_chunk_bytes=ingest_max_chunk_bytes,
ingest_max_files_per_job=ingest_max_files_per_job, 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", "OBSIDIAN_MCP_QUERY_RUNTIME_ROOT",
str(Path.cwd() / ".runtime" / "mcp-query"), 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) query_max_chars = runtime_config_int("OBSIDIAN_MCP_QUERY_MAX_CHARS", DEFAULT_QUERY_MAX_CHARS)
try: try:
if transport == "stdio": if transport == "stdio":
@@ -860,6 +896,10 @@ def main(argv: list[str] | None = None) -> int:
ingest_runtime_root=ingest_runtime_root, ingest_runtime_root=ingest_runtime_root,
query_runtime_root=query_runtime_root, query_runtime_root=query_runtime_root,
opencode_path=opencode_path, 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_file_bytes=ingest_max_file_bytes,
ingest_max_chunk_bytes=ingest_max_chunk_bytes, ingest_max_chunk_bytes=ingest_max_chunk_bytes,
ingest_max_files_per_job=ingest_max_files_per_job, 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, ingest_runtime_root=ingest_runtime_root,
query_runtime_root=query_runtime_root, query_runtime_root=query_runtime_root,
opencode_path=opencode_path, 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_file_bytes=ingest_max_file_bytes,
ingest_max_chunk_bytes=ingest_max_chunk_bytes, ingest_max_chunk_bytes=ingest_max_chunk_bytes,
ingest_max_files_per_job=ingest_max_files_per_job, ingest_max_files_per_job=ingest_max_files_per_job,
+56 -11
View File
@@ -32,6 +32,10 @@ DEFAULT_INGEST_UPLOAD_PATH = "/mcp-ingest/uploads"
DEFAULT_TOKEN_BYTES = 32 DEFAULT_TOKEN_BYTES = 32
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache" DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
DEFAULT_OPENCODE_PATH = "opencode" 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_FILE_ASSETS = "file-assets"
TOOL_INGEST = "ingest" TOOL_INGEST = "ingest"
TOOL_QUERY = "query" TOOL_QUERY = "query"
@@ -115,6 +119,14 @@ class ServicePaths:
env_path: Path 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: def generate_token(num_bytes: int = DEFAULT_TOKEN_BYTES) -> str:
return secrets.token_urlsafe(num_bytes) return secrets.token_urlsafe(num_bytes)
@@ -248,10 +260,12 @@ def write_token_env_file(
enable_ingest: bool | None = None, enable_ingest: bool | None = None,
enable_query: bool | None = None, enable_query: bool | None = None,
opencode_path: str | Path | None = None, opencode_path: str | Path | None = None,
model_settings: OpenCodeModelSettings | None = None,
) -> None: ) -> None:
tools = normalize_enabled_tools(DEFAULT_ENABLED_TOOLS if enabled_tools is None else enabled_tools) tools = normalize_enabled_tools(DEFAULT_ENABLED_TOOLS if enabled_tools is None else enabled_tools)
tools = update_ingest_tool(tools, enable_ingest) tools = update_ingest_tool(tools, enable_ingest)
tools = update_query_tool(tools, enable_query) tools = update_query_tool(tools, enable_query)
models = model_settings or OpenCodeModelSettings()
env_path.parent.mkdir(parents=True, exist_ok=True) env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.parent.chmod(0o700) env_path.parent.chmod(0o700)
fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) 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_PATH={path}\n")
env_file.write(f"OBSIDIAN_MCP_ENABLED_TOOLS={format_enabled_tools(tools)}\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_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: if opencode_path:
env_file.write(f"OBSIDIAN_MCP_OPENCODE_PATH={opencode_path}\n") env_file.write(f"OBSIDIAN_MCP_OPENCODE_PATH={opencode_path}\n")
if TOOL_INGEST in tools: if TOOL_INGEST in tools:
@@ -284,7 +302,20 @@ def read_service_env_file(env_path: Path) -> dict[str, str]:
return values 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) paths = resolve_service_paths(project_root)
values = read_service_env_file(paths.env_path) values = read_service_env_file(paths.env_path)
token = values.get("OBSIDIAN_MCP_TOKEN", "").strip() 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() values.get("OBSIDIAN_MCP_OPENCODE_PATH", "").strip()
or values.get("OBSIDIAN_MCP_INGEST_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( def write_service_unit(
@@ -420,7 +451,7 @@ def render_remote_config(
def show_config(project_root: Path) -> str: def show_config(project_root: Path) -> str:
paths = resolve_service_paths(project_root) 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( return render_remote_config(
hosts=detect_remote_hosts(), hosts=detect_remote_hosts(),
token=token, token=token,
@@ -459,8 +490,9 @@ def new_token(
paths = resolve_service_paths(project_root) paths = resolve_service_paths(project_root)
enabled_tools = DEFAULT_ENABLED_TOOLS enabled_tools = DEFAULT_ENABLED_TOOLS
opencode_path = "" opencode_path = ""
model_settings = OpenCodeModelSettings()
try: 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: except RuntimeError:
pass pass
token = generate_token(token_bytes) token = generate_token(token_bytes)
@@ -472,6 +504,7 @@ def new_token(
path=path, path=path,
enabled_tools=enabled_tools, enabled_tools=enabled_tools,
opencode_path=opencode_path, opencode_path=opencode_path,
model_settings=model_settings,
) )
restart_service_if_installed(paths) restart_service_if_installed(paths)
return render_remote_config( return render_remote_config(
@@ -497,13 +530,14 @@ def start_service(
) -> str: ) -> str:
paths = resolve_service_paths(project_root) paths = resolve_service_paths(project_root)
try: 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: except RuntimeError:
if paths.env_path.exists(): if paths.env_path.exists():
raise raise
token = generate_token(token_bytes) token = generate_token(token_bytes)
enabled_tools = DEFAULT_ENABLED_TOOLS enabled_tools = DEFAULT_ENABLED_TOOLS
opencode_path = "" opencode_path = ""
model_settings = OpenCodeModelSettings()
enabled_tools = update_ingest_tool(enabled_tools, enable_ingest) enabled_tools = update_ingest_tool(enabled_tools, enable_ingest)
enabled_tools = update_query_tool(enabled_tools, enable_query) enabled_tools = update_query_tool(enabled_tools, enable_query)
if ({TOOL_INGEST, TOOL_QUERY} & set(enabled_tools)) and not opencode_path: if ({TOOL_INGEST, TOOL_QUERY} & set(enabled_tools)) and not opencode_path:
@@ -516,6 +550,7 @@ def start_service(
path=path, path=path,
enabled_tools=enabled_tools, enabled_tools=enabled_tools,
opencode_path=opencode_path, opencode_path=opencode_path,
model_settings=model_settings,
) )
uv_path = find_uv_path() uv_path = find_uv_path()
prepare_runtime(paths.project_root, uv_path=uv_path) prepare_runtime(paths.project_root, uv_path=uv_path)
@@ -560,10 +595,10 @@ def render_tool_status(
return "\n".join(lines) + "\n" 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) paths = resolve_service_paths(project_root)
try: 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: except RuntimeError:
token = generate_token() token = generate_token()
host = DEFAULT_HOST host = DEFAULT_HOST
@@ -571,18 +606,27 @@ def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, i
path = DEFAULT_PATH path = DEFAULT_PATH
enabled_tools = DEFAULT_ENABLED_TOOLS enabled_tools = DEFAULT_ENABLED_TOOLS
opencode_path = "" opencode_path = ""
write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=enabled_tools) model_settings = OpenCodeModelSettings()
return paths, token, host, port, path, enabled_tools, opencode_path 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: 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) 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: def set_tool_enabled(project_root: Path, tool: str, enabled: bool) -> str:
tool_name = normalize_tool_name(tool) 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) current = set(enabled_tools)
if enabled: if enabled:
current.add(tool_name) current.add(tool_name)
@@ -599,6 +643,7 @@ def set_tool_enabled(project_root: Path, tool: str, enabled: bool) -> str:
path=path, path=path,
enabled_tools=updated_tools, enabled_tools=updated_tools,
opencode_path=opencode_path, opencode_path=opencode_path,
model_settings=model_settings,
) )
if paths.unit_path.exists() and {TOOL_INGEST, TOOL_QUERY} & set(updated_tools): if paths.unit_path.exists() and {TOOL_INGEST, TOOL_QUERY} & set(updated_tools):
import_proxy_environment() import_proxy_environment()
+8
View File
@@ -91,6 +91,8 @@ class McpIngestTests(unittest.TestCase):
self.assertIn(".agents/skills/llm-wiki/SKILL.md", "\n".join(result["errors"])) self.assertIn(".agents/skills/llm-wiki/SKILL.md", "\n".join(result["errors"]))
def test_check_opencode_readiness_smoke_test_success(self): 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: 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) 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) self.assertEqual(run_mock.call_count, 3)
smoke_args = run_mock.call_args_list[-1].args[0] 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[: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("--auto", smoke_args)
self.assertIn("--dir", smoke_args) self.assertIn("--dir", smoke_args)
self.assertIn("MCP ingest readiness smoke test", 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): def test_start_ingest_job_constructs_restricted_opencode_command(self):
payload = b"source data" payload = b"source data"
upload = self._complete_upload("source.pdf", payload) upload = self._complete_upload("source.pdf", payload)
self.manager.opencode_model = "openai/gpt-5.5"
self.manager.opencode_variant = "high"
popen = FakePopen() popen = FakePopen()
with ( with (
@@ -241,6 +247,8 @@ class McpIngestTests(unittest.TestCase):
args = popen_mock.call_args.args[0] args = popen_mock.call_args.args[0]
self.assertEqual(args[:5], ["/usr/bin/opencode", "run", "--format", "json", "--auto"]) 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.assertIn("--dir", args)
self.assertEqual(args[args.index("--dir") + 1], str(self.project)) self.assertEqual(args[args.index("--dir") + 1], str(self.project))
self.assertIn("--title", args) self.assertIn("--title", args)
+8
View File
@@ -61,6 +61,8 @@ class McpQueryTests(unittest.TestCase):
self.assertIn(".agents/skills/llm-wiki/SKILL.md", "\n".join(result["errors"])) self.assertIn(".agents/skills/llm-wiki/SKILL.md", "\n".join(result["errors"]))
def test_check_opencode_readiness_smoke_test_success(self): 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: 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) 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) self.assertEqual(run_mock.call_count, 3)
smoke_args = run_mock.call_args_list[-1].args[0] 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[: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("--auto", smoke_args)
self.assertIn("--dir", smoke_args) self.assertIn("--dir", smoke_args)
self.assertIn("MCP query readiness smoke test", 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") self.manager.start_query_job("what do I know?", mode="full")
def test_start_query_job_constructs_restricted_opencode_command(self): 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() popen = FakePopen()
with ( with (
@@ -97,6 +103,8 @@ class McpQueryTests(unittest.TestCase):
args = popen_mock.call_args.args[0] args = popen_mock.call_args.args[0]
self.assertEqual(args[:5], ["/usr/bin/opencode", "run", "--format", "default", "--auto"]) 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.assertIn("--dir", args)
self.assertEqual(args[args.index("--dir") + 1], str(self.project)) self.assertEqual(args[args.index("--dir") + 1], str(self.project))
self.assertIn("--title", args) self.assertIn("--title", args)
+67
View File
@@ -425,6 +425,73 @@ class RemoteMcpServerTests(unittest.TestCase):
self.assertIn("cancel_wiki_query", FakeMCP.tool_names) self.assertIn("cancel_wiki_query", FakeMCP.tool_names)
self.assertIn("check_query_opencode_readiness", 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): def test_create_ingest_upload_ticket_tool_returns_http_only_token(self):
class FakeMCP: class FakeMCP:
tools = {} tools = {}
+24
View File
@@ -70,6 +70,10 @@ class McpServiceConfigTests(unittest.TestCase):
self.assertIn("OBSIDIAN_MCP_PATH=/mcp\n", content) self.assertIn("OBSIDIAN_MCP_PATH=/mcp\n", content)
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\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_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).st_mode), 0o600)
self.assertEqual(stat.S_IMODE(os.stat(env_path.parent).st_mode), 0o700) 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_ENABLED_TOOLS=file-assets,ingest\n", env_content)
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\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_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("http://10.0.0.4:9876/wiki-mcp", output)
self.assertIn("Authorization: Bearer created-token", output) self.assertIn("Authorization: Bearer created-token", output)
self.assertIn('"type": "streamable-http"', output) self.assertIn('"type": "streamable-http"', output)
@@ -264,6 +272,14 @@ class McpServiceConfigTests(unittest.TestCase):
+ "OBSIDIAN_MCP_INGEST_OPENCODE_PATH=/custom/bin/opencode\n", + "OBSIDIAN_MCP_INGEST_OPENCODE_PATH=/custom/bin/opencode\n",
encoding="utf-8", 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 ( with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}), 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_ENABLED_TOOLS=file-assets,ingest\n", env_content)
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\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_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): def test_start_service_can_enable_query_without_ingest(self):
with tempfile.TemporaryDirectory() as tmp: 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_ENABLED_TOOLS=file-assets,query\n", env_content)
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=0\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_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.assertNotIn("OBSIDIAN_MCP_INGEST_OPENCODE_PATH=", env_content)
self.assertIn("Enabled tool groups:\nfile-assets,query", output) 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")) prepare_runtime.assert_called_once_with(project_root, uv_path=Path("/home/nac/.local/bin/uv"))