diff --git a/.env.example b/.env.example index d01337c..56d6d7f 100644 --- a/.env.example +++ b/.env.example @@ -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 /_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= diff --git a/AGENTS.md b/AGENTS.md index 32f6f37..86c7d5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/business-facts.md b/docs/business-facts.md index e12b7b1..d7e28c7 100644 --- a/docs/business-facts.md +++ b/docs/business-facts.md @@ -14,6 +14,56 @@ Use `/_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 +`/_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 diff --git a/obsidian_wiki/business_facts.py b/obsidian_wiki/business_facts.py index c1a3273..7d3116c 100644 --- a/obsidian_wiki/business_facts.py +++ b/obsidian_wiki/business_facts.py @@ -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( """ diff --git a/obsidian_wiki/mcp_business.py b/obsidian_wiki/mcp_business.py new file mode 100644 index 0000000..4fa9282 --- /dev/null +++ b/obsidian_wiki/mcp_business.py @@ -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() diff --git a/obsidian_wiki/mcp_server.py b/obsidian_wiki/mcp_server.py index bdff055..1756f4d 100644 --- a/obsidian_wiki/mcp_server.py +++ b/obsidian_wiki/mcp_server.py @@ -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 diff --git a/scripts/manage_mcp_service.py b/scripts/manage_mcp_service.py index 0a9bcb7..622dbae 100755 --- a/scripts/manage_mcp_service.py +++ b/scripts/manage_mcp_service.py @@ -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 diff --git a/tests/test_mcp_business.py b/tests/test_mcp_business.py new file mode 100644 index 0000000..ae47bf0 --- /dev/null +++ b/tests/test_mcp_business.py @@ -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]) diff --git a/tests/test_mcp_server_remote.py b/tests/test_mcp_server_remote.py index 52e7366..e91e7c3 100644 --- a/tests/test_mcp_server_remote.py +++ b/tests/test_mcp_server_remote.py @@ -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) diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index 160316b..1f5a6de 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -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 = [