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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user