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.
This commit is contained in:
nac
2026-07-05 11:10:41 +00:00
parent 777607dc29
commit 76a480a0b7
10 changed files with 896 additions and 2 deletions
+9
View File
@@ -28,6 +28,15 @@ OBSIDIAN_SOURCE_CATALOG_DIR=_sources/catalog
# SQLite source-file locator. Leave empty to use _meta/file-assets.sqlite. # SQLite source-file locator. Leave empty to use _meta/file-assets.sqlite.
OBSIDIAN_FILE_ASSET_DB= 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) # Wiki categories (directories created in the vault)
OBSIDIAN_CATEGORIES=concepts,entities,skills,references,synthesis,journal OBSIDIAN_CATEGORIES=concepts,entities,skills,references,synthesis,journal
+19
View File
@@ -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. 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/` 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. `_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.
+25
View File
@@ -453,6 +453,7 @@ def cmd_file_index_search(args: argparse.Namespace) -> int:
competitor=args.competitor, competitor=args.competitor,
status=args.status, status=args.status,
include_staged=not args.active_only, include_staged=not args.active_only,
read_only=True,
) )
if args.json: if args.json:
print(json.dumps(rows, ensure_ascii=False, indent=2)) print(json.dumps(rows, ensure_ascii=False, indent=2))
@@ -479,6 +480,23 @@ def cmd_file_index_stage(args: argparse.Namespace) -> int:
return 0 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: def cmd_bot_view_list(args: argparse.Namespace) -> int:
vault_path = _require_vault(args) vault_path = _require_vault(args)
for name in list_profiles(vault_path): 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.add_argument("--visibility", default="internal", help="initial visibility")
ftp.set_defaults(func=cmd_file_index_stage) 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") bp = sub.add_parser("bot-view", help="derive isolated chatbot views from the mother vault")
bot_sub = bp.add_subparsers(dest="bot_view_command") bot_sub = bp.add_subparsers(dest="bot_view_command")
+58
View File
@@ -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)
+32 -2
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import mimetypes import mimetypes
import os
import re import re
import shutil import shutil
import sqlite3 import sqlite3
@@ -59,7 +60,16 @@ def registry_path_for_vault(vault_path: str | Path) -> Path:
return vault / SOURCE_REGISTRY_RELATIVE_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) db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@@ -458,6 +468,7 @@ def search_assets(
competitor: str | None = None, competitor: str | None = None,
status: str | None = None, status: str | None = None,
include_staged: bool = True, include_staged: bool = True,
read_only: bool = False,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
db_path = db_path or db_path_for_vault(vault_path) db_path = db_path or db_path_for_vault(vault_path)
if not db_path.is_file(): if not db_path.is_file():
@@ -479,7 +490,7 @@ def search_assets(
where = f"WHERE {' AND '.join(filters)}" if filters else "" where = f"WHERE {' AND '.join(filters)}" if filters else ""
rows: list[dict[str, Any]] = [] 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(): for row in conn.execute(f"SELECT * FROM file_assets {where}", params).fetchall():
record = dict(row) record = dict(row)
if product_line and product_line.lower() not in _searchable(record.get("product_lines")): if product_line and product_line.lower() not in _searchable(record.get("product_lines")):
@@ -499,6 +510,25 @@ def search_assets(
return rows[:limit] 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: def score_asset(record: dict[str, Any], query: str) -> int:
query = query.strip() query = query.strip()
if not query: if not query:
+379
View File
@@ -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
+178
View File
@@ -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())
+4
View File
@@ -33,6 +33,9 @@ classifiers = [
# Pure-stdlib CLI — installing skills needs no third-party runtime dependencies. # Pure-stdlib CLI — installing skills needs no third-party runtime dependencies.
dependencies = [] dependencies = []
[project.optional-dependencies]
mcp = ["mcp>=1.28,<2"]
[project.urls] [project.urls]
Homepage = "https://github.com/Ar9av/obsidian-wiki" Homepage = "https://github.com/Ar9av/obsidian-wiki"
Repository = "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] [project.scripts]
obsidian-wiki = "obsidian_wiki.cli:main" 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), # 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. # matching the repo's release tags. Tagging a release is the version bump.
+1
View File
@@ -0,0 +1 @@
"""Test package for obsidian-wiki."""
+191
View File
@@ -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()