feat(mcp): separate direct order queries from wiki tools

Add an opt-in business group with schema, template, guarded SQL, and status tools. Reuse existing order statistics through read-only SQLite connections without OpenCode or wiki retrieval.

Add tools set for switching enabled groups with one restart. Gate file diagnostics with file-assets and document business-only operation while retaining the independent Kingdee sync task.

Validation: 217 unit tests passed; business tool tests rerun after read-only annotations. Live authenticated MCP schema/status/template/SQL calls passed, ask_wiki was unavailable, uploads returned 404, and unauthenticated requests returned 401.
This commit is contained in:
Codex
2026-10-31 06:47:43 +00:00
parent 1133e2bf50
commit 6c451c3eb6
10 changed files with 500 additions and 44 deletions
+4
View File
@@ -71,6 +71,10 @@ OBSIDIAN_MCP_HOST=127.0.0.1
OBSIDIAN_MCP_PORT=8765
OBSIDIAN_MCP_PATH=/mcp
OBSIDIAN_MCP_ENABLED_TOOLS=file-assets
# Direct order queries without Wiki/OpenCode: set the managed service with
# scripts/mcp_service.sh tools set business
# Optional separate existing business database; default is <vault>/_meta/business-facts.sqlite.
# OBSIDIAN_MCP_BUSINESS_DB=/path/to/business-facts.sqlite
# Optional public URLs shown to remote agents in capabilities/config output.
# Set PUBLIC_BASE_URL when /mcp is behind DNS/tunnel/reverse proxy.
OBSIDIAN_MCP_PUBLIC_BASE_URL=
+2
View File
@@ -126,6 +126,8 @@ See `wiki-query` and `wiki-export` skills for how the filter is applied.
## File Asset Indexing
- **Keep business MCP independent.** The opt-in `business` group exposes direct schema/template/guarded-SQL/status tools without Wiki retrieval or OpenCode. `scripts/mcp_service.sh tools set business` disables Wiki query, file assets, and ingest together while preserving order access. Business MCP reads the existing SQLite database through read-only connections; the separate Kingdee sync task owns updates. Preserve configured internal-party exclusions and sync freshness metadata. `OBSIDIAN_MCP_BUSINESS_DB` or `--business-db` selects an existing business database independently of the file-asset index.
- **Index source files as richly as possible.** When writing `_meta/source-registry.jsonl` or rebuilding `_meta/file-assets.sqlite`, preserve structured identifiers instead of relying only on filenames or summaries.
- **Keep product lookup fields explicit.** Carry forward `bjd_part_numbers`, `competitor_part_numbers`, `series`, `pitch`, `pin_count`, `product_lines`, `customers`, `competitors`, `standards`, `business_categories`, `related_pages`, and `catalog_paths` whenever they are known.
- **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.
+50
View File
@@ -14,6 +14,56 @@ Use `<vault>/_meta/business-facts.sqlite` for questions where markdown is the wr
The wiki remains the place for context: customer background, project history, sales notes, email-derived signals, and cross-linked narrative knowledge.
## Independent Business MCP
The `business` MCP tool group queries the synchronized SQLite database directly.
It does not start OpenCode, load `wiki-query`, read Wiki pages, or run Kingdee sync.
Clients interpret natural-language requests and call these tools:
| Tool | Purpose |
| --- | --- |
| `get_business_schema` | Tables, templates, parameters, configured internal-party defaults, and counting rules |
| `query_business_template` | Existing order, sales, customer/product/staff, and delivery-plan templates |
| `execute_business_sql` | Guarded SELECT fallback when no template fits |
| `get_business_status` | Data availability, stored order date range, and last successful sync |
To keep orders available while turning off all Wiki MCP capabilities:
```bash
scripts/mcp_service.sh tools set business
```
This saves `OBSIDIAN_MCP_ENABLED_TOOLS=business` and restarts the managed service
once, preserving the connection URL and bearer token. Reconnect clients to refresh
their tool list. `ask_wiki`, its job tools, file tools/resources, and ingest tools
and upload routes are absent. `get_mcp_capabilities` remains available.
Example tool arguments for `query_business_template`:
```json
{"template_name": "sales_amount", "params": {"year": 2026, "month": 6}}
```
Database connections use SQLite `mode=ro` and `query_only`; queries never initialize
or migrate the sync-owned database. Missing data returns an unavailable/error
response rather than silently creating an empty database. The existing independent
Kingdee timer continues to own updates. Database location defaults to
`<vault>/_meta/business-facts.sqlite`; `--business-db` or
`OBSIDIAN_MCP_BUSINESS_DB` (in the project `.env` or process environment) can select
another existing database without changing the file-asset index.
Templates load the operator's `OBSIDIAN_BUSINESS_INTERNAL_PARTIES`,
`OBSIDIAN_BUSINESS_INTERNAL_PARTY_IDS`, and `OBSIDIAN_BUSINESS_INCLUDE_INTERNAL_ORDERS`
settings. Clients cannot replace the internal-party lists; `include_internal_orders`
remains an explicit template option for internal-transfer audits. Guarded SQL keeps
the existing table/column restrictions and row limits, and must express the internal
transfer filter itself. All query results include `last_sync`, `is_stale`, and warnings.
The legacy `query` group still supports the combined OpenCode workflow when enabled.
For example, `scripts/mcp_service.sh tools set business file-assets query ingest`
enables both paths again. The groups are independently gated; this does not migrate
or duplicate the business database.
## Commands
```bash
+22
View File
@@ -11,6 +11,8 @@ import hashlib
import json
import re
import sqlite3
from contextlib import contextmanager
from contextvars import ContextVar
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
@@ -20,6 +22,17 @@ from obsidian_wiki.kingdee_api import fetch_kingdee_sales_orders, load_kingdee_c
BUSINESS_DB_RELATIVE_PATH = Path("_meta") / "business-facts.sqlite"
KINGDEE_SYNC_STATE_SOURCE = "kingdee_sales_orders_modifytime"
_READ_ONLY = ContextVar("business_facts_read_only", default=False)
@contextmanager
def read_only_queries():
"""Reuse business queries without creating/migrating the sync-owned database."""
token = _READ_ONLY.set(True)
try:
yield
finally:
_READ_ONLY.reset(token)
ORDER_COLUMNS = (
"order_id",
@@ -166,6 +179,11 @@ def business_db_path_for_vault(vault_path: str | Path) -> Path:
def connect(db_path: Path) -> sqlite3.Connection:
if _READ_ONLY.get():
conn = sqlite3.connect(db_path.resolve().as_uri() + "?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA query_only=ON")
return conn
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
@@ -175,6 +193,10 @@ def connect(db_path: Path) -> sqlite3.Connection:
def init_db(db_path: Path) -> None:
if _READ_ONLY.get():
if not db_path.is_file():
raise RuntimeError("Business database is unavailable; run the operator's Kingdee sync first.")
return
with connect(db_path) as conn:
conn.executescript(
"""
+134
View File
@@ -0,0 +1,134 @@
"""Direct business MCP tools, independent of Wiki retrieval and OpenCode."""
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import Any, Callable
from obsidian_wiki import business_facts as facts
BUSINESS_TOOLS = [
"get_business_schema",
"query_business_template",
"execute_business_sql",
"get_business_status",
]
TEMPLATE_PARAMETERS = {
"year", "month", "start_date", "end_date", "end_date_exclusive",
"limit", "granularity", "staff_role_scope", "customer", "product", "staff",
"org", "department", "project", "warehouse", "billstatus", "orderstatus",
"closestatus", "currency", "include_internal_orders",
}
class BusinessQueries:
def __init__(self, db_path: Path, *, defaults: dict[str, Any] | None = None):
self.db_path = db_path.expanduser().resolve()
self.defaults = dict(defaults or {})
def schema(self) -> dict[str, Any]:
schema = facts.business_schema()
schema["purpose"] = "Query synchronized Kingdee sales orders directly. No Wiki or server-side LLM is involved."
schema["defaults"] = self.defaults
schema["read_only"] = True
schema["sql_guardrails"]["intercompany_filter"] = (
"Templates apply configured internal-party exclusions automatically. "
"Custom SQL must apply the same rule using defaults unless auditing internal transfers."
)
for template in schema["templates"]:
template["optional_parameters"] = [
p for p in template["optional_parameters"]
if p not in {"internal_parties", "internal_party_ids"}
]
return schema
def _read(self, operation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
with facts.read_only_queries():
try:
facts.init_db(self.db_path)
return operation()
except sqlite3.Error as exc:
# Do not leak local database paths or query fragments through MCP errors.
raise RuntimeError("Business query failed; check the schema, query permissions, and sync status.") from exc
def _freshness(self) -> dict[str, Any]:
last_sync = facts.last_successful_sync(self.db_path)
stale = facts.sync_is_stale(last_sync)
return {
"data_source": "kingdee_sales_orders",
"last_sync": last_sync,
"is_stale": stale,
"warnings": ["Data may be incomplete since the last successful sync."] if stale else [],
}
def status(self) -> dict[str, Any]:
if not self.db_path.is_file():
return {"available": False, "last_sync": "", "is_stale": True,
"warnings": ["Business database is unavailable; run the operator's Kingdee sync first."]}
def read():
with facts.connect(self.db_path) as conn:
row = conn.execute(
"SELECT COUNT(*) AS stored_orders, MIN(order_date) AS earliest_order_date, "
"MAX(order_date) AS latest_order_date FROM orders"
).fetchone()
return {"available": True, **dict(row), **self._freshness()}
return self._read(read)
def template(self, template_name: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
params = dict(params or {})
unknown = params.keys() - TEMPLATE_PARAMETERS
if unknown:
raise ValueError("Unsupported template parameters: " + ", ".join(sorted(unknown)))
effective = {**self.defaults, **params}
def read():
result = facts.execute_business_template(self.db_path, template_name, **effective)
result.update(self._freshness())
return result
return self._read(read)
def sql(self, sql: str, limit: int = facts.SQL_DEFAULT_LIMIT) -> dict[str, Any]:
def read():
result = facts.execute_business_sql(self.db_path, sql, limit=limit)
result.update(self._freshness())
result["counting_basis"] = "Custom SQL; internal-party exclusions must be expressed in the query."
return result
return self._read(read)
def register_business_tools(server: Any, queries: BusinessQueries) -> None:
from mcp.types import ToolAnnotations
read_only = ToolAnnotations(readOnlyHint=True, destructiveHint=False, openWorldHint=False)
@server.tool(annotations=read_only)
def get_business_schema() -> dict[str, Any]:
"""Read sales-order tables, query templates, parameters, and counting rules before querying."""
return queries.schema()
@server.tool(annotations=read_only)
def query_business_template(template_name: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""Query orders, sales, customers, products, staff, or delivery plans. Prefer this over SQL.
Read get_business_schema first. params needs year/month or start_date/end_date_exclusive,
with optional customer/product/staff filters. Returns results and last sync time.
"""
return queries.template(template_name, params)
@server.tool(annotations=read_only)
def execute_business_sql(sql: str, limit: int = facts.SQL_DEFAULT_LIMIT) -> dict[str, Any]:
"""Run one guarded SELECT/WITH SELECT when no template fits. No raw JSON or writes.
Apply internal-party exclusion rules from get_business_schema for normal sales metrics.
"""
return queries.sql(sql, limit)
@server.tool(annotations=read_only)
def get_business_status() -> dict[str, Any]:
"""Check order-data availability, stored order date range, and last successful sync."""
return queries.status()
+85 -39
View File
@@ -16,6 +16,8 @@ from urllib.parse import urlsplit, urlunsplit
from obsidian_wiki import __version__
from obsidian_wiki.config import config_value, resolve_vault_path
from obsidian_wiki.business_facts import business_db_path_for_vault
from obsidian_wiki.mcp_business import BUSINESS_TOOLS, BusinessQueries, register_business_tools
from obsidian_wiki.file_assets import clean_config_value, db_path_for_vault, registry_path_for_vault
from obsidian_wiki.mcp_ingest import (
DEFAULT_INGEST_JOB_TIMEOUT_SECONDS,
@@ -63,13 +65,14 @@ PUBLIC_UPLOAD_URL_ENV = "OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL"
ALLOWED_HOSTS_ENV = "OBSIDIAN_MCP_ALLOWED_HOSTS"
ALLOWED_ORIGINS_ENV = "OBSIDIAN_MCP_ALLOWED_ORIGINS"
MCP_SERVER_NAME = "wiki-file-assets"
CAPABILITIES_SCHEMA_VERSION = 3
TOOLS_VERSION = 3
CAPABILITIES_SCHEMA_VERSION = 4
TOOLS_VERSION = 4
TOOL_FILE_ASSETS = "file-assets"
TOOL_INGEST = "ingest"
TOOL_QUERY = "query"
TOOL_BUSINESS = "business"
DEFAULT_ENABLED_TOOLS = {TOOL_FILE_ASSETS}
AVAILABLE_TOOLS = {TOOL_FILE_ASSETS, TOOL_INGEST, TOOL_QUERY}
AVAILABLE_TOOLS = {TOOL_FILE_ASSETS, TOOL_INGEST, TOOL_QUERY, TOOL_BUSINESS}
def text_note_file_name(title: str | None) -> str:
@@ -114,6 +117,8 @@ def normalize_tool_name(name: str) -> str:
"upload": TOOL_INGEST,
"uploads": TOOL_INGEST,
"opencode-ingest": TOOL_INGEST,
"business": TOOL_BUSINESS,
"orders": TOOL_BUSINESS,
"query": TOOL_QUERY,
"wiki-query": TOOL_QUERY,
"ask": TOOL_QUERY,
@@ -205,6 +210,8 @@ def create_server(
enable_file_assets: bool = True,
enable_ingest: bool = False,
enable_query: bool = False,
enable_business: bool = False,
business_db_path: str | Path | None = None,
project_root: str | Path | None = None,
ingest_runtime_root: str | Path | None = None,
query_runtime_root: str | Path | None = None,
@@ -265,39 +272,41 @@ def create_server(
transport_security=transport_security,
)
@server.tool()
def file_index_status() -> dict[str, Any]:
"""Show the configured vault, file index, registry, and MCP limits."""
registry = registry_path_for_vault(vault)
status = {
"db_exists": db.is_file(),
"registry_exists": registry.is_file(),
"source_registry_records": len(load_registry_by_file_id(vault)),
"profile_name": profile_name,
"max_file_bytes": max_file_bytes,
"expose_paths": expose_paths,
"allow_source_files": allow_source_files,
"enabled_tools": sorted(
([TOOL_FILE_ASSETS] if enable_file_assets else [])
+ ([TOOL_INGEST] if enable_ingest else [])
+ ([TOOL_QUERY] if enable_query else [])
),
"ingest_enabled": enable_ingest,
"query_enabled": enable_query,
"ingest_opencode_model": ingest_opencode_model or "",
"ingest_opencode_variant": ingest_opencode_variant or "",
"query_opencode_model": query_opencode_model or "",
"query_opencode_variant": query_opencode_variant or "",
}
if expose_paths:
status.update(
{
"vault_path": str(vault),
"db_path": str(db),
"registry_path": str(registry),
}
)
return status
if enable_file_assets:
@server.tool()
def file_index_status() -> dict[str, Any]:
"""Show the configured vault, file index, registry, and MCP limits."""
registry = registry_path_for_vault(vault)
status = {
"db_exists": db.is_file(),
"registry_exists": registry.is_file(),
"source_registry_records": len(load_registry_by_file_id(vault)),
"profile_name": profile_name,
"max_file_bytes": max_file_bytes,
"expose_paths": expose_paths,
"allow_source_files": allow_source_files,
"enabled_tools": sorted(
([TOOL_FILE_ASSETS] if enable_file_assets else [])
+ ([TOOL_INGEST] if enable_ingest else [])
+ ([TOOL_QUERY] if enable_query else [])
+ ([TOOL_BUSINESS] if enable_business else [])
),
"ingest_enabled": enable_ingest,
"query_enabled": enable_query,
"ingest_opencode_model": ingest_opencode_model or "",
"ingest_opencode_variant": ingest_opencode_variant or "",
"query_opencode_model": query_opencode_model or "",
"query_opencode_variant": query_opencode_variant or "",
}
if expose_paths:
status.update(
{
"vault_path": str(vault),
"db_path": str(db),
"registry_path": str(registry),
}
)
return status
@server.tool()
def get_mcp_capabilities() -> dict[str, Any]:
@@ -306,6 +315,7 @@ def create_server(
([TOOL_FILE_ASSETS] if enable_file_assets else [])
+ ([TOOL_INGEST] if enable_ingest else [])
+ ([TOOL_QUERY] if enable_query else [])
+ ([TOOL_BUSINESS] if enable_business else [])
)
return {
"server_name": MCP_SERVER_NAME,
@@ -318,8 +328,17 @@ def create_server(
enable_file_assets=enable_file_assets,
enable_ingest=enable_ingest,
enable_query=enable_query,
enable_business=enable_business,
),
"file_index": _file_index_capabilities(vault, db),
"file_index": _file_index_capabilities(vault, db) if enable_file_assets else {"enabled": False},
"business": {
"enabled": enable_business,
"tools": BUSINESS_TOOLS if enable_business else [],
"preferred_tool": "query_business_template" if enable_business else None,
"read_only": True,
"requires_opencode": False,
"requires_wiki": False,
},
"file_assets": {
"enabled": enable_file_assets,
"tools": (
@@ -462,6 +481,20 @@ def create_server(
},
}
if enable_business:
configured_db = business_db_path or runtime_config_value("OBSIDIAN_MCP_BUSINESS_DB", "")
business_db = Path(configured_db).expanduser() if configured_db else business_db_path_for_vault(vault)
defaults = {}
for key, env in (
("internal_parties", "OBSIDIAN_BUSINESS_INTERNAL_PARTIES"),
("internal_party_ids", "OBSIDIAN_BUSINESS_INTERNAL_PARTY_IDS"),
("include_internal_orders", "OBSIDIAN_BUSINESS_INCLUDE_INTERNAL_ORDERS"),
):
value = runtime_config_value(env, "")
if value:
defaults[key] = value
register_business_tools(server, BusinessQueries(business_db, defaults=defaults))
owns_ingest_manager = False
if enable_ingest:
if ingest_manager is None:
@@ -1290,6 +1323,8 @@ def run_http_server(
enable_file_assets: bool = True,
enable_ingest: bool = False,
enable_query: bool = False,
enable_business: bool = False,
business_db_path: str | Path | None = None,
project_root: str | Path | None = None,
ingest_runtime_root: str | Path | None = None,
query_runtime_root: str | Path | None = None,
@@ -1375,6 +1410,8 @@ def run_http_server(
enable_file_assets=enable_file_assets,
enable_ingest=enable_ingest,
enable_query=enable_query,
enable_business=enable_business,
business_db_path=business_db_path,
project_root=project_root,
ingest_runtime_root=ingest_runtime_root,
query_runtime_root=query_runtime_root,
@@ -1414,6 +1451,7 @@ def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="obsidian-wiki-mcp", description="Run the obsidian-wiki MCP server")
parser.add_argument("--vault", metavar="PATH", help="absolute path to the Obsidian vault")
parser.add_argument("--db", metavar="PATH", help="SQLite file asset index path")
parser.add_argument("--business-db", metavar="PATH", help="Existing sales-order SQLite database (independent of the file index)")
parser.add_argument("--profile", default=None, help=f"bot profile name, default: {DEFAULT_PROFILE_NAME}")
parser.add_argument("--max-file-bytes", type=int, default=None, help="maximum resource size, 0 disables the limit")
parser.add_argument(
@@ -1479,6 +1517,8 @@ def main(argv: list[str] | None = None) -> int:
enable_file_assets = TOOL_FILE_ASSETS in enabled_tools
enable_ingest = TOOL_INGEST in enabled_tools
enable_query = TOOL_QUERY in enabled_tools
enable_business = TOOL_BUSINESS in enabled_tools
business_db_path = args.business_db or runtime_config_value("OBSIDIAN_MCP_BUSINESS_DB", "") or None
ingest_max_file_bytes = runtime_config_int("OBSIDIAN_MCP_INGEST_MAX_FILE_BYTES", DEFAULT_INGEST_MAX_FILE_BYTES)
ingest_max_chunk_bytes = runtime_config_int("OBSIDIAN_MCP_INGEST_MAX_CHUNK_BYTES", DEFAULT_INGEST_MAX_CHUNK_BYTES)
ingest_max_files_per_job = runtime_config_int(
@@ -1529,6 +1569,8 @@ def main(argv: list[str] | None = None) -> int:
enable_file_assets=enable_file_assets,
enable_ingest=enable_ingest,
enable_query=enable_query,
enable_business=enable_business,
business_db_path=business_db_path,
project_root=Path.cwd(),
ingest_runtime_root=ingest_runtime_root,
query_runtime_root=query_runtime_root,
@@ -1571,6 +1613,8 @@ def main(argv: list[str] | None = None) -> int:
enable_file_assets=enable_file_assets,
enable_ingest=enable_ingest,
enable_query=enable_query,
enable_business=enable_business,
business_db_path=business_db_path,
project_root=Path.cwd(),
ingest_runtime_root=ingest_runtime_root,
query_runtime_root=query_runtime_root,
@@ -1630,8 +1674,10 @@ def _upload_url_from_public_base(public_base_url: str, upload_path: str) -> str
return urlunsplit((parsed.scheme, parsed.netloc, upload_path, "", ""))
def _recommended_first_steps(*, enable_file_assets: bool, enable_ingest: bool, enable_query: bool) -> list[str]:
def _recommended_first_steps(*, enable_file_assets: bool, enable_ingest: bool, enable_query: bool, enable_business: bool = False) -> list[str]:
steps: list[str] = []
if enable_business:
steps.append("Use get_business_schema, then query_business_template for direct sales-order queries; get_business_status reports freshness.")
if enable_query:
steps.append("Use ask_wiki for ordinary wiki, business facts, or hybrid questions.")
if enable_file_assets:
@@ -1640,7 +1686,7 @@ def _recommended_first_steps(*, enable_file_assets: bool, enable_ingest: bool, e
steps.append("Use ingest_text_note for short text notes.")
steps.append("Use create_ingest_upload_ticket before remote HTTP file upload.")
if not steps:
steps.append("No optional MCP tool groups are enabled; ask the operator to enable file-assets, query, or ingest.")
steps.append("No optional MCP tool groups are enabled; ask the operator to enable business, file-assets, query, or ingest.")
return steps
+29 -1
View File
@@ -53,7 +53,8 @@ DEFAULT_RUNTIME_CLEANUP_DAYS = 30
TOOL_FILE_ASSETS = "file-assets"
TOOL_INGEST = "ingest"
TOOL_QUERY = "query"
AVAILABLE_TOOL_GROUPS = (TOOL_FILE_ASSETS, TOOL_INGEST, TOOL_QUERY)
TOOL_BUSINESS = "business"
AVAILABLE_TOOL_GROUPS = (TOOL_FILE_ASSETS, TOOL_INGEST, TOOL_QUERY, TOOL_BUSINESS)
DEFAULT_ENABLED_TOOLS = (TOOL_FILE_ASSETS,)
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
PROXY_ENV_NAMES = ("http_proxy", "https_proxy", "all_proxy", "no_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY")
@@ -71,6 +72,8 @@ def normalize_tool_name(name: str) -> str:
"upload": TOOL_INGEST,
"uploads": TOOL_INGEST,
"opencode-ingest": TOOL_INGEST,
"business": TOOL_BUSINESS,
"orders": TOOL_BUSINESS,
"query": TOOL_QUERY,
"wiki-query": TOOL_QUERY,
"ask": TOOL_QUERY,
@@ -642,6 +645,12 @@ def render_remote_config(
"Remote agents should upload with create_ingest_upload_ticket and X-Obsidian-Wiki-Upload-Token.\n"
"Bearer upload is for local/admin use. POST multipart/form-data with one or more `files` fields.\n"
)
business_section = ""
if enabled_tools is not None and TOOL_BUSINESS in normalize_enabled_tools(enabled_tools):
business_section = (
"\nBusiness query tools (direct SQLite, no OpenCode):\n"
"Use get_business_schema, query_business_template, execute_business_sql, and get_business_status.\n"
)
query_section = ""
if enabled_tools is not None and TOOL_QUERY in normalize_enabled_tools(enabled_tools):
query_section = (
@@ -661,6 +670,7 @@ def render_remote_config(
f"{enabled_section}"
f"{ingest_upload_section}"
f"{query_section}"
f"{business_section}"
"\n"
"Use this header:\n"
f"Authorization: Bearer {token}\n"
@@ -1000,6 +1010,17 @@ def set_tool_enabled(project_root: Path, tool: str, enabled: bool) -> str:
else:
current.discard(tool_name)
updated_tools = tuple(item for item in AVAILABLE_TOOL_GROUPS if item in current)
return _save_tool_groups(paths, config, updated_tools)
def set_enabled_tools(project_root: Path, tools: Sequence[str]) -> str:
"""Replace the whole group set with one config write and one service restart."""
updated_tools = normalize_enabled_tools(tools)
paths, config = ensure_service_config(project_root)
return _save_tool_groups(paths, config, updated_tools)
def _save_tool_groups(paths: ServicePaths, config: ServiceConfig, updated_tools: tuple[str, ...]) -> str:
opencode_path = config.opencode_path
if ({TOOL_INGEST, TOOL_QUERY} & set(updated_tools)) and not opencode_path:
opencode_path = resolve_opencode_path()
@@ -1257,6 +1278,10 @@ def main(argv: Sequence[str] | None = None) -> int:
tools_disable_parser.add_argument("tool", choices=AVAILABLE_TOOL_GROUPS)
tools_disable_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
tools_set_parser = tools_subparsers.add_parser("set", help="Replace enabled groups and restart once; e.g. tools set business.")
tools_set_parser.add_argument("tools", nargs="+", choices=AVAILABLE_TOOL_GROUPS)
tools_set_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
args = parser.parse_args(argv)
if args.command == "new-token":
@@ -1309,6 +1334,9 @@ def main(argv: Sequence[str] | None = None) -> int:
print(cleanup_runtime(args.project_root, max_age_days=args.max_age_days), end="")
return 0
if args.command == "tools":
if args.tools_command == "set":
print(set_enabled_tools(args.project_root, args.tools), end="")
return 0
if args.tools_command == "list":
print(list_tools(args.project_root), end="")
return 0
+144
View File
@@ -0,0 +1,144 @@
import asyncio
import hashlib
import os
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from obsidian_wiki import business_facts as facts, mcp_server
from obsidian_wiki.mcp_business import BUSINESS_TOOLS, BusinessQueries
class BusinessMcpTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.db = self.root / "orders.sqlite"
facts.sync_kingdee_orders(
self.root,
[
{"id": "external", "billno": "SO-1", "bizdate": "2026-06-03",
"customer_name": "External customer", "org_name": "Factory",
"totalallamount": 100, "operator_operatorname": "Alice"},
{"id": "internal", "billno": "SO-2", "bizdate": "2026-06-04",
"customer_name": "Sales company", "org_name": "Factory",
"totalallamount": 500},
],
db_path=self.db,
synced_at="2026-06-05T00:00:00Z",
)
self.queries = BusinessQueries(self.db, defaults={"internal_parties": "Factory,Sales company"})
def test_templates_preserve_intercompany_basis_and_do_not_modify_database(self):
# Checkpoint the sync transaction so the file digest covers the current data.
with sqlite3.connect(self.db) as conn:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
before = hashlib.sha256(self.db.read_bytes()).hexdigest()
result = self.queries.template("sales_amount", {"year": 2026, "month": 6})
self.assertEqual(result["data"]["sales_amount"], 100)
self.assertEqual(result["last_sync"], "2026-06-05T00:00:00Z")
all_orders = self.queries.template("sales_amount", {
"year": 2026, "month": 6, "include_internal_orders": True,
})
self.assertEqual(all_orders["data"]["sales_amount"], 600)
self.assertEqual(hashlib.sha256(self.db.read_bytes()).hexdigest(), before)
with facts.read_only_queries():
with facts.connect(self.db) as conn:
with self.assertRaises(sqlite3.OperationalError):
conn.execute("DELETE FROM orders")
def test_every_existing_template_runs_through_read_only_adapter(self):
for name in facts.BUSINESS_TEMPLATE_NAMES:
with self.subTest(template=name):
result = self.queries.template(name, {
"year": 2026, "month": 6, "customer": "External", "product": "P1", "staff": "Alice",
})
self.assertEqual(result["template"], name)
self.assertIn("last_sync", result)
def test_sql_keeps_guards_row_limit_and_freshness(self):
result = self.queries.sql("SELECT order_no FROM orders ORDER BY order_no", limit=1)
self.assertEqual(result["rows"], [{"order_no": "SO-1"}])
self.assertTrue(result["is_stale"])
self.assertTrue(result["warnings"])
for sql in ["DELETE FROM orders", "SELECT raw_json FROM orders", "SELECT * FROM orders",
"SELECT * FROM raw_snapshots", "PRAGMA database_list",
"SELECT order_no FROM orders; SELECT 1"]:
with self.subTest(sql=sql), self.assertRaises(RuntimeError):
self.queries.sql(sql)
def test_missing_database_is_not_created(self):
db = self.root / "missing" / "orders.sqlite"
queries = BusinessQueries(db)
self.assertFalse(queries.status()["available"])
with self.assertRaises(RuntimeError):
queries.template("order_count", {"year": 2026, "month": 6})
self.assertFalse(db.parent.exists())
def test_database_path_and_server_filters_cannot_be_overridden_by_client(self):
for params in [{"db_path": "/tmp/other"}, {"internal_parties": ""}, {"internal_party_ids": []}]:
with self.subTest(params=params), self.assertRaises(ValueError):
self.queries.template("order_count", params)
def test_read_only_context_does_not_change_normal_sync(self):
self.queries.status()
facts.sync_kingdee_orders(self.root, [
{"id": "new", "billno": "SO-3", "bizdate": "2026-06-06", "totalallamount": 20},
], db_path=self.db)
self.assertEqual(self.queries.status()["stored_orders"], 3)
def test_business_only_mcp_has_no_wiki_tools_resources_or_job_managers(self):
with (
patch.object(mcp_server, "IngestManager", side_effect=AssertionError("no ingest")),
patch.object(mcp_server, "QueryManager", side_effect=AssertionError("no OpenCode")),
patch.object(mcp_server, "_file_index_capabilities", side_effect=AssertionError("no file index")),
):
server = mcp_server.create_server(
vault_path=self.root / "nonexistent-vault", enable_file_assets=False,
enable_business=True, business_db_path=self.db,
)
names = {tool.name for tool in asyncio.run(server.list_tools())}
self.assertEqual(names, {*BUSINESS_TOOLS, "get_mcp_capabilities"})
self.assertEqual(asyncio.run(server.list_resources()), [])
self.assertEqual(asyncio.run(server.list_resource_templates()), [])
self.assertEqual(server._obsidian_job_managers, ())
async def check():
result = await server.call_tool("get_mcp_capabilities", {})
# FastMCP returns content blocks plus structured content for dict outputs.
capabilities = result[1]
self.assertEqual(capabilities["enabled_tools"], ["business"])
self.assertFalse(capabilities["business"]["requires_opencode"])
self.assertFalse(capabilities["query"]["enabled"])
self.assertFalse(capabilities["ingest"]["enabled"])
self.assertFalse(capabilities["file_assets"]["enabled"])
self.assertFalse(capabilities["safety"]["arbitrary_shell"])
response = await server.call_tool("query_business_template", {
"template_name": "order_count", "params": {"year": 2026, "month": 6},
})
self.assertIn("order_count", response[1]["data"])
asyncio.run(check())
self.assertFalse((self.root / "nonexistent-vault").exists())
def test_business_tools_are_opt_in(self):
server = mcp_server.create_server(vault_path=self.root, enable_file_assets=False)
self.assertEqual({t.name for t in asyncio.run(server.list_tools())}, {"get_mcp_capabilities"})
with patch.dict(os.environ, {"OBSIDIAN_MCP_ENABLED_TOOLS": "business"}):
self.assertEqual(mcp_server.enabled_tool_names(), {"business"})
def test_http_entrypoint_passes_business_group_and_separate_database(self):
with (
patch.dict(os.environ, {"OBSIDIAN_MCP_ENABLED_TOOLS": "business"}),
patch.object(mcp_server, "run_http_server") as run,
):
self.assertEqual(mcp_server.main([
"--vault", str(self.root), "--business-db", str(self.db), "--transport", "streamable-http",
]), 0)
self.assertTrue(run.call_args.kwargs["enable_business"])
self.assertEqual(run.call_args.kwargs["business_db_path"], str(self.db))
for name in ("enable_file_assets", "enable_query", "enable_ingest"):
self.assertFalse(run.call_args.kwargs[name])
+4 -4
View File
@@ -1081,9 +1081,9 @@ class RemoteMcpServerTests(unittest.TestCase):
self.assertEqual(capabilities["server_name"], "wiki-file-assets")
self.assertIn("server_version", capabilities)
self.assertEqual(capabilities["schema_version"], 3)
self.assertEqual(capabilities["capabilities_schema_version"], 3)
self.assertEqual(capabilities["tools_version"], 3)
self.assertEqual(capabilities["schema_version"], 4)
self.assertEqual(capabilities["capabilities_schema_version"], 4)
self.assertEqual(capabilities["tools_version"], 4)
self.assertEqual(capabilities["enabled_tools"], ["file-assets", "ingest", "query"])
self.assertIn("file_index", capabilities)
self.assertIn("record_count", capabilities["file_index"])
@@ -1755,7 +1755,7 @@ class RemoteMcpServerTests(unittest.TestCase):
server = mcp_server.create_server(vault_path=Path(tmp), enable_file_assets=False)
self.assertIsInstance(server, FakeMCP)
self.assertIn("file_index_status", FakeMCP.tool_names)
self.assertNotIn("file_index_status", FakeMCP.tool_names)
self.assertNotIn("search_file_assets", FakeMCP.tool_names)
self.assertNotIn("list_file_assets", FakeMCP.tool_names)
self.assertNotIn("get_file_asset_metadata", FakeMCP.tool_names)
+26
View File
@@ -15,6 +15,32 @@ import manage_mcp_service
class McpServiceConfigTests(unittest.TestCase):
def test_set_business_only_preserves_connection_and_restarts_once_without_opencode(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config_home = root / "config"
env_path = root / ".runtime" / "obsidian-wiki-mcp.env"
manage_mcp_service.write_token_env_file(
env_path, "existing-token", host="0.0.0.0", port=8765,
public_base_url="https://orders.example.test/mcp",
enabled_tools=("file-assets", "ingest", "query"),
)
with (
patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "restart_service_if_installed", return_value=True) as restart,
patch.object(manage_mcp_service, "resolve_opencode_path", side_effect=AssertionError("no OpenCode")),
patch("builtins.print"),
):
result = manage_mcp_service.main(["tools", "set", "business", "--project-root", str(root)])
self.assertEqual(result, 0)
restart.assert_called_once()
config = manage_mcp_service.saved_service_config(root)
self.assertEqual(config.enabled_tools, ("business",))
self.assertEqual(config.token, "existing-token")
self.assertEqual(config.public_base_url, "https://orders.example.test/mcp")
self.assertEqual(config.port, 8765)
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=0", env_path.read_text())
def test_service_commands_delegate_to_unified_user_ops_entrypoint(self):
entrypoint = SCRIPTS_DIR / "mcp_service.sh"
legacy_wrappers = [