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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Test package for obsidian-wiki."""
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user