feat(mcp): add agent-friendly query capabilities

Add get_mcp_capabilities so remote agents can discover enabled tool groups and preferred entrypoints.

Add ask_wiki as a one-call query wrapper with structured result fields while keeping the legacy answer field for compatibility.

Update MCP startup guidance and docs, and cover the new query/capability behavior with tests.
This commit is contained in:
Codex
2026-07-08 16:53:59 +00:00
parent 1150107aa3
commit ee7defe8bf
8 changed files with 368 additions and 15 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ See `wiki-query` and `wiki-export` skills for how the filter is applied.
- **Merge, do not overwrite.** Automatic identifier extraction may add terms, but it must not discard curated registry metadata. Registry values are the durable source for future index rebuilds and remote MCP lookup.
- **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 and direct text notes must stage outside the vault under the MCP runtime root; `wiki-ingest` is responsible for accepting, archiving, and indexing sources inside the vault. Use `ingest_text_note` only for short remote-agent thoughts/notes; it must wrap the text as a temporary markdown upload and then use the fixed `/wiki-ingest` job path. Use the HTTP multipart endpoint `/mcp-ingest/uploads` for 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. Upload tickets are consumed only by authorized `POST` uploads, the upload endpoint must enforce configured host/origin restrictions, and oversized request bodies must fail with HTTP `413`. 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 responses must not expose local runtime paths; internal job records may keep paths for the fixed local prompt. 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. Remote query responses may expose log tails but must not expose local log file paths.
- **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. `ask_wiki` is the preferred remote-agent query entrypoint; the async `start_wiki_query`/status/result tools remain available for long jobs. `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. Remote query responses may expose log tails but must not expose local log file paths, and structured fields such as `answer_markdown`, `citations`, `warnings`, and `confidence` must remain backward-compatible with the legacy `answer` field.
- **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
+3 -1
View File
@@ -388,6 +388,7 @@ Configure your agent to run `obsidian-wiki-mcp` as a stdio MCP server. The serve
- `list_file_assets` — browses safe source files with optional filters.
- `get_file_asset_metadata` — returns one file's safe metadata and `asset://source/sha256/...` URI.
- `file_index_status` — shows vault/index/profile status.
- `get_mcp_capabilities` — tells remote agents which tool groups are enabled and which tool to use first.
- `asset://source/sha256/{digest}` — binary resource for an allowed archived file.
By default the MCP layer is stricter than local CLI lookup: it only serves engineering files such as drawings, 3D/STEP models, specifications, product parameters, and test reports. Customer-shareable engineering deliverables, such as PDF customer drawings, are allowed for source-file lookup when the active profile already allows technical engineering data. It blocks raw orders, prices, email correspondence, complaint evidence, claims, personal data, and files missing from `_meta/source-registry.jsonl`. Use `OBSIDIAN_MCP_BOT_PROFILE`, `OBSIDIAN_MCP_MAX_FILE_BYTES`, and `OBSIDIAN_MCP_EXPOSE_PATHS` to tune the runtime behavior.
@@ -500,12 +501,13 @@ After `query` is enabled once, later `scripts/obsidian-wiki-ops.py mcp start` pr
This adds read-oriented OpenCode-backed query tools to the same `wiki-file-assets` server and bearer token:
- `check_query_opencode_readiness` — verifies the local OpenCode binary, required run flags, project `wiki-query`/`llm-wiki` skills, and optionally runs a no-write smoke test.
- `ask_wiki` — preferred one-call query path for remote agents. It starts the local job, polls briefly, and returns either a structured answer or a `job_id` to continue polling.
- `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; remote query status/result responses include log tails but omit local log file paths. A normal `wiki-query` log entry is allowed. The generated prompt forbids ingest/update/rebuild workflows, writes, sync/config/timer operations, and arbitrary shell execution; it only allows the `obsidian-wiki business schema/template/sql` read-only commands needed by the wiki-query business facts route. Hybrid business + wiki questions may run a wiki context track and a business facts track, using parallel subagents only when the host supports them and otherwise running sequentially. When `public_only=true`, business facts remain disabled.
`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; remote query status/result responses include log tails but omit local log file paths. Query results keep the legacy `answer` field and also include `answer_markdown`, `citations`, `used_wiki`, `used_business_facts`, `data_freshness`, `warnings`, and `confidence` so agents can parse the response without scraping the log tail. A normal `wiki-query` log entry is allowed. The generated prompt forbids ingest/update/rebuild workflows, writes, sync/config/timer operations, and arbitrary shell execution; it only allows the `obsidian-wiki business schema/template/sql` read-only commands needed by the wiki-query business facts route. Hybrid business + wiki questions may run a wiki context track and a business facts track, using parallel subagents only when the host supports them and otherwise running sequentially. When `public_only=true`, business facts remain disabled.
Clean old MCP runtime staging data with the default 30-day retention window:
+87 -11
View File
@@ -140,17 +140,34 @@ class QueryManager:
def get_query_job_result(self, job_id: str) -> dict[str, Any]:
with self._lock:
status = self.get_query_job_status(job_id)
output_tail = status.get("log_tail", "")
return {
"job_id": status["job_id"],
"state": status["state"],
"answer": output_tail.strip(),
"mode": status.get("mode"),
"public_only": status.get("public_only"),
"exit_code": status.get("exit_code"),
"opencode_output_tail": output_tail,
"error": status.get("error", ""),
}
return self._query_result_from_status(status)
def ask_wiki(
self,
query: str,
*,
mode: str = "normal",
public_only: bool = False,
note: str | None = None,
timeout_seconds: int = 90,
poll_interval_seconds: int = 2,
) -> dict[str, Any]:
timeout = max(0, int(timeout_seconds))
poll_interval = max(1, int(poll_interval_seconds))
status = self.start_query_job(query, mode=mode, public_only=public_only, note=note)
job_id = status["job_id"]
deadline = time.monotonic() + timeout
while status.get("state") in {"queued", "running"}:
if time.monotonic() >= deadline:
result = self._query_result_from_status(status)
result["next_poll_after_seconds"] = poll_interval
result["warnings"].append(
f"Query timed out after {timeout} seconds; poll get_wiki_query_status or get_wiki_query_result with job_id."
)
return result
time.sleep(min(poll_interval, max(0, deadline - time.monotonic())))
status = self.get_query_job_status(job_id)
return self.get_query_job_result(job_id)
def cancel_query_job(self, job_id: str) -> dict[str, Any]:
with self._lock:
@@ -348,6 +365,65 @@ class QueryManager:
"log_tail": self._read_tail(Path(job["log_path"])),
}
def _query_result_from_status(self, status: dict[str, Any]) -> dict[str, Any]:
output_tail = status.get("log_tail", "")
answer = output_tail.strip()
structured = self._structured_answer_metadata(answer, status=status)
return {
"job_id": status["job_id"],
"state": status["state"],
"answer": answer,
"answer_markdown": answer,
"citations": structured["citations"],
"used_wiki": structured["used_wiki"],
"used_business_facts": structured["used_business_facts"],
"data_freshness": structured["data_freshness"],
"warnings": structured["warnings"],
"confidence": structured["confidence"],
"mode": status.get("mode"),
"public_only": status.get("public_only"),
"exit_code": status.get("exit_code"),
"opencode_output_tail": output_tail,
"error": status.get("error", ""),
}
def _structured_answer_metadata(self, answer: str, *, status: dict[str, Any]) -> dict[str, Any]:
citations = self._extract_wikilink_citations(answer)
lower = answer.lower()
used_business_facts = bool(
re.search(r"\bbusiness facts?\b|\bbusiness data\b|业务数据|业务查询|业务事实", answer, flags=re.IGNORECASE)
)
used_wiki = bool(citations or "wiki" in lower or "wikilink" in lower)
data_freshness = list(dict.fromkeys(re.findall(r"\b20\d{2}-\d{2}-\d{2}\b", answer)))
warnings: list[str] = []
state = status.get("state")
error = str(status.get("error") or "").strip()
if state == "failed":
warnings.append("Query job failed.")
if error:
warnings.append(error)
elif state in {"queued", "running"}:
warnings.append(f"Query job is still {state}.")
if not answer:
warnings.append("Query returned no answer text yet.")
confidence = "low" if state == "failed" else "medium" if state in {"queued", "running"} or not answer else "high"
return {
"citations": citations,
"used_wiki": used_wiki,
"used_business_facts": used_business_facts,
"data_freshness": data_freshness,
"warnings": warnings,
"confidence": confidence,
}
def _extract_wikilink_citations(self, answer: str) -> list[str]:
citations: list[str] = []
for raw in re.findall(r"\[\[([^\]]+)\]\]", answer):
target = raw.split("|", 1)[0].split("#", 1)[0].strip()
if target and target not in citations:
citations.append(target)
return citations
def _tree_mtime(self, path: Path) -> float:
if not path.exists():
return time.time()
+111
View File
@@ -278,6 +278,98 @@ def create_server(
)
return status
@server.tool()
def get_mcp_capabilities() -> dict[str, Any]:
"""Tell remote agents which MCP capabilities are enabled and which tool to use first."""
enabled = sorted(
([TOOL_FILE_ASSETS] if enable_file_assets else [])
+ ([TOOL_INGEST] if enable_ingest else [])
+ ([TOOL_QUERY] if enable_query else [])
)
return {
"server_name": MCP_SERVER_NAME,
"enabled_tools": enabled,
"recommended_first_steps": [
"Use ask_wiki for ordinary wiki, business facts, or hybrid questions when query is enabled.",
"Use search_file_assets before reading source file resources.",
"Use ingest_text_note for short text notes when ingest is enabled.",
"Use get_ingest_upload_instructions before HTTP file upload.",
],
"file_assets": {
"enabled": enable_file_assets,
"tools": (
[
"search_file_assets",
"list_file_assets",
"get_file_asset_metadata",
"asset://source/sha256/{digest}",
]
if enable_file_assets
else []
),
"best_for": "safe engineering source files such as drawings, CAD/3D models, specifications, and test reports",
"blocked_content": "raw orders, prices, email correspondence, complaint evidence, claims, and personal data",
"max_file_bytes": max_file_bytes,
},
"query": {
"enabled": enable_query,
"preferred_tool": "ask_wiki" if enable_query else None,
"async_tools": (
[
"start_wiki_query",
"get_wiki_query_status",
"get_wiki_query_result",
"cancel_wiki_query",
]
if enable_query
else []
),
"tools": (
[
"ask_wiki",
"start_wiki_query",
"get_wiki_query_status",
"get_wiki_query_result",
"cancel_wiki_query",
"check_query_opencode_readiness",
]
if enable_query
else []
),
"supports_business_facts": enable_query,
"supports_hybrid_questions": enable_query,
"public_only_disables_business_facts": True,
"max_query_chars": query_max_chars,
},
"ingest": {
"enabled": enable_ingest,
"preferred_text_tool": "ingest_text_note" if enable_ingest else None,
"http_upload_path": DEFAULT_INGEST_UPLOAD_PATH if enable_ingest else None,
"tools": (
[
"get_ingest_upload_instructions",
"ingest_text_note",
"create_ingest_upload_ticket",
"start_ingest_job",
"get_ingest_job_status",
"get_ingest_job_result",
"cancel_ingest_job",
"check_ingest_opencode_readiness",
]
if enable_ingest
else []
),
"max_file_bytes": ingest_max_file_bytes,
"max_files_per_job": ingest_max_files_per_job,
},
"safety": {
"arbitrary_shell": False,
"arbitrary_prompt": False,
"local_paths_hidden_by_default": not expose_paths,
"model_choice_server_side": True,
},
}
if enable_ingest:
if ingest_manager is None:
ingest_manager = IngestManager(
@@ -426,6 +518,25 @@ def create_server(
max_query_chars=query_max_chars,
)
@server.tool()
def ask_wiki(
query: str,
mode: str = "normal",
public_only: bool = False,
note: str | None = None,
timeout_seconds: int = 90,
poll_interval_seconds: int = 2,
) -> dict[str, Any]:
"""Ask the wiki in one call; returns a final structured answer or a job_id if it times out."""
return query_manager.ask_wiki(
query,
mode=mode,
public_only=public_only,
note=note,
timeout_seconds=timeout_seconds,
poll_interval_seconds=poll_interval_seconds,
)
@server.tool()
def start_wiki_query(
query: str,
+3 -2
View File
@@ -427,8 +427,9 @@ def render_remote_config(
query_section = (
"\n"
"Wiki query tools:\n"
"Use start_wiki_query, get_wiki_query_status, get_wiki_query_result, "
"cancel_wiki_query, and check_query_opencode_readiness.\n"
"Use ask_wiki for ordinary wiki, business facts, or hybrid questions.\n"
"For long-running jobs, use start_wiki_query, get_wiki_query_status, "
"get_wiki_query_result, cancel_wiki_query, and check_query_opencode_readiness.\n"
)
return (
"MCP service is running.\n"
+68
View File
@@ -150,8 +150,76 @@ class McpQueryTests(unittest.TestCase):
self.assertEqual(result["state"], "succeeded")
self.assertIn("Based on the wiki", result["answer"])
self.assertEqual(result["answer_markdown"], result["answer"])
self.assertEqual(result["citations"], [])
self.assertFalse(result["used_business_facts"])
self.assertTrue(result["used_wiki"])
self.assertEqual(result["data_freshness"], [])
self.assertEqual(result["confidence"], "high")
self.assertEqual(result["warnings"], [])
self.assertIn("Answer body", result["opencode_output_tail"])
def test_query_result_extracts_structured_metadata_from_output(self):
popen = FakePopen(returncode=None)
with (
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen),
):
job = self.manager.start_query_job("customer situation?")
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
log_path.write_text(
"Based on [[Mini FAKRA]] and [[BJD]].\n"
"Business facts used. Data through 2026-06-02.\n"
"Answer body\n",
encoding="utf-8",
)
popen.returncode = 0
result = self.manager.get_query_job_result(job["job_id"])
self.assertEqual(result["answer_markdown"], result["answer"])
self.assertEqual(result["citations"], ["Mini FAKRA", "BJD"])
self.assertTrue(result["used_business_facts"])
self.assertTrue(result["used_wiki"])
self.assertEqual(result["data_freshness"], ["2026-06-02"])
self.assertEqual(result["confidence"], "high")
def test_query_result_marks_failed_jobs_low_confidence_with_warnings(self):
popen = FakePopen(returncode=1)
with (
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen),
):
job = self.manager.start_query_job("what failed?")
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
log_path.write_text("Traceback: failed\n", encoding="utf-8")
result = self.manager.get_query_job_result(job["job_id"])
self.assertEqual(result["state"], "failed")
self.assertEqual(result["confidence"], "low")
self.assertTrue(any("failed" in warning.lower() for warning in result["warnings"]))
def test_ask_wiki_returns_timeout_payload_with_job_id(self):
popen = FakePopen(returncode=None)
with (
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen),
):
result = self.manager.ask_wiki(
"slow question?",
timeout_seconds=0,
poll_interval_seconds=1,
)
self.assertEqual(result["state"], "running")
self.assertRegex(result["job_id"], r"^[0-9a-f]{32}$")
self.assertEqual(result["next_poll_after_seconds"], 1)
self.assertIn("timed out", " ".join(result["warnings"]).lower())
def test_jobs_queue_one_at_a_time_and_cancel_queued_job(self):
popen = FakePopen(returncode=None)
+94
View File
@@ -416,12 +416,14 @@ class RemoteMcpServerTests(unittest.TestCase):
self.assertIn("search_file_assets", FakeMCP.tool_names)
self.assertIn("list_file_assets", FakeMCP.tool_names)
self.assertIn("get_file_asset_metadata", FakeMCP.tool_names)
self.assertIn("get_mcp_capabilities", FakeMCP.tool_names)
self.assertIn("how_to_use_wiki_file_assets", FakeMCP.prompt_names)
self.assertNotIn("create_ingest_upload", FakeMCP.tool_names)
def test_create_server_registers_ingest_tools_when_enabled(self):
class FakeMCP:
tool_names = []
tools = {}
def __init__(self, _name, **_kwargs):
pass
@@ -429,6 +431,7 @@ class RemoteMcpServerTests(unittest.TestCase):
def tool(self):
def decorator(func):
self.tool_names.append(func.__name__)
self.tools[func.__name__] = func
return func
return decorator
@@ -474,6 +477,7 @@ class RemoteMcpServerTests(unittest.TestCase):
def test_create_server_registers_query_tools_only_when_enabled(self):
class FakeMCP:
tool_names = []
tools = {}
def __init__(self, _name, **_kwargs):
pass
@@ -481,6 +485,7 @@ class RemoteMcpServerTests(unittest.TestCase):
def tool(self):
def decorator(func):
self.tool_names.append(func.__name__)
self.tools[func.__name__] = func
return func
return decorator
@@ -498,6 +503,16 @@ class RemoteMcpServerTests(unittest.TestCase):
return decorator
class FakeQueryManager:
def ask_wiki(self, query, mode="normal", public_only=False, note=None, timeout_seconds=90, poll_interval_seconds=2):
return {
"job_id": "query-job",
"state": "succeeded",
"answer": "answer",
"answer_markdown": "answer",
"citations": [],
"warnings": [],
}
def start_query_job(self, query, mode="normal", public_only=False, note=None):
return {"job_id": "query-job", "state": "queued"}
@@ -532,11 +547,89 @@ class RemoteMcpServerTests(unittest.TestCase):
self.assertIsInstance(disabled_server, FakeMCP)
self.assertIsInstance(enabled_server, FakeMCP)
self.assertNotIn("start_wiki_query", disabled_tool_names)
self.assertNotIn("ask_wiki", disabled_tool_names)
self.assertIn("ask_wiki", FakeMCP.tool_names)
self.assertIn("start_wiki_query", FakeMCP.tool_names)
self.assertIn("get_wiki_query_status", FakeMCP.tool_names)
self.assertIn("get_wiki_query_result", FakeMCP.tool_names)
self.assertIn("cancel_wiki_query", FakeMCP.tool_names)
self.assertIn("check_query_opencode_readiness", FakeMCP.tool_names)
self.assertEqual(
FakeMCP.tools["ask_wiki"]("what do I know?", timeout_seconds=3, poll_interval_seconds=1)["answer_markdown"],
"answer",
)
def test_get_mcp_capabilities_reports_agent_facing_tools_without_local_paths(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_manager=IngestManager(
vault_path=root / "vault",
project_root=root / "project",
runtime_root=root / "fake-ingest",
),
query_manager=type(
"FakeQueryManager",
(),
{
"ask_wiki": lambda self, query, mode="normal", public_only=False, note=None, timeout_seconds=90, poll_interval_seconds=2: {},
"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: {},
},
)(),
)
capabilities = FakeMCP.tools["get_mcp_capabilities"]()
self.assertEqual(capabilities["server_name"], "wiki-file-assets")
self.assertEqual(capabilities["enabled_tools"], ["file-assets", "ingest", "query"])
self.assertTrue(capabilities["query"]["enabled"])
self.assertEqual(capabilities["query"]["preferred_tool"], "ask_wiki")
self.assertIn("ask_wiki", capabilities["query"]["tools"])
self.assertTrue(capabilities["ingest"]["enabled"])
self.assertEqual(capabilities["ingest"]["http_upload_path"], "/mcp-ingest/uploads")
self.assertEqual(capabilities["ingest"]["preferred_text_tool"], "ingest_text_note")
self.assertIn("search_file_assets", capabilities["file_assets"]["tools"])
self.assertNotIn("vault_path", json.dumps(capabilities))
self.assertNotIn("runtime", json.dumps(capabilities).lower())
self.assertNotIn("Bearer ", json.dumps(capabilities))
self.assertNotIn("upload_token", json.dumps(capabilities))
def test_file_index_status_reports_opencode_model_settings(self):
class FakeMCP:
@@ -589,6 +682,7 @@ class RemoteMcpServerTests(unittest.TestCase):
"FakeQueryManager",
(),
{
"ask_wiki": lambda self, query, mode="normal", public_only=False, note=None, timeout_seconds=90, poll_interval_seconds=2: {},
"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: {},
+1
View File
@@ -332,6 +332,7 @@ class McpServiceConfigTests(unittest.TestCase):
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)
self.assertIn("Use ask_wiki for ordinary wiki, business facts, or hybrid questions.", output)
prepare_runtime.assert_called_once_with(project_root, uv_path=Path("/home/nac/.local/bin/uv"))
run_systemctl.assert_has_calls(
[