From 76a480a0b719d6adb135f7216bb12dacc693b801 Mon Sep 17 00:00:00 2001 From: nac Date: Sun, 5 Jul 2026 11:10:41 +0000 Subject: [PATCH] feat(mcp): serve safe source file assets Add a stdio MCP server for searching and reading safe engineering source files from the wiki file asset index. The implementation uses registry-backed ACL checks, engineering-file whitelisting, asset:// resource URIs, read-only SQLite access, CLI entrypoints, optional MCP packaging, and unittest coverage for search, metadata, resource reads, blocked sources, bot ACLs, and size limits. Validation: python3 -m unittest discover -v; python3 -m py_compile obsidian_wiki/config.py obsidian_wiki/file_assets.py obsidian_wiki/mcp_assets.py obsidian_wiki/mcp_server.py obsidian_wiki/cli.py tests/test_mcp_assets.py; real BJD vault search/read smoke tests. --- .env.example | 9 + README.md | 19 ++ obsidian_wiki/cli.py | 25 +++ obsidian_wiki/config.py | 58 ++++++ obsidian_wiki/file_assets.py | 34 +++- obsidian_wiki/mcp_assets.py | 379 +++++++++++++++++++++++++++++++++++ obsidian_wiki/mcp_server.py | 178 ++++++++++++++++ pyproject.toml | 4 + tests/__init__.py | 1 + tests/test_mcp_assets.py | 191 ++++++++++++++++++ 10 files changed, 896 insertions(+), 2 deletions(-) create mode 100644 obsidian_wiki/config.py create mode 100644 obsidian_wiki/mcp_assets.py create mode 100644 obsidian_wiki/mcp_server.py create mode 100644 tests/__init__.py create mode 100644 tests/test_mcp_assets.py diff --git a/.env.example b/.env.example index d1a6a9d..d1e86d2 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,15 @@ OBSIDIAN_SOURCE_CATALOG_DIR=_sources/catalog # SQLite source-file locator. Leave empty to use _meta/file-assets.sqlite. OBSIDIAN_FILE_ASSET_DB= +# --- Source File MCP Server (optional) --- +# +# Run with: obsidian-wiki-mcp +# Install extra: pip install "obsidian-wiki[mcp]" +# The MCP server serves only safe engineering source files by default. +OBSIDIAN_MCP_BOT_PROFILE=bjd-general-bot +OBSIDIAN_MCP_MAX_FILE_BYTES=26214400 +OBSIDIAN_MCP_EXPOSE_PATHS=false + # Wiki categories (directories created in the vault) OBSIDIAN_CATEGORIES=concepts,entities,skills,references,synthesis,journal diff --git a/README.md b/README.md index ebd4045..57bf771 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,25 @@ obsidian-wiki file-index stage ./drawing.step --project bjd --doc-type drawing Staging is for upload/intake only. The normal ingest flow should still classify the file, archive it under `_sources/archive/`, update source registry/catalog pages, and rebuild the file index. +### Optional: Source File MCP Server + +Agents that support MCP can ask for original engineering files through a local stdio server: + +```bash +pip install "obsidian-wiki[mcp]" +obsidian-wiki file-index rebuild +obsidian-wiki-mcp +``` + +Configure your agent to run `obsidian-wiki-mcp` as a stdio MCP server. The server exposes: + +- `search_file_assets` — finds safe source files by product code, series, customer, doc type, or file type. +- `get_file_asset_metadata` — returns one file's safe metadata and `asset://source/sha256/...` URI. +- `file_index_status` — shows vault/index/profile status. +- `asset://source/sha256/{digest}` — binary resource for an allowed archived file. + +By default the MCP layer is stricter than local CLI lookup: it only serves engineering files such as drawings, 3D/STEP models, specifications, product parameters, and test reports. It blocks raw orders, prices, email correspondence, complaint evidence, claims, personal data, and files missing from `_meta/source-registry.jsonl`. Use `OBSIDIAN_MCP_BOT_PROFILE`, `OBSIDIAN_MCP_MAX_FILE_BYTES`, and `OBSIDIAN_MCP_EXPOSE_PATHS` to tune the runtime behavior. + ### `_raw/` Staging Directory `_raw/` is a staging area inside your vault for unprocessed captures — rough notes, clipboard pastes, quick voice-memo transcripts. Drop files there and the next `wiki-ingest` run will promote them to proper wiki pages and remove the originals. diff --git a/obsidian_wiki/cli.py b/obsidian_wiki/cli.py index db46954..e50a175 100644 --- a/obsidian_wiki/cli.py +++ b/obsidian_wiki/cli.py @@ -453,6 +453,7 @@ def cmd_file_index_search(args: argparse.Namespace) -> int: competitor=args.competitor, status=args.status, include_staged=not args.active_only, + read_only=True, ) if args.json: print(json.dumps(rows, ensure_ascii=False, indent=2)) @@ -479,6 +480,23 @@ def cmd_file_index_stage(args: argparse.Namespace) -> int: return 0 +def cmd_mcp_server(args: argparse.Namespace) -> int: + from obsidian_wiki.mcp_server import main as mcp_main + + argv: list[str] = [] + if args.vault: + argv.extend(["--vault", args.vault]) + if args.db: + argv.extend(["--db", args.db]) + if args.profile: + argv.extend(["--profile", args.profile]) + if args.max_file_bytes is not None: + argv.extend(["--max-file-bytes", str(args.max_file_bytes)]) + if args.expose_paths: + argv.append("--expose-paths") + return mcp_main(argv) + + def cmd_bot_view_list(args: argparse.Namespace) -> int: vault_path = _require_vault(args) for name in list_profiles(vault_path): @@ -570,6 +588,13 @@ def build_parser() -> argparse.ArgumentParser: ftp.add_argument("--visibility", default="internal", help="initial visibility") ftp.set_defaults(func=cmd_file_index_stage) + mp = sub.add_parser("mcp-server", help="run the stdio MCP server for safe source-file lookup") + _add_file_index_storage_args(mp) + mp.add_argument("--profile", help="bot profile name for ACL checks") + mp.add_argument("--max-file-bytes", type=int, help="maximum resource size, 0 disables the limit") + mp.add_argument("--expose-paths", action="store_true", help="include archive/absolute paths in tool output") + mp.set_defaults(func=cmd_mcp_server) + bp = sub.add_parser("bot-view", help="derive isolated chatbot views from the mother vault") bot_sub = bp.add_subparsers(dest="bot_view_command") diff --git a/obsidian_wiki/config.py b/obsidian_wiki/config.py new file mode 100644 index 0000000..812772a --- /dev/null +++ b/obsidian_wiki/config.py @@ -0,0 +1,58 @@ +"""Configuration resolution shared by CLI and MCP entrypoints.""" + +from __future__ import annotations + +from pathlib import Path + +from obsidian_wiki.file_assets import clean_config_value + +GLOBAL_CONFIG = Path.home() / ".obsidian-wiki" / "config" + + +def parse_config_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + if not path.is_file(): + return values + for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = clean_config_value(value) + return values + + +def find_config(start: str | Path | None = None) -> Path | None: + """Resolve config using the llm-wiki protocol.""" + current = Path(start or Path.cwd()).expanduser().resolve() + if current.is_file(): + current = current.parent + home = Path.home().resolve() + + while True: + candidate = current / ".env" + if candidate.is_file() and parse_config_file(candidate).get("OBSIDIAN_VAULT_PATH"): + return candidate + if current == home or current.parent == current: + break + current = current.parent + + if GLOBAL_CONFIG.is_file(): + return GLOBAL_CONFIG + return None + + +def load_config(start: str | Path | None = None) -> dict[str, str]: + path = find_config(start) + return parse_config_file(path) if path else {} + + +def resolve_vault_path(cli_vault: str | None = None, *, start: str | Path | None = None) -> str: + if cli_vault: + return str(Path(clean_config_value(cli_vault)).expanduser()) + config = load_config(start) + return config.get("OBSIDIAN_VAULT_PATH", "") + + +def config_value(key: str, default: str = "", *, start: str | Path | None = None) -> str: + return load_config(start).get(key, default) diff --git a/obsidian_wiki/file_assets.py b/obsidian_wiki/file_assets.py index 8e1a1f2..cfeaf4d 100644 --- a/obsidian_wiki/file_assets.py +++ b/obsidian_wiki/file_assets.py @@ -11,6 +11,7 @@ from __future__ import annotations import hashlib import json import mimetypes +import os import re import shutil import sqlite3 @@ -59,7 +60,16 @@ def registry_path_for_vault(vault_path: str | Path) -> Path: return vault / SOURCE_REGISTRY_RELATIVE_PATH -def connect(db_path: Path) -> sqlite3.Connection: +def connect(db_path: Path, *, read_only: bool = False) -> sqlite3.Connection: + if read_only: + if not db_path.is_file(): + raise FileNotFoundError(f"file asset index not found: {db_path}") + immutable = "&immutable=1" if not os.access(db_path.parent, os.W_OK) else "" + uri = f"{db_path.resolve().as_uri()}?mode=ro{immutable}" + conn = sqlite3.connect(uri, uri=True) + conn.row_factory = sqlite3.Row + return conn + db_path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(str(db_path)) conn.row_factory = sqlite3.Row @@ -458,6 +468,7 @@ def search_assets( competitor: str | None = None, status: str | None = None, include_staged: bool = True, + read_only: bool = False, ) -> list[dict[str, Any]]: db_path = db_path or db_path_for_vault(vault_path) if not db_path.is_file(): @@ -479,7 +490,7 @@ def search_assets( where = f"WHERE {' AND '.join(filters)}" if filters else "" rows: list[dict[str, Any]] = [] - with connect(db_path) as conn: + with connect(db_path, read_only=read_only) as conn: for row in conn.execute(f"SELECT * FROM file_assets {where}", params).fetchall(): record = dict(row) if product_line and product_line.lower() not in _searchable(record.get("product_lines")): @@ -499,6 +510,25 @@ def search_assets( return rows[:limit] +def get_asset_by_file_id( + vault_path: str | Path, + file_id: str, + *, + db_path: Path | None = None, + read_only: bool = False, +) -> dict[str, Any] | None: + db_path = db_path or db_path_for_vault(vault_path) + if not db_path.is_file(): + raise FileNotFoundError(f"file asset index not found: {db_path}") + with connect(db_path, read_only=read_only) as conn: + row = conn.execute("SELECT * FROM file_assets WHERE file_id = ?", (file_id,)).fetchone() + if row is None: + return None + record = dict(row) + record["absolute_path"] = absolute_asset_path(vault_path, record["archive_path"]) + return record + + def score_asset(record: dict[str, Any], query: str) -> int: query = query.strip() if not query: diff --git a/obsidian_wiki/mcp_assets.py b/obsidian_wiki/mcp_assets.py new file mode 100644 index 0000000..8ab8284 --- /dev/null +++ b/obsidian_wiki/mcp_assets.py @@ -0,0 +1,379 @@ +"""MCP-facing source file lookup and access control helpers.""" + +from __future__ import annotations + +import json +import mimetypes +import os +import re +from pathlib import Path +from typing import Any + +from obsidian_wiki.access_control import as_list, load_profiles, source_decision +from obsidian_wiki.config import config_value +from obsidian_wiki.file_assets import ( + absolute_asset_path, + clean_config_value, + db_path_for_vault, + get_asset_by_file_id, + registry_path_for_vault, + search_assets, +) + +DEFAULT_PROFILE_NAME = "bjd-general-bot" +DEFAULT_MAX_FILE_BYTES = 25 * 1024 * 1024 + +ALLOWED_ENGINEERING_DOC_TYPES = { + "drawing", + "customer_3d_model", + "test_report", + "product_parameter", + "customer_specification", + "product_specification", +} + +BLOCKED_DOC_TYPES = { + "order_data", + "customer_product_price", + "customer_order_detail", + "delivery_detail", + "email_correspondence", + "evidence_media", + "claim_record", + "customer_complaint_source", + "customer_complaint_report", + "inspection_control", + "personal_document", + "identity_document", +} + +BLOCKED_CATEGORY_PREFIXES = ( + "orders", + "orders/", + "customer-pricing", + "customer-order-detail", + "pricing-detail", + "quality/customer-complaints", + "quality/claims", + "claims", + "emails", + "email", + "pii", + "personal", +) + +SAFE_RESULT_FIELDS = ( + "file_id", + "resource_uri", + "score", + "original_name", + "title", + "summary", + "doc_type", + "source_type", + "mime_type", + "size_bytes", + "project", + "visibility", + "data_classification", + "product_lines", + "customers", + "competitors", + "standards", + "business_categories", + "series", + "pitch", + "pin_count", + "related_pages", + "classification_confidence", + "updated_at", +) + +ASSET_URI_RE = re.compile(r"^asset://source/sha256/([0-9a-fA-F]{64})$") + + +class AssetAccessError(RuntimeError): + """Raised when ACL denies access to a file asset.""" + + +class AssetNotFoundError(FileNotFoundError): + """Raised when an indexed asset or archived file is missing.""" + + +class AssetTooLargeError(RuntimeError): + """Raised when a resource exceeds the configured read limit.""" + + +def asset_uri_for_file_id(file_id: str) -> str: + digest = file_id.removeprefix("sha256:") + if not re.fullmatch(r"[0-9a-fA-F]{64}", digest): + raise ValueError(f"unsupported file_id for MCP asset URI: {file_id}") + return f"asset://source/sha256/{digest.lower()}" + + +def file_id_from_asset_uri(uri: str) -> str: + match = ASSET_URI_RE.match(uri) + if not match: + raise ValueError(f"unsupported asset URI: {uri}") + return f"sha256:{match.group(1).lower()}" + + +def search_file_assets( + vault_path: str | Path, + query: str, + *, + db_path: Path | None = None, + limit: int = 10, + doc_type: str | None = None, + source_type: str | None = None, + product_line: str | None = None, + customer: str | None = None, + competitor: str | None = None, + profile_name: str = DEFAULT_PROFILE_NAME, + expose_paths: bool | None = None, +) -> list[dict[str, Any]]: + vault = _vault_path(vault_path) + registry = load_registry_by_file_id(vault) + profile = _load_profile(vault, profile_name) + rows = search_assets( + vault, + query, + db_path=db_path, + limit=max(limit * 3, limit), + doc_type=doc_type, + source_type=source_type, + product_line=product_line, + customer=customer, + competitor=competitor, + include_staged=False, + read_only=True, + ) + + allowed: list[dict[str, Any]] = [] + for row in rows: + record = registry.get(str(row.get("file_id") or "")) + if record is None: + continue + ok, _reason = record_allowed_for_mcp(record, profile_name=profile_name, profile=profile) + if not ok: + continue + allowed.append(public_asset_record({**record, **row}, expose_paths=expose_paths)) + if len(allowed) >= limit: + break + return allowed + + +def get_file_asset_metadata( + vault_path: str | Path, + file_id: str, + *, + db_path: Path | None = None, + profile_name: str = DEFAULT_PROFILE_NAME, + expose_paths: bool | None = None, +) -> dict[str, Any]: + vault = _vault_path(vault_path) + row = get_asset_by_file_id(vault, file_id, db_path=db_path, read_only=True) + if row is None: + raise AssetNotFoundError(f"file asset not found: {file_id}") + registry_record = load_registry_by_file_id(vault).get(file_id) + if registry_record is None: + raise AssetAccessError(f"file asset is not present in source registry: {file_id}") + ok, reason = record_allowed_for_mcp( + registry_record, + profile_name=profile_name, + profile=_load_profile(vault, profile_name), + ) + if not ok: + raise AssetAccessError(f"file asset blocked: {reason}") + return public_asset_record({**registry_record, **row}, expose_paths=expose_paths) + + +def read_file_asset_resource( + vault_path: str | Path, + uri: str, + *, + db_path: Path | None = None, + profile_name: str = DEFAULT_PROFILE_NAME, + max_file_bytes: int | None = None, +) -> dict[str, Any]: + file_id = file_id_from_asset_uri(uri) + vault = _vault_path(vault_path) + row = get_asset_by_file_id(vault, file_id, db_path=db_path, read_only=True) + if row is None: + raise AssetNotFoundError(f"file asset not found: {file_id}") + + registry_record = load_registry_by_file_id(vault).get(file_id) + if registry_record is None: + raise AssetAccessError(f"file asset is not present in source registry: {file_id}") + ok, reason = record_allowed_for_mcp( + registry_record, + profile_name=profile_name, + profile=_load_profile(vault, profile_name), + ) + if not ok: + raise AssetAccessError(f"file asset blocked: {reason}") + + path = safe_asset_path(vault, str(row.get("archive_path") or "")) + if not path.is_file(): + raise AssetNotFoundError(f"archived file missing: {row.get('archive_path')}") + limit = DEFAULT_MAX_FILE_BYTES if max_file_bytes is None else max_file_bytes + size = path.stat().st_size + if limit > 0 and size > limit: + raise AssetTooLargeError(f"file exceeds max size: {size} > {limit}") + name = str(row.get("original_name") or path.name) + return { + "file_id": file_id, + "resource_uri": uri, + "name": name, + "mime_type": str(row.get("mime_type") or mimetypes.guess_type(name)[0] or "application/octet-stream"), + "size_bytes": size, + "bytes": path.read_bytes(), + } + + +def public_asset_record(record: dict[str, Any], *, expose_paths: bool | None = None) -> dict[str, Any]: + file_id = str(record.get("file_id") or record.get("source_id") or "") + data: dict[str, Any] = {} + merged = { + **record, + "file_id": file_id, + "resource_uri": asset_uri_for_file_id(file_id), + "data_classification": record.get("data_classification") + or _infer_data_classification_label(record), + } + for field in SAFE_RESULT_FIELDS: + if field not in merged: + continue + value = _jsonish(merged[field]) + if value not in (None, "", []): + data[field] = value + if expose_paths if expose_paths is not None else _env_flag("OBSIDIAN_MCP_EXPOSE_PATHS", False): + if record.get("archive_path"): + data["archive_path"] = record["archive_path"] + if record.get("absolute_path"): + data["absolute_path"] = record["absolute_path"] + return data + + +def record_allowed_for_mcp( + record: dict[str, Any], + *, + profile_name: str = DEFAULT_PROFILE_NAME, + profile: dict[str, Any] | None = None, +) -> tuple[bool, str]: + doc_type = str(record.get("doc_type") or "other") + visibility = str(record.get("visibility") or "internal").removeprefix("visibility/") + categories = [item.lower() for item in as_list(record.get("business_categories"))] + allowed_bots = as_list(record.get("allowed_bots")) + excluded_bots = as_list(record.get("excluded_bots")) + if profile_name in excluded_bots or "*" in excluded_bots: + return False, "explicitly excluded" + if allowed_bots and profile_name not in allowed_bots and "*" not in allowed_bots: + return False, "not in explicit allowed_bots" + if visibility == "pii": + return False, "visibility pii blocked" + if doc_type in BLOCKED_DOC_TYPES: + return False, f"doc_type {doc_type} blocked" + if doc_type not in ALLOWED_ENGINEERING_DOC_TYPES: + return False, f"doc_type {doc_type} is not in engineering file whitelist" + for category in categories: + if any(category == prefix or category.startswith(prefix + "/") for prefix in BLOCKED_CATEGORY_PREFIXES): + return False, f"business category {category} blocked" + + if profile is None: + return True, "allowed" + decision = source_decision(profile_name, {**profile, "include_source_files": True}, record) + if not decision.include: + return False, decision.reason + return True, "allowed" + + +def load_registry_by_file_id(vault_path: str | Path) -> dict[str, dict[str, Any]]: + registry_path = registry_path_for_vault(vault_path) + if not registry_path.is_file(): + return {} + records: dict[str, dict[str, Any]] = {} + for raw in registry_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + if not raw.strip(): + continue + try: + record = json.loads(raw) + except json.JSONDecodeError: + continue + if record.get("record_type") != "source_archive": + continue + file_id = str(record.get("source_id") or "") + if file_id: + records[file_id] = record + return records + + +def safe_asset_path(vault_path: str | Path, archive_path: str) -> Path: + vault = _vault_path(vault_path).resolve() + path = Path(absolute_asset_path(vault, archive_path)).resolve() + if path != vault and os.path.commonpath([str(vault), str(path)]) != str(vault): + raise AssetAccessError(f"asset path escapes vault: {archive_path}") + return path + + +def mcp_runtime_options( + *, + profile_name: str | None = None, + max_file_bytes: int | None = None, + expose_paths: bool | None = None, +) -> dict[str, Any]: + return { + "profile_name": profile_name or config_value("OBSIDIAN_MCP_BOT_PROFILE", DEFAULT_PROFILE_NAME), + "max_file_bytes": _env_int("OBSIDIAN_MCP_MAX_FILE_BYTES", DEFAULT_MAX_FILE_BYTES) + if max_file_bytes is None + else max_file_bytes, + "expose_paths": _env_flag("OBSIDIAN_MCP_EXPOSE_PATHS", False) if expose_paths is None else expose_paths, + } + + +def _load_profile(vault: Path, profile_name: str) -> dict[str, Any] | None: + try: + return load_profiles(vault).get(profile_name) + except FileNotFoundError: + return None + + +def _vault_path(vault_path: str | Path) -> Path: + return Path(clean_config_value(str(vault_path))).expanduser() + + +def _jsonish(value: Any) -> Any: + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + return value + return value + + +def _infer_data_classification_label(record: dict[str, Any]) -> str: + if str(record.get("visibility") or "").removeprefix("visibility/") == "pii": + return "personal-sensitive" + doc_type = str(record.get("doc_type") or "") + if doc_type in {"drawing", "customer_3d_model", "customer_specification"}: + return "technical-confidential" + if doc_type == "test_report": + return "internal" + return "internal" + + +def _env_flag(key: str, default: bool) -> bool: + raw = os.environ.get(key) or config_value(key) + if raw == "": + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _env_int(key: str, default: int) -> int: + raw = os.environ.get(key) or config_value(key) + if raw == "": + return default + try: + return int(raw) + except ValueError: + return default diff --git a/obsidian_wiki/mcp_server.py b/obsidian_wiki/mcp_server.py new file mode 100644 index 0000000..f42a1cb --- /dev/null +++ b/obsidian_wiki/mcp_server.py @@ -0,0 +1,178 @@ +"""stdio MCP server exposing safe wiki source-file resources.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Any + +from obsidian_wiki.config import resolve_vault_path +from obsidian_wiki.file_assets import clean_config_value, db_path_for_vault, registry_path_for_vault +from obsidian_wiki.mcp_assets import ( + DEFAULT_MAX_FILE_BYTES, + DEFAULT_PROFILE_NAME, + AssetAccessError, + AssetNotFoundError, + AssetTooLargeError, + get_file_asset_metadata as core_get_file_asset_metadata, + load_registry_by_file_id, + mcp_runtime_options, + read_file_asset_resource, + search_file_assets as core_search_file_assets, +) + + +def create_server( + *, + vault_path: str | Path, + db_path: str | Path | None = None, + profile_name: str = DEFAULT_PROFILE_NAME, + max_file_bytes: int = DEFAULT_MAX_FILE_BYTES, + expose_paths: bool = False, +): + if sys.version_info < (3, 10): + raise RuntimeError("obsidian-wiki MCP server requires Python 3.10 or newer") + try: + from mcp.server.fastmcp import FastMCP + except ImportError as exc: + raise RuntimeError('MCP support is not installed. Run: pip install "obsidian-wiki[mcp]"') from exc + + vault = Path(clean_config_value(str(vault_path))).expanduser() + db = _resolve_db_path(vault, db_path) + server = FastMCP("obsidian-wiki") + + @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) + return { + "vault_path": str(vault), + "db_path": str(db), + "db_exists": db.is_file(), + "registry_path": str(registry), + "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, + } + + @server.tool() + def search_file_assets( + query: str, + doc_type: str | None = None, + source_type: str | None = None, + product_line: str | None = None, + customer: str | None = None, + competitor: str | None = None, + limit: int = 10, + ) -> list[dict[str, Any]]: + """Search safe engineering source files such as drawings, 3D models, specs, and test reports.""" + return _as_mcp_results( + core_search_file_assets( + vault, + query, + db_path=db, + limit=limit, + doc_type=doc_type, + source_type=source_type, + product_line=product_line, + customer=customer, + competitor=competitor, + profile_name=profile_name, + expose_paths=expose_paths, + ) + ) + + @server.tool() + def get_file_asset_metadata(file_id: str) -> dict[str, Any]: + """Return safe metadata and the resource URI for one indexed source file.""" + return _as_mcp_result( + core_get_file_asset_metadata( + vault, + file_id, + db_path=db, + profile_name=profile_name, + expose_paths=expose_paths, + ) + ) + + @server.resource("asset://source/sha256/{digest}") + def read_file_asset(digest: str) -> bytes: + """Read one allowed source file as a binary MCP resource.""" + resource = read_file_asset_resource( + vault, + f"asset://source/sha256/{digest}", + db_path=db, + profile_name=profile_name, + max_file_bytes=max_file_bytes, + ) + return resource["bytes"] + + return server + + +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("--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("--expose-paths", action="store_true", help="include archive/absolute paths in tool output") + args = parser.parse_args(argv) + + vault = resolve_vault_path(args.vault) + if not vault: + print("error: vault path is not configured; run `obsidian-wiki setup --vault /path/to/vault`", file=sys.stderr) + return 1 + options = mcp_runtime_options( + profile_name=args.profile, + max_file_bytes=args.max_file_bytes, + expose_paths=args.expose_paths, + ) + try: + server = create_server( + vault_path=vault, + db_path=args.db, + profile_name=options["profile_name"], + max_file_bytes=options["max_file_bytes"], + expose_paths=options["expose_paths"], + ) + try: + server.run(transport="stdio") + except TypeError: + server.run() + except (RuntimeError, FileNotFoundError, AssetAccessError, AssetNotFoundError, AssetTooLargeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +def _resolve_db_path(vault: Path, raw_path: str | Path | None) -> Path: + if raw_path is None: + return db_path_for_vault(vault) + db_path = Path(clean_config_value(str(raw_path))).expanduser() + if not db_path.is_absolute(): + db_path = vault / db_path + return db_path + + +def _as_mcp_results(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [_as_mcp_result(row) for row in rows] + + +def _as_mcp_result(row: dict[str, Any]) -> dict[str, Any]: + resource_uri = str(row.get("resource_uri") or "") + return { + **row, + "resource_link": { + "uri": resource_uri, + "name": row.get("original_name") or row.get("title") or resource_uri, + "mime_type": row.get("mime_type") or "application/octet-stream", + }, + } + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index f37f4b1..491591b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ classifiers = [ # Pure-stdlib CLI — installing skills needs no third-party runtime dependencies. dependencies = [] +[project.optional-dependencies] +mcp = ["mcp>=1.28,<2"] + [project.urls] Homepage = "https://github.com/Ar9av/obsidian-wiki" Repository = "https://github.com/Ar9av/obsidian-wiki" @@ -40,6 +43,7 @@ Issues = "https://github.com/Ar9av/obsidian-wiki/issues" [project.scripts] obsidian-wiki = "obsidian_wiki.cli:main" +obsidian-wiki-mcp = "obsidian_wiki.mcp_server:main" # Version is derived from the git tag (CalVer, e.g. v2026.05.2 -> 2026.5.2), # matching the repo's release tags. Tagging a release is the version bump. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..926317b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for obsidian-wiki.""" diff --git a/tests/test_mcp_assets.py b/tests/test_mcp_assets.py new file mode 100644 index 0000000..00cbb03 --- /dev/null +++ b/tests/test_mcp_assets.py @@ -0,0 +1,191 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from obsidian_wiki.file_assets import connect, init_db, upsert_asset +from obsidian_wiki.mcp_assets import ( + AssetAccessError, + AssetTooLargeError, + asset_uri_for_file_id, + file_id_from_asset_uri, + get_file_asset_metadata, + read_file_asset_resource, + search_file_assets, +) + + +class McpAssetTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.vault = Path(self.tempdir.name) + (self.vault / "_meta").mkdir() + self.db_path = self.vault / "_meta" / "file-assets.sqlite" + self.registry_path = self.vault / "_meta" / "source-registry.jsonl" + init_db(self.db_path) + + self.drawing_id = "sha256:" + "a" * 64 + self.order_id = "sha256:" + "b" * 64 + self.complaint_id = "sha256:" + "c" * 64 + self._write_asset_file("drawings/a.pdf", b"%PDF allowed drawing") + self._write_asset_file("orders/order.xlsx", b"customer price data") + self._write_asset_file("quality/photo.png", b"complaint evidence") + + self._insert_asset( + self.drawing_id, + archive_path="_sources/archive/drawings/a.pdf", + original_name="B3501_11101c_X2.pdf", + doc_type="drawing", + source_type="pdf", + product_lines=["BTB"], + business_categories=["products/btb", "drawings/customer"], + data_classification="technical-confidential", + visibility="internal", + summary="B3501 customer drawing.", + ) + self._insert_asset( + self.order_id, + archive_path="_sources/archive/orders/order.xlsx", + original_name="订单项.xlsx", + doc_type="order_data", + source_type="spreadsheet", + business_categories=["orders/raw-excel"], + data_classification="commercial-confidential", + visibility="pii", + summary="Raw customer order lines.", + ) + self._insert_asset( + self.complaint_id, + archive_path="_sources/archive/quality/photo.png", + original_name="客户反馈来料标签错误图片.png", + doc_type="evidence_media", + source_type="image", + business_categories=["quality/customer-complaints"], + data_classification="quality-confidential", + visibility="internal", + summary="Raw complaint evidence image.", + ) + + def tearDown(self): + self.tempdir.cleanup() + + def _write_asset_file(self, rel_path, data): + path = self.vault / "_sources" / "archive" / rel_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + def _insert_asset(self, file_id, **fields): + record = { + "record_type": "source_archive", + "source_id": file_id, + "project": "bjd", + "title": Path(fields["original_name"]).stem, + "size_bytes": len((self.vault / fields["archive_path"]).read_bytes()), + "related_pages": ["projects/bjd/references/example.md"], + "allowed_bots": ["bjd-general-bot"], + **fields, + } + with self.registry_path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(record, ensure_ascii=False) + "\n") + asset = { + "file_id": file_id, + "sha256": file_id.removeprefix("sha256:"), + "archive_path": fields["archive_path"], + "original_name": fields["original_name"], + "file_ext": Path(fields["original_name"]).suffix.lower(), + "mime_type": "application/pdf", + "source_type": fields["source_type"], + "doc_type": fields["doc_type"], + "project": "bjd", + "title": record["title"], + "summary": fields["summary"], + "visibility": fields["visibility"], + "business_categories": json.dumps(fields.get("business_categories", []), ensure_ascii=False), + "product_lines": json.dumps(fields.get("product_lines", []), ensure_ascii=False), + "customers": "[]", + "competitors": "[]", + "standards": "[]", + "market_tags": "[]", + "bjd_part_numbers": "[]", + "competitor_part_numbers": "[]", + "series": "[]", + "pitch": "[]", + "pin_count": "[]", + "related_pages": json.dumps(record["related_pages"], ensure_ascii=False), + "catalog_paths": "[]", + "extracted_text": None, + "classification_confidence": 0.9, + "classification_evidence": "[]", + "needs_review": 0, + "size_bytes": record["size_bytes"], + "modified_at": None, + "archived_at": "2026-07-05T00:00:00Z", + } + with connect(self.db_path) as conn: + upsert_asset(conn, asset, status="active") + + def test_search_returns_allowed_engineering_assets_only(self): + rows = search_file_assets(self.vault, "B3501 drawing", db_path=self.db_path) + + self.assertEqual([row["file_id"] for row in rows], [self.drawing_id]) + self.assertEqual(rows[0]["resource_uri"], asset_uri_for_file_id(self.drawing_id)) + self.assertNotIn("absolute_path", rows[0]) + + def test_search_blocks_sensitive_order_and_complaint_sources(self): + self.assertEqual(search_file_assets(self.vault, "订单", db_path=self.db_path), []) + self.assertEqual(search_file_assets(self.vault, "来料标签错误", db_path=self.db_path), []) + + def test_asset_uri_round_trips_file_id(self): + uri = asset_uri_for_file_id(self.drawing_id) + + self.assertEqual(uri, "asset://source/sha256/" + "a" * 64) + self.assertEqual(file_id_from_asset_uri(uri), self.drawing_id) + + def test_resource_read_rechecks_acl_and_returns_file_bytes(self): + data = read_file_asset_resource( + self.vault, + asset_uri_for_file_id(self.drawing_id), + db_path=self.db_path, + ) + + self.assertEqual(data["name"], "B3501_11101c_X2.pdf") + self.assertEqual(data["mime_type"], "application/pdf") + self.assertEqual(data["bytes"], b"%PDF allowed drawing") + + def test_resource_read_rejects_blocked_asset_even_by_direct_uri(self): + with self.assertRaises(AssetAccessError): + read_file_asset_resource( + self.vault, + asset_uri_for_file_id(self.order_id), + db_path=self.db_path, + ) + + def test_resource_read_enforces_max_size(self): + with self.assertRaises(AssetTooLargeError): + read_file_asset_resource( + self.vault, + asset_uri_for_file_id(self.drawing_id), + db_path=self.db_path, + max_file_bytes=5, + ) + + def test_metadata_uses_safe_fields(self): + metadata = get_file_asset_metadata(self.vault, self.drawing_id, db_path=self.db_path) + + self.assertEqual(metadata["file_id"], self.drawing_id) + self.assertEqual(metadata["doc_type"], "drawing") + self.assertEqual(metadata["resource_uri"], asset_uri_for_file_id(self.drawing_id)) + self.assertNotIn("absolute_path", metadata) + + def test_registry_allowed_bots_are_enforced_without_profile_file(self): + with self.assertRaises(AssetAccessError): + get_file_asset_metadata( + self.vault, + self.drawing_id, + db_path=self.db_path, + profile_name="public-product-bot", + ) + + +if __name__ == "__main__": + unittest.main()