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
+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 = [