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:
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user