Default ticket, multipart, and text-note ingest entry points to staging without launching a job. Propagate WIKI_ARCHIVE_SOURCES from project configuration into the managed service environment.\n\nValidated with the full 206-test suite.
1718 lines
71 KiB
Python
1718 lines
71 KiB
Python
"""MCP server exposing safe wiki source-file resources."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import hmac
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
from obsidian_wiki import __version__
|
|
from obsidian_wiki.config import config_value, resolve_vault_path
|
|
from obsidian_wiki.file_assets import clean_config_value, db_path_for_vault, registry_path_for_vault
|
|
from obsidian_wiki.mcp_ingest import (
|
|
DEFAULT_INGEST_JOB_TIMEOUT_SECONDS,
|
|
DEFAULT_INGEST_MAX_CHUNK_BYTES,
|
|
DEFAULT_INGEST_MAX_FILE_BYTES,
|
|
DEFAULT_INGEST_MAX_FILES_PER_JOB,
|
|
DEFAULT_INGEST_MAX_REQUEST_BYTES,
|
|
DEFAULT_INGEST_OPENCODE_MODEL,
|
|
DEFAULT_INGEST_OPENCODE_VARIANT,
|
|
DEFAULT_UPLOAD_TICKET_SECONDS,
|
|
DEFAULT_OPENCODE_PATH,
|
|
MAX_UPLOAD_TICKET_SECONDS,
|
|
IngestError,
|
|
IngestManager,
|
|
parse_bool,
|
|
)
|
|
from obsidian_wiki.mcp_query import (
|
|
DEFAULT_QUERY_JOB_TIMEOUT_SECONDS,
|
|
DEFAULT_QUERY_MAX_CHARS,
|
|
DEFAULT_QUERY_OPENCODE_MODEL,
|
|
DEFAULT_QUERY_OPENCODE_VARIANT,
|
|
QueryError,
|
|
QueryManager,
|
|
)
|
|
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,
|
|
)
|
|
|
|
DEFAULT_HTTP_HOST = "127.0.0.1"
|
|
DEFAULT_HTTP_PORT = 8765
|
|
DEFAULT_HTTP_PATH = "/mcp"
|
|
DEFAULT_INGEST_UPLOAD_PATH = "/mcp-ingest/uploads"
|
|
DEFAULT_TOKEN_ENV = "OBSIDIAN_MCP_TOKEN"
|
|
PUBLIC_BASE_URL_ENV = "OBSIDIAN_MCP_PUBLIC_BASE_URL"
|
|
PUBLIC_UPLOAD_URL_ENV = "OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL"
|
|
ALLOWED_HOSTS_ENV = "OBSIDIAN_MCP_ALLOWED_HOSTS"
|
|
ALLOWED_ORIGINS_ENV = "OBSIDIAN_MCP_ALLOWED_ORIGINS"
|
|
MCP_SERVER_NAME = "wiki-file-assets"
|
|
CAPABILITIES_SCHEMA_VERSION = 3
|
|
TOOLS_VERSION = 3
|
|
TOOL_FILE_ASSETS = "file-assets"
|
|
TOOL_INGEST = "ingest"
|
|
TOOL_QUERY = "query"
|
|
DEFAULT_ENABLED_TOOLS = {TOOL_FILE_ASSETS}
|
|
AVAILABLE_TOOLS = {TOOL_FILE_ASSETS, TOOL_INGEST, TOOL_QUERY}
|
|
|
|
|
|
def text_note_file_name(title: str | None) -> str:
|
|
text = str(title or "").strip().lower()
|
|
slug = []
|
|
last_dash = False
|
|
for char in text:
|
|
if char.isascii() and char.isalnum():
|
|
slug.append(char)
|
|
last_dash = False
|
|
elif char in {"-", "_"}:
|
|
slug.append(char)
|
|
last_dash = False
|
|
elif not last_dash:
|
|
slug.append("-")
|
|
last_dash = True
|
|
name = "".join(slug).strip("-_")[:80].strip("-_")
|
|
return (name or "thought") + ".md"
|
|
|
|
|
|
def text_note_payload(text: str, title: str | None = None) -> bytes:
|
|
body = str(text).replace("\x00", "").strip()
|
|
if not body:
|
|
raise IngestError("text must not be empty")
|
|
heading = " ".join(str(title or "").replace("\x00", "").split())
|
|
if heading:
|
|
content = f"# {heading}\n\n{body}\n"
|
|
else:
|
|
content = body + "\n"
|
|
return content.encode("utf-8")
|
|
|
|
|
|
def normalize_tool_name(name: str) -> str:
|
|
normalized = name.strip().lower().replace("_", "-")
|
|
aliases = {
|
|
"file": TOOL_FILE_ASSETS,
|
|
"files": TOOL_FILE_ASSETS,
|
|
"file-assets": TOOL_FILE_ASSETS,
|
|
"source-file": TOOL_FILE_ASSETS,
|
|
"source-files": TOOL_FILE_ASSETS,
|
|
"ingest": TOOL_INGEST,
|
|
"upload": TOOL_INGEST,
|
|
"uploads": TOOL_INGEST,
|
|
"opencode-ingest": TOOL_INGEST,
|
|
"query": TOOL_QUERY,
|
|
"wiki-query": TOOL_QUERY,
|
|
"ask": TOOL_QUERY,
|
|
"question": TOOL_QUERY,
|
|
"questions": TOOL_QUERY,
|
|
}
|
|
if normalized not in aliases:
|
|
raise ValueError(f"unknown MCP tool group: {name}")
|
|
return aliases[normalized]
|
|
|
|
|
|
def parse_enabled_tools(raw: str | None) -> set[str]:
|
|
if raw is None or not raw.strip():
|
|
return set(DEFAULT_ENABLED_TOOLS)
|
|
if raw.strip().lower() in {"none", "off", "disabled"}:
|
|
return set()
|
|
return {normalize_tool_name(part) for part in raw.split(",") if part.strip()}
|
|
|
|
|
|
def enabled_tool_names() -> set[str]:
|
|
configured = runtime_config_value("OBSIDIAN_MCP_ENABLED_TOOLS", "")
|
|
if configured:
|
|
return parse_enabled_tools(configured)
|
|
enabled = set(DEFAULT_ENABLED_TOOLS)
|
|
if parse_bool(runtime_config_value("OBSIDIAN_MCP_ENABLE_INGEST", "0")):
|
|
enabled.add(TOOL_INGEST)
|
|
return enabled
|
|
|
|
|
|
def bearer_token(authorization: str) -> str:
|
|
scheme, _, token = authorization.partition(" ")
|
|
if scheme.lower() != "bearer" or not token:
|
|
return ""
|
|
return token.strip()
|
|
|
|
|
|
class BearerTokenAuthMiddleware:
|
|
def __init__(self, app: Any, token: str, *, bypass_paths: set[str] | None = None) -> None:
|
|
if not token:
|
|
raise ValueError("HTTP MCP token must not be empty")
|
|
self.app = app
|
|
self.token = token
|
|
self.bypass_paths = bypass_paths or set()
|
|
|
|
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
if scope.get("type") != "http":
|
|
await self.app(scope, receive, send)
|
|
return
|
|
if scope.get("path") in self.bypass_paths:
|
|
await self.app(scope, receive, send)
|
|
return
|
|
headers = {key.lower(): value for key, value in scope.get("headers", [])}
|
|
authorization = headers.get(b"authorization", b"").decode("latin1")
|
|
token = bearer_token(authorization)
|
|
if not hmac.compare_digest(token, self.token):
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": 401,
|
|
"headers": [
|
|
(b"content-type", b"application/json"),
|
|
(b"www-authenticate", b"Bearer"),
|
|
],
|
|
}
|
|
)
|
|
await send({"type": "http.response.body", "body": b'{"error":"unauthorized"}'})
|
|
return
|
|
await self.app(scope, receive, send)
|
|
|
|
|
|
class RequestBodyTooLarge(IngestError, OSError):
|
|
"""Raised when an HTTP ingest upload body exceeds the configured request limit."""
|
|
|
|
|
|
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,
|
|
allow_source_files: bool = False,
|
|
host: str = DEFAULT_HTTP_HOST,
|
|
port: int = DEFAULT_HTTP_PORT,
|
|
streamable_http_path: str = DEFAULT_HTTP_PATH,
|
|
public_base_url: str | None = None,
|
|
allowed_hosts: list[str] | None = None,
|
|
allowed_origins: list[str] | None = None,
|
|
enable_file_assets: bool = True,
|
|
enable_ingest: bool = False,
|
|
enable_query: bool = False,
|
|
project_root: str | Path | None = None,
|
|
ingest_runtime_root: str | Path | None = None,
|
|
query_runtime_root: str | Path | None = None,
|
|
opencode_path: str | Path = DEFAULT_OPENCODE_PATH,
|
|
ingest_opencode_model: str | None = DEFAULT_INGEST_OPENCODE_MODEL,
|
|
ingest_opencode_variant: str | None = DEFAULT_INGEST_OPENCODE_VARIANT,
|
|
query_opencode_model: str | None = DEFAULT_QUERY_OPENCODE_MODEL,
|
|
query_opencode_variant: str | None = DEFAULT_QUERY_OPENCODE_VARIANT,
|
|
ingest_max_file_bytes: int = DEFAULT_INGEST_MAX_FILE_BYTES,
|
|
ingest_max_chunk_bytes: int = DEFAULT_INGEST_MAX_CHUNK_BYTES,
|
|
ingest_max_files_per_job: int = DEFAULT_INGEST_MAX_FILES_PER_JOB,
|
|
ingest_max_request_bytes: int = DEFAULT_INGEST_MAX_REQUEST_BYTES,
|
|
ingest_job_timeout_seconds: int = DEFAULT_INGEST_JOB_TIMEOUT_SECONDS,
|
|
query_max_chars: int = DEFAULT_QUERY_MAX_CHARS,
|
|
query_job_timeout_seconds: int = DEFAULT_QUERY_JOB_TIMEOUT_SECONDS,
|
|
ingest_manager: IngestManager | None = None,
|
|
query_manager: QueryManager | None = None,
|
|
):
|
|
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
|
|
try:
|
|
from mcp.server.fastmcp.server import TransportSecuritySettings
|
|
except ImportError:
|
|
TransportSecuritySettings = None
|
|
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)
|
|
project = Path(project_root).expanduser().resolve() if project_root is not None else Path.cwd().resolve()
|
|
public_base = _normalize_public_base_url(public_base_url or runtime_config_value(PUBLIC_BASE_URL_ENV, ""))
|
|
public_upload_url = _normalize_public_upload_url(runtime_config_value(PUBLIC_UPLOAD_URL_ENV, ""))
|
|
if not public_upload_url:
|
|
public_upload_url = _upload_url_from_public_base(public_base, DEFAULT_INGEST_UPLOAD_PATH)
|
|
ingest_runtime = (
|
|
Path(ingest_runtime_root).expanduser().resolve()
|
|
if ingest_runtime_root is not None
|
|
else project / ".runtime" / "mcp-ingest"
|
|
)
|
|
query_runtime = (
|
|
Path(query_runtime_root).expanduser().resolve()
|
|
if query_runtime_root is not None
|
|
else project / ".runtime" / "mcp-query"
|
|
)
|
|
transport_security = None
|
|
if TransportSecuritySettings is not None and (allowed_hosts is not None or allowed_origins is not None):
|
|
transport_security = TransportSecuritySettings(
|
|
allowed_hosts=allowed_hosts or [],
|
|
allowed_origins=allowed_origins or [],
|
|
)
|
|
server = FastMCP(
|
|
MCP_SERVER_NAME,
|
|
host=host,
|
|
port=port,
|
|
streamable_http_path=streamable_http_path,
|
|
transport_security=transport_security,
|
|
)
|
|
|
|
@server.tool()
|
|
def file_index_status() -> dict[str, Any]:
|
|
"""Show the configured vault, file index, registry, and MCP limits."""
|
|
registry = registry_path_for_vault(vault)
|
|
status = {
|
|
"db_exists": db.is_file(),
|
|
"registry_exists": registry.is_file(),
|
|
"source_registry_records": len(load_registry_by_file_id(vault)),
|
|
"profile_name": profile_name,
|
|
"max_file_bytes": max_file_bytes,
|
|
"expose_paths": expose_paths,
|
|
"allow_source_files": allow_source_files,
|
|
"enabled_tools": sorted(
|
|
([TOOL_FILE_ASSETS] if enable_file_assets else [])
|
|
+ ([TOOL_INGEST] if enable_ingest else [])
|
|
+ ([TOOL_QUERY] if enable_query else [])
|
|
),
|
|
"ingest_enabled": enable_ingest,
|
|
"query_enabled": enable_query,
|
|
"ingest_opencode_model": ingest_opencode_model or "",
|
|
"ingest_opencode_variant": ingest_opencode_variant or "",
|
|
"query_opencode_model": query_opencode_model or "",
|
|
"query_opencode_variant": query_opencode_variant or "",
|
|
}
|
|
if expose_paths:
|
|
status.update(
|
|
{
|
|
"vault_path": str(vault),
|
|
"db_path": str(db),
|
|
"registry_path": str(registry),
|
|
}
|
|
)
|
|
return status
|
|
|
|
@server.tool()
|
|
def get_mcp_capabilities() -> dict[str, Any]:
|
|
"""Tell remote agents which MCP capabilities are enabled and which tool to use first."""
|
|
enabled = sorted(
|
|
([TOOL_FILE_ASSETS] if enable_file_assets else [])
|
|
+ ([TOOL_INGEST] if enable_ingest else [])
|
|
+ ([TOOL_QUERY] if enable_query else [])
|
|
)
|
|
return {
|
|
"server_name": MCP_SERVER_NAME,
|
|
"server_version": __version__,
|
|
"schema_version": CAPABILITIES_SCHEMA_VERSION,
|
|
"capabilities_schema_version": CAPABILITIES_SCHEMA_VERSION,
|
|
"tools_version": TOOLS_VERSION,
|
|
"enabled_tools": enabled,
|
|
"recommended_first_steps": _recommended_first_steps(
|
|
enable_file_assets=enable_file_assets,
|
|
enable_ingest=enable_ingest,
|
|
enable_query=enable_query,
|
|
),
|
|
"file_index": _file_index_capabilities(vault, db),
|
|
"file_assets": {
|
|
"enabled": enable_file_assets,
|
|
"tools": (
|
|
[
|
|
"search_file_assets",
|
|
"list_file_assets",
|
|
"get_file_asset_metadata",
|
|
"asset://source/sha256/{digest}",
|
|
]
|
|
if enable_file_assets
|
|
else []
|
|
),
|
|
"best_for": "safe engineering source files such as drawings, CAD/3D models, specifications, and test reports",
|
|
"blocked_content": "raw orders, prices, email correspondence, complaint evidence, claims, and personal data",
|
|
"max_file_bytes": max_file_bytes,
|
|
"source_file_download_enabled": bool(enable_file_assets and allow_source_files),
|
|
},
|
|
"query": {
|
|
"enabled": enable_query,
|
|
"preferred_tool": "ask_wiki" if enable_query else None,
|
|
"async_tools": (
|
|
[
|
|
"start_wiki_query",
|
|
"get_wiki_query_status",
|
|
"get_wiki_query_result",
|
|
"cancel_wiki_query",
|
|
]
|
|
if enable_query
|
|
else []
|
|
),
|
|
"tools": (
|
|
[
|
|
"ask_wiki",
|
|
"start_wiki_query",
|
|
"get_wiki_query_status",
|
|
"get_wiki_query_result",
|
|
"cancel_wiki_query",
|
|
"check_query_opencode_readiness",
|
|
]
|
|
if enable_query
|
|
else []
|
|
),
|
|
"supports_business_facts": enable_query,
|
|
"supports_hybrid_questions": enable_query,
|
|
"public_only_disables_business_facts": True,
|
|
"supports_structured_result": enable_query,
|
|
"max_query_chars": query_max_chars,
|
|
"job_timeout_seconds": query_job_timeout_seconds,
|
|
"known_limitations": (
|
|
[
|
|
"answer_markdown is produced by the local wiki-query agent; citations and source flags are best-effort structured metadata.",
|
|
"Long-running queries may return a job_id and require polling get_wiki_query_result.",
|
|
]
|
|
if enable_query
|
|
else []
|
|
),
|
|
},
|
|
"ingest": {
|
|
"enabled": enable_ingest,
|
|
"preferred_text_tool": "ingest_text_note" if enable_ingest else None,
|
|
"supports_bundle_upload": enable_ingest,
|
|
"supports_body_text": enable_ingest,
|
|
"authoritative_job_phases": (
|
|
["queued", "preflight", "agent_running", "verifying", "complete"]
|
|
if enable_ingest
|
|
else []
|
|
),
|
|
"process_alive_source": "process_supervisor" if enable_ingest else None,
|
|
"supports_post_run_verification": enable_ingest,
|
|
"semantic_activity_inference": False,
|
|
"multipart_optional_fields": (
|
|
[
|
|
"body_text",
|
|
"body_title",
|
|
"bundle_id",
|
|
"source_type",
|
|
"label",
|
|
"project",
|
|
"doc_type_hint",
|
|
"note",
|
|
"start_job",
|
|
]
|
|
if enable_ingest
|
|
else []
|
|
),
|
|
"http_upload_path": DEFAULT_INGEST_UPLOAD_PATH if enable_ingest else None,
|
|
"http_upload_url": public_upload_url if enable_ingest else None,
|
|
"http_upload_url_warning": (
|
|
None
|
|
if public_upload_url or not enable_ingest
|
|
else "Set OBSIDIAN_MCP_PUBLIC_BASE_URL or OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL so remote agents can receive a complete upload URL."
|
|
),
|
|
"preferred_upload_auth": "upload_ticket" if enable_ingest else None,
|
|
"tools": (
|
|
[
|
|
"get_ingest_upload_instructions",
|
|
"ingest_text_note",
|
|
"create_ingest_upload_ticket",
|
|
"validate_upload",
|
|
"preview_upload_metadata",
|
|
"start_ingest_job",
|
|
"get_ingest_job_status",
|
|
"get_ingest_job_result",
|
|
"cancel_ingest_job",
|
|
"check_ingest_opencode_readiness",
|
|
]
|
|
if enable_ingest
|
|
else []
|
|
),
|
|
"max_file_bytes": ingest_max_file_bytes,
|
|
"max_files_per_job": ingest_max_files_per_job,
|
|
"max_request_bytes": _max_upload_body_bytes(ingest_manager, None)
|
|
if enable_ingest and ingest_manager is not None
|
|
else ingest_max_request_bytes or None,
|
|
"job_timeout_seconds": ingest_job_timeout_seconds,
|
|
"last_ingest_at": _last_ingest_at(ingest_manager) if enable_ingest and ingest_manager is not None else None,
|
|
"queue": _ingest_queue_summary(ingest_manager) if enable_ingest and ingest_manager is not None else None,
|
|
"known_limitations": (
|
|
[
|
|
"Remote file uploads should use single-use upload tickets; bearer upload is retained for local/admin compatibility.",
|
|
"Upload preview detects exact sha256 duplicates only, not fuzzy near-duplicates.",
|
|
"Full dry-run ingest is not exposed; OpenCode runs only through the fixed wiki-ingest prompt.",
|
|
]
|
|
if enable_ingest
|
|
else []
|
|
),
|
|
},
|
|
"safety": {
|
|
"arbitrary_shell": bool(enable_ingest or enable_query),
|
|
"direct_arbitrary_shell_tool": False,
|
|
"arbitrary_prompt": False,
|
|
"fixed_prompt_entrypoints": True,
|
|
"prompt_restrictions_enforced": False,
|
|
"shell_sandbox_enforced": False,
|
|
"opencode_auto_approve": bool(enable_ingest or enable_query),
|
|
"trusted_clients_required": True,
|
|
"local_paths_hidden_by_default": not expose_paths,
|
|
"model_choice_server_side": True,
|
|
"source_file_download_enabled": bool(enable_file_assets and allow_source_files),
|
|
},
|
|
}
|
|
|
|
owns_ingest_manager = False
|
|
if enable_ingest:
|
|
if ingest_manager is None:
|
|
ingest_manager = IngestManager(
|
|
vault_path=vault,
|
|
project_root=project,
|
|
runtime_root=ingest_runtime,
|
|
opencode_path=opencode_path,
|
|
opencode_model=ingest_opencode_model,
|
|
opencode_variant=ingest_opencode_variant,
|
|
max_file_bytes=ingest_max_file_bytes,
|
|
max_chunk_bytes=ingest_max_chunk_bytes,
|
|
max_files_per_job=ingest_max_files_per_job,
|
|
max_request_bytes=ingest_max_request_bytes,
|
|
job_timeout_seconds=ingest_job_timeout_seconds,
|
|
)
|
|
owns_ingest_manager = True
|
|
|
|
@server.tool()
|
|
def get_ingest_upload_instructions() -> dict[str, Any]:
|
|
"""Return preferred upload methods and limits for remote MCP ingest clients."""
|
|
upload_url_warning = (
|
|
None
|
|
if public_upload_url
|
|
else "Set OBSIDIAN_MCP_PUBLIC_BASE_URL or OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL so remote agents can receive a complete upload URL."
|
|
)
|
|
return {
|
|
"preferred_method": "use ingest_text_note for short text; for files, call create_ingest_upload_ticket and upload with X-Obsidian-Wiki-Upload-Token",
|
|
"preferred_upload_auth": "upload_ticket",
|
|
"http_upload": {
|
|
"method": "POST",
|
|
"path": DEFAULT_INGEST_UPLOAD_PATH,
|
|
"url": public_upload_url,
|
|
"url_warning": upload_url_warning,
|
|
"file_field": "files",
|
|
"auth": "call create_ingest_upload_ticket and send X-Obsidian-Wiki-Upload-Token",
|
|
"bearer_auth": "admin_or_local_only",
|
|
"optional_fields": [
|
|
"body_text",
|
|
"body_title",
|
|
"bundle_id",
|
|
"source_type",
|
|
"label",
|
|
"project",
|
|
"doc_type_hint",
|
|
"note",
|
|
"start_job",
|
|
],
|
|
"start_job_default": False,
|
|
"curl_example": (
|
|
"curl -H 'X-Obsidian-Wiki-Upload-Token: <upload-token>' "
|
|
"-F 'files=@drawing.pdf' -F 'project=bjd' "
|
|
"-F 'doc_type_hint=drawing' -F 'start_job=false' "
|
|
f"{public_upload_url or '<upload-url>'}"
|
|
),
|
|
},
|
|
"upload_ticket_tool": {
|
|
"name": "create_ingest_upload_ticket",
|
|
"token_header": "X-Obsidian-Wiki-Upload-Token",
|
|
"best_for": "default file upload path for remote agents",
|
|
"curl_example": (
|
|
"curl -H 'X-Obsidian-Wiki-Upload-Token: <upload-token>' "
|
|
f"-F 'files=@drawing.pdf' {public_upload_url or '<upload-url>'}"
|
|
),
|
|
},
|
|
"direct_text_tool": {
|
|
"name": "ingest_text_note",
|
|
"best_for": "short remote-agent thoughts, notes, findings, or pasted text",
|
|
"start_job_default": False,
|
|
"notes": "wraps text as a temporary markdown upload, then uses the same fixed wiki-ingest job path",
|
|
},
|
|
"limits": {
|
|
"max_file_bytes": ingest_manager.max_file_bytes,
|
|
"max_chunk_bytes": ingest_manager.max_chunk_bytes,
|
|
"max_files_per_job": ingest_manager.max_files_per_job,
|
|
"max_request_bytes": _max_upload_body_bytes(ingest_manager, None),
|
|
},
|
|
"staging": {
|
|
"note": "uploads are temporary staging files outside the vault; wiki-ingest decides final archive/index placement",
|
|
},
|
|
"recommended_file_workflow": [
|
|
"create_ingest_upload_ticket(start_job=false)",
|
|
"POST multipart files with X-Obsidian-Wiki-Upload-Token",
|
|
"preview_upload_metadata(upload_ids)",
|
|
"start_ingest_job(upload_ids) when preview looks correct",
|
|
],
|
|
}
|
|
|
|
@server.tool()
|
|
def ingest_text_note(
|
|
text: str,
|
|
title: str | None = None,
|
|
project: str | None = None,
|
|
doc_type_hint: str | None = "thought",
|
|
note: str | None = None,
|
|
start_job: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Ingest a short text note by wrapping it as a temporary markdown upload."""
|
|
payload = text_note_payload(text, title=title)
|
|
upload = ingest_manager.create_completed_upload(
|
|
file_name=text_note_file_name(title),
|
|
payload=payload,
|
|
project=project,
|
|
doc_type_hint=doc_type_hint or "thought",
|
|
)
|
|
bundle = ingest_manager.create_ingest_bundle(
|
|
[upload["upload_id"]],
|
|
body_upload_id=upload["upload_id"],
|
|
source_type="text_note",
|
|
label=title,
|
|
)
|
|
upload["bundle_id"] = bundle["bundle_id"]
|
|
upload["bundle_role"] = "primary"
|
|
job = ingest_manager.start_ingest_job([upload["upload_id"]], note=note) if start_job else None
|
|
return {
|
|
"bundle": bundle,
|
|
"uploads": [upload],
|
|
"job": job,
|
|
"text_bytes": len(payload),
|
|
}
|
|
|
|
@server.tool()
|
|
def create_ingest_upload_ticket(
|
|
expires_seconds: int = DEFAULT_UPLOAD_TICKET_SECONDS,
|
|
max_files: int | None = None,
|
|
project: str | None = None,
|
|
doc_type_hint: str | None = None,
|
|
note: str | None = None,
|
|
bundle_id: str | None = None,
|
|
source_type: str | None = None,
|
|
label: str | None = None,
|
|
start_job: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Create a short-lived HTTP upload ticket so remote agents can upload files without the main MCP token."""
|
|
ticket = ingest_manager.create_upload_ticket(
|
|
expires_seconds=expires_seconds,
|
|
max_files=max_files,
|
|
project=project,
|
|
doc_type_hint=doc_type_hint,
|
|
note=note,
|
|
bundle_id=bundle_id,
|
|
source_type=source_type,
|
|
label=label,
|
|
start_job=start_job,
|
|
)
|
|
return {
|
|
**ticket,
|
|
"upload_path": DEFAULT_INGEST_UPLOAD_PATH,
|
|
"upload_url": public_upload_url,
|
|
"upload_url_warning": (
|
|
None
|
|
if public_upload_url
|
|
else "Set OBSIDIAN_MCP_PUBLIC_BASE_URL or OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL so remote agents can receive a complete upload URL."
|
|
),
|
|
"token_header": "X-Obsidian-Wiki-Upload-Token",
|
|
"expires_seconds_max": MAX_UPLOAD_TICKET_SECONDS,
|
|
"curl_example": (
|
|
"curl -H 'X-Obsidian-Wiki-Upload-Token: "
|
|
+ ticket["upload_token"]
|
|
+ f"' -F 'files=@/path/to/file.pdf' {public_upload_url or '<upload-url>'}"
|
|
),
|
|
}
|
|
|
|
@server.tool()
|
|
def validate_upload(
|
|
files: list[dict[str, Any]],
|
|
project: str | None = None,
|
|
doc_type_hint: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Validate a planned remote upload without creating files or starting ingest."""
|
|
return ingest_manager.validate_upload_plan(files, project=project, doc_type_hint=doc_type_hint)
|
|
|
|
@server.tool()
|
|
def preview_upload_metadata(upload_ids: list[str]) -> dict[str, Any]:
|
|
"""Preview completed upload metadata and exact duplicate warnings before ingest."""
|
|
return ingest_manager.preview_upload_metadata(upload_ids)
|
|
|
|
@server.tool()
|
|
def start_ingest_job(upload_ids: list[str], note: str | None = None) -> dict[str, Any]:
|
|
"""Start or queue a local OpenCode wiki-ingest job for completed uploads."""
|
|
return ingest_manager.start_ingest_job(upload_ids, note=note)
|
|
|
|
@server.tool()
|
|
def get_ingest_job_status(job_id: str) -> dict[str, Any]:
|
|
"""Return state, log tail, and exit status for an MCP ingest job."""
|
|
return ingest_manager.get_ingest_job_status(job_id)
|
|
|
|
@server.tool()
|
|
def get_ingest_job_result(job_id: str) -> dict[str, Any]:
|
|
"""Return the final or current summary for an MCP ingest job."""
|
|
return ingest_manager.get_ingest_job_result(job_id)
|
|
|
|
@server.tool()
|
|
def cancel_ingest_job(job_id: str) -> dict[str, Any]:
|
|
"""Cancel a queued or running MCP ingest job."""
|
|
return ingest_manager.cancel_ingest_job(job_id)
|
|
|
|
@server.tool()
|
|
def check_ingest_opencode_readiness(run_smoke_test: bool = False) -> dict[str, Any]:
|
|
"""Check whether local OpenCode can load the wiki-ingest skill for MCP ingest jobs."""
|
|
return _public_readiness(ingest_manager, run_smoke_test=run_smoke_test)
|
|
|
|
if enable_query:
|
|
if query_manager is None:
|
|
try:
|
|
query_manager = QueryManager(
|
|
vault_path=vault,
|
|
project_root=project,
|
|
runtime_root=query_runtime,
|
|
opencode_path=opencode_path,
|
|
opencode_model=query_opencode_model,
|
|
opencode_variant=query_opencode_variant,
|
|
max_query_chars=query_max_chars,
|
|
job_timeout_seconds=query_job_timeout_seconds,
|
|
)
|
|
except Exception:
|
|
if owns_ingest_manager and ingest_manager is not None:
|
|
ingest_manager.close()
|
|
raise
|
|
|
|
@server.tool()
|
|
def ask_wiki(
|
|
query: str,
|
|
mode: str = "normal",
|
|
public_only: bool = False,
|
|
note: str | None = None,
|
|
timeout_seconds: int = 90,
|
|
poll_interval_seconds: int = 2,
|
|
) -> dict[str, Any]:
|
|
"""Ask the wiki in one call; returns a final structured answer or a job_id if it times out."""
|
|
return query_manager.ask_wiki(
|
|
query,
|
|
mode=mode,
|
|
public_only=public_only,
|
|
note=note,
|
|
timeout_seconds=timeout_seconds,
|
|
poll_interval_seconds=poll_interval_seconds,
|
|
)
|
|
|
|
@server.tool()
|
|
def start_wiki_query(
|
|
query: str,
|
|
mode: str = "normal",
|
|
public_only: bool = False,
|
|
note: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Start or queue a local OpenCode wiki-query job."""
|
|
return query_manager.start_query_job(
|
|
query,
|
|
mode=mode,
|
|
public_only=public_only,
|
|
note=note,
|
|
)
|
|
|
|
@server.tool()
|
|
def get_wiki_query_status(job_id: str) -> dict[str, Any]:
|
|
"""Return state, log tail, and exit status for an MCP wiki query job."""
|
|
return query_manager.get_query_job_status(job_id)
|
|
|
|
@server.tool()
|
|
def get_wiki_query_result(job_id: str) -> dict[str, Any]:
|
|
"""Return the final or current answer for an MCP wiki query job."""
|
|
return query_manager.get_query_job_result(job_id)
|
|
|
|
@server.tool()
|
|
def cancel_wiki_query(job_id: str) -> dict[str, Any]:
|
|
"""Cancel a queued or running MCP wiki query job."""
|
|
return query_manager.cancel_query_job(job_id)
|
|
|
|
@server.tool()
|
|
def check_query_opencode_readiness(run_smoke_test: bool = False) -> dict[str, Any]:
|
|
"""Check whether local OpenCode can load the wiki-query skill for MCP query jobs."""
|
|
return _public_readiness(query_manager, run_smoke_test=run_smoke_test)
|
|
|
|
if enable_file_assets:
|
|
|
|
@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 list_file_assets(
|
|
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]]:
|
|
"""List safe source files matching optional filters. Use search_file_assets when the user gives keywords such as product codes, sizes, or drawing names."""
|
|
return _as_mcp_results(
|
|
core_search_file_assets(
|
|
vault,
|
|
"",
|
|
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}", mime_type="application/octet-stream")
|
|
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,
|
|
allow_source_files=allow_source_files,
|
|
)
|
|
return resource["bytes"]
|
|
|
|
@server.prompt(
|
|
"how_to_use_wiki_file_assets",
|
|
description="Explain how to search and retrieve original source files from the wiki-file-assets MCP server.",
|
|
)
|
|
def how_to_use_wiki_file_assets() -> str:
|
|
download_status = (
|
|
"Source file downloads are enabled by the server operator. "
|
|
if allow_source_files
|
|
else "Source file downloads are disabled; metadata search remains available. "
|
|
)
|
|
return (
|
|
"wiki-file-assets serves original source files archived from an Obsidian wiki vault. "
|
|
"Use search_file_assets for keyword searches such as product codes, drawing names, sizes, "
|
|
"or document types. Use list_file_assets to browse allowed engineering files with optional "
|
|
"filters. Use get_file_asset_metadata with a file_id from search/list results to get the "
|
|
"asset://source/sha256/... URI, then read that resource to fetch the original file bytes. "
|
|
f"{download_status}This MCP is intentionally limited to safe engineering files such as drawings, CAD/3D models, "
|
|
"specifications, product parameters, and test reports; it blocks raw orders, prices, email "
|
|
"correspondence, complaint evidence, claims, and personal data."
|
|
)
|
|
|
|
setattr(
|
|
server,
|
|
"_obsidian_job_managers",
|
|
tuple(manager for manager in (ingest_manager, query_manager) if manager is not None),
|
|
)
|
|
return server
|
|
|
|
|
|
def close_server_managers(server: Any) -> list[Exception]:
|
|
errors: list[Exception] = []
|
|
for manager in getattr(server, "_obsidian_job_managers", ()):
|
|
try:
|
|
manager.close()
|
|
except Exception as exc:
|
|
errors.append(exc)
|
|
return errors
|
|
|
|
|
|
def read_token_from_env(env_name: str = DEFAULT_TOKEN_ENV) -> str:
|
|
token = os.environ.get(env_name, "").strip()
|
|
if not token:
|
|
raise RuntimeError(f"{env_name} must be set for HTTP MCP transport")
|
|
return token
|
|
|
|
|
|
def runtime_config_value(key: str, default: str) -> str:
|
|
raw = os.environ.get(key)
|
|
if raw is None:
|
|
raw = config_value(key, default)
|
|
value = clean_config_value(raw).strip()
|
|
return value or default
|
|
|
|
|
|
def runtime_config_int(key: str, default: int) -> int:
|
|
raw = runtime_config_value(key, str(default))
|
|
try:
|
|
return int(raw)
|
|
except ValueError:
|
|
return default
|
|
|
|
|
|
def runtime_config_list(key: str) -> list[str] | None:
|
|
raw = os.environ.get(key)
|
|
if raw is None:
|
|
configured = config_value(key, "")
|
|
if not configured:
|
|
return None
|
|
raw = configured
|
|
return [item.strip() for item in clean_config_value(raw).split(",") if item.strip()]
|
|
|
|
|
|
def _public_readiness(manager: Any, *, run_smoke_test: bool) -> dict[str, Any]:
|
|
result = manager.check_opencode_readiness(run_smoke_test=run_smoke_test)
|
|
sanitizer = getattr(manager, "_public_preflight", None)
|
|
if callable(sanitizer):
|
|
public = sanitizer(result)
|
|
if isinstance(public, dict):
|
|
return public
|
|
return {
|
|
key: value
|
|
for key, value in result.items()
|
|
if key not in {"checked_paths", "errors"}
|
|
} | ({"errors": ["OpenCode readiness failed"]} if result.get("errors") else {"errors": []})
|
|
|
|
|
|
class IngestUploadRouter:
|
|
def __init__(
|
|
self,
|
|
app: Any,
|
|
*,
|
|
ingest_manager: IngestManager | None = None,
|
|
upload_path: str = DEFAULT_INGEST_UPLOAD_PATH,
|
|
bearer_auth_token: str = "",
|
|
allowed_hosts: list[str] | None = None,
|
|
allowed_origins: list[str] | None = None,
|
|
) -> None:
|
|
self.app = app
|
|
self.ingest_manager = ingest_manager
|
|
self.upload_path = upload_path
|
|
self.bearer_auth_token = bearer_auth_token
|
|
self.enforce_allowed_hosts = allowed_hosts is not None
|
|
self.enforce_allowed_origins = allowed_origins is not None
|
|
self.allowed_hosts = list(allowed_hosts or [])
|
|
self.allowed_origins = list(allowed_origins or [])
|
|
|
|
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
if scope.get("type") != "http" or scope.get("path") != self.upload_path:
|
|
await self.app(scope, receive, send)
|
|
return
|
|
if not self._transport_headers_allowed(scope):
|
|
await _send_json(send, 403, {"error": "forbidden"})
|
|
return
|
|
if scope.get("method", "").upper() != "POST":
|
|
if not self._has_upload_auth(scope):
|
|
await _send_json(send, 401, {"error": "unauthorized"})
|
|
return
|
|
await _send_json(send, 405, {"error": "method_not_allowed"})
|
|
return
|
|
try:
|
|
upload_ticket = self._authorize_upload(scope)
|
|
except IngestError:
|
|
await _send_json(send, 401, {"error": "unauthorized"})
|
|
return
|
|
if self.ingest_manager is None:
|
|
await _send_json(send, 404, {"error": "ingest_not_enabled"})
|
|
return
|
|
try:
|
|
data = await self._handle_upload(scope, receive, upload_ticket=upload_ticket)
|
|
except RequestBodyTooLarge as exc:
|
|
await _send_json(send, 413, _upload_error_payload(str(exc), upload_ticket=upload_ticket))
|
|
return
|
|
except IngestError as exc:
|
|
await _send_json(send, 400, _upload_error_payload(str(exc), upload_ticket=upload_ticket))
|
|
return
|
|
await _send_json(send, 200, data)
|
|
|
|
def _transport_headers_allowed(self, scope: dict[str, Any]) -> bool:
|
|
host = _header_value(scope, b"host")
|
|
if self.enforce_allowed_hosts and (not host or not _matches_host(host, self.allowed_hosts)):
|
|
return False
|
|
origin = _header_value(scope, b"origin")
|
|
if origin and self.enforce_allowed_origins and not _matches_any(origin, self.allowed_origins):
|
|
return False
|
|
return True
|
|
|
|
def _has_upload_auth(self, scope: dict[str, Any]) -> bool:
|
|
authorization = _header_value(scope, b"authorization")
|
|
token = bearer_token(authorization)
|
|
if self.bearer_auth_token and hmac.compare_digest(token, self.bearer_auth_token):
|
|
return True
|
|
if _header_value(scope, b"x-obsidian-wiki-upload-token"):
|
|
return True
|
|
return False
|
|
|
|
def _authorize_upload(self, scope: dict[str, Any]) -> dict[str, Any] | None:
|
|
authorization = _header_value(scope, b"authorization")
|
|
token = bearer_token(authorization)
|
|
if self.bearer_auth_token and hmac.compare_digest(token, self.bearer_auth_token):
|
|
return None
|
|
if self.ingest_manager is None:
|
|
raise IngestError("unauthorized")
|
|
upload_token = _header_value(scope, b"x-obsidian-wiki-upload-token")
|
|
if not upload_token:
|
|
raise IngestError("unauthorized")
|
|
return self.ingest_manager.consume_upload_ticket(upload_token)
|
|
|
|
async def _handle_upload(
|
|
self,
|
|
scope: dict[str, Any],
|
|
receive: Any,
|
|
*,
|
|
upload_ticket: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
manager = self.ingest_manager
|
|
if manager is None:
|
|
raise IngestError("ingest is not enabled")
|
|
content_type = _header_value(scope, b"content-type")
|
|
if not content_type.lower().startswith("multipart/form-data"):
|
|
raise IngestError("content-type must be multipart/form-data")
|
|
max_body_bytes = _max_upload_body_bytes(manager, upload_ticket)
|
|
content_length = _header_value(scope, b"content-length")
|
|
if content_length:
|
|
try:
|
|
declared_body_bytes = int(content_length)
|
|
except ValueError as exc:
|
|
raise IngestError("content-length must be an integer") from exc
|
|
if declared_body_bytes < 0:
|
|
raise IngestError("content-length must be non-negative")
|
|
if max_body_bytes is not None and declared_body_bytes > max_body_bytes:
|
|
raise RequestBodyTooLarge(
|
|
f"request body exceeds max upload size: {declared_body_bytes} > {max_body_bytes}"
|
|
)
|
|
max_files = int(upload_ticket.get("max_files", manager.max_files_per_job)) if upload_ticket else manager.max_files_per_job
|
|
received_body_bytes = 0
|
|
|
|
async def limited_receive() -> dict[str, Any]:
|
|
nonlocal received_body_bytes
|
|
message = await receive()
|
|
if message.get("type") == "http.request":
|
|
received_body_bytes += len(message.get("body", b""))
|
|
if max_body_bytes is not None and received_body_bytes > max_body_bytes:
|
|
raise RequestBodyTooLarge(
|
|
f"request body exceeds max upload size: {received_body_bytes} > {max_body_bytes}"
|
|
)
|
|
return message
|
|
|
|
try:
|
|
from starlette.datastructures import UploadFile
|
|
from starlette.formparsers import MultiPartException, MultiPartParser
|
|
from starlette.requests import Request
|
|
except ImportError as exc:
|
|
raise IngestError("streaming multipart support is not installed") from exc
|
|
|
|
class FilePartTooLarge(MultiPartException):
|
|
pass
|
|
|
|
class LimitedFileMultiPartParser(MultiPartParser):
|
|
def __init__(self, *args: Any, max_file_part_size: int, **kwargs: Any) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
self.max_file_part_size = max_file_part_size
|
|
self.current_file_size = 0
|
|
|
|
def on_part_begin(self) -> None:
|
|
super().on_part_begin()
|
|
self.current_file_size = 0
|
|
|
|
def on_part_data(self, data: bytes, start: int, end: int) -> None:
|
|
if self._current_part.file is not None:
|
|
self.current_file_size += end - start
|
|
if self.max_file_part_size > 0 and self.current_file_size > self.max_file_part_size:
|
|
raise FilePartTooLarge(
|
|
f"file part exceeds max upload size: "
|
|
f"{self.current_file_size} > {self.max_file_part_size}"
|
|
)
|
|
super().on_part_data(data, start, end)
|
|
|
|
form = None
|
|
try:
|
|
request = Request(scope, limited_receive)
|
|
try:
|
|
parser = LimitedFileMultiPartParser(
|
|
request.headers,
|
|
request.stream(),
|
|
max_files=max_files,
|
|
max_fields=12,
|
|
max_part_size=64 * 1024,
|
|
max_file_part_size=manager.max_file_bytes,
|
|
)
|
|
form = await parser.parse()
|
|
except FilePartTooLarge as exc:
|
|
raise RequestBodyTooLarge(str(exc)) from exc
|
|
except MultiPartException as exc:
|
|
raise IngestError(f"multipart request body is invalid: {exc}") from exc
|
|
files = [
|
|
value
|
|
for field_name, value in form.multi_items()
|
|
if field_name == "files" and isinstance(value, UploadFile)
|
|
]
|
|
def first_text_field(name: str) -> str | None:
|
|
for value in form.getlist(name):
|
|
if isinstance(value, str):
|
|
return value
|
|
return None
|
|
|
|
def ticket_bound_field(name: str) -> str | None:
|
|
ticket_value = (upload_ticket or {}).get(name)
|
|
return ticket_value if ticket_value not in (None, "") else first_text_field(name)
|
|
|
|
project = ticket_bound_field("project")
|
|
doc_type_hint = ticket_bound_field("doc_type_hint")
|
|
note = ticket_bound_field("note")
|
|
body_text = first_text_field("body_text")
|
|
body_title = first_text_field("body_title")
|
|
bundle_id = ticket_bound_field("bundle_id")
|
|
source_type = ticket_bound_field("source_type")
|
|
label = ticket_bound_field("label")
|
|
start_default = bool((upload_ticket or {}).get("start_job", False))
|
|
requested_start = parse_bool(first_text_field("start_job"), default=start_default)
|
|
start_job = requested_start if upload_ticket is None else start_default and requested_start
|
|
has_body = bool(body_text and body_text.strip())
|
|
item_count = len(files) + (1 if has_body else 0)
|
|
if item_count == 0:
|
|
raise IngestError("multipart request must include body_text or at least one file")
|
|
if item_count > max_files:
|
|
raise IngestError(f"too many files for one ingest job: {item_count} > {max_files}")
|
|
uploads = []
|
|
bundle = None
|
|
try:
|
|
body_upload_id = None
|
|
if has_body:
|
|
payload = text_note_payload(body_text or "", title=body_title)
|
|
body_upload = await _run_blocking(
|
|
lambda: manager.create_completed_upload(
|
|
file_name=text_note_file_name(body_title or label or "body"),
|
|
payload=payload,
|
|
project=project,
|
|
doc_type_hint=doc_type_hint or "text",
|
|
)
|
|
)
|
|
uploads.append(body_upload)
|
|
body_upload_id = body_upload["upload_id"]
|
|
for file in files:
|
|
await file.seek(0)
|
|
upload = await _run_blocking(
|
|
lambda file=file: manager.create_completed_upload_from_stream(
|
|
file_name=file.filename or "",
|
|
stream=file.file,
|
|
size_bytes=file.size,
|
|
project=project,
|
|
doc_type_hint=doc_type_hint,
|
|
)
|
|
)
|
|
uploads.append(upload)
|
|
bundle = manager.create_ingest_bundle(
|
|
[upload["upload_id"] for upload in uploads],
|
|
body_upload_id=body_upload_id,
|
|
bundle_id=bundle_id,
|
|
source_type=source_type,
|
|
label=label,
|
|
)
|
|
for upload in uploads:
|
|
upload["bundle_id"] = bundle["bundle_id"]
|
|
upload["bundle_role"] = (
|
|
"primary"
|
|
if upload["upload_id"] == body_upload_id
|
|
else "attachment"
|
|
)
|
|
job = (
|
|
manager.start_ingest_job([upload["upload_id"] for upload in uploads], note=note)
|
|
if start_job
|
|
else None
|
|
)
|
|
return {"bundle": bundle, "uploads": uploads, "job": job}
|
|
except BaseException:
|
|
if bundle is not None:
|
|
try:
|
|
manager.discard_ingest_bundle(bundle["bundle_id"])
|
|
except (IngestError, OSError):
|
|
pass
|
|
for upload in reversed(uploads):
|
|
try:
|
|
manager.discard_upload(upload["upload_id"])
|
|
except (IngestError, OSError):
|
|
pass
|
|
raise
|
|
finally:
|
|
if form is not None:
|
|
await form.close()
|
|
|
|
|
|
def create_http_app(
|
|
server: Any,
|
|
token: str,
|
|
*,
|
|
ingest_manager: IngestManager | None = None,
|
|
upload_path: str = DEFAULT_INGEST_UPLOAD_PATH,
|
|
allowed_hosts: list[str] | None = None,
|
|
allowed_origins: list[str] | None = None,
|
|
) -> Any:
|
|
app = server.streamable_http_app() if hasattr(server, "streamable_http_app") else server
|
|
app = IngestUploadRouter(
|
|
app,
|
|
ingest_manager=ingest_manager,
|
|
upload_path=upload_path,
|
|
bearer_auth_token=token,
|
|
allowed_hosts=allowed_hosts,
|
|
allowed_origins=allowed_origins,
|
|
)
|
|
return BearerTokenAuthMiddleware(app, token, bypass_paths={upload_path})
|
|
|
|
|
|
def _max_upload_body_bytes(manager: IngestManager, upload_ticket: dict[str, Any] | None) -> int | None:
|
|
max_files = int(upload_ticket.get("max_files", manager.max_files_per_job)) if upload_ticket else manager.max_files_per_job
|
|
derived_limit = None
|
|
if manager.max_file_bytes > 0:
|
|
overhead = max(16 * 1024, max_files * 4096)
|
|
derived_limit = manager.max_file_bytes * max_files + overhead
|
|
configured_limit = int(getattr(manager, "max_request_bytes", 0) or 0)
|
|
if configured_limit <= 0:
|
|
return derived_limit
|
|
return min(configured_limit, derived_limit) if derived_limit is not None else configured_limit
|
|
|
|
|
|
def _header_value(scope: dict[str, Any], name: bytes) -> str:
|
|
headers = {key.lower(): value for key, value in scope.get("headers", [])}
|
|
return headers.get(name.lower(), b"").decode("latin1")
|
|
|
|
|
|
def _matches_any(value: str, patterns: list[str]) -> bool:
|
|
if value in patterns:
|
|
return True
|
|
return any(
|
|
pattern.endswith(":*") and value.startswith(pattern[:-2] + ":")
|
|
for pattern in patterns
|
|
)
|
|
|
|
|
|
def _matches_host(value: str, patterns: list[str]) -> bool:
|
|
return _matches_any(value, patterns)
|
|
|
|
|
|
def _upload_error_payload(error: str, *, upload_ticket: dict[str, Any] | None) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {"error": error}
|
|
if upload_ticket is not None:
|
|
payload.update(
|
|
{
|
|
"ticket_consumed": True,
|
|
"retry_action": "create_new_upload_ticket",
|
|
}
|
|
)
|
|
return payload
|
|
|
|
|
|
async def _send_json(send: Any, status: int, payload: dict[str, Any]) -> None:
|
|
body = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": status,
|
|
"headers": [
|
|
(b"content-type", b"application/json"),
|
|
(b"content-length", str(len(body)).encode("ascii")),
|
|
],
|
|
}
|
|
)
|
|
await send({"type": "http.response.body", "body": body})
|
|
|
|
|
|
async def _run_blocking(operation: Any) -> Any:
|
|
done = threading.Event()
|
|
result: list[Any] = []
|
|
errors: list[BaseException] = []
|
|
|
|
def run() -> None:
|
|
try:
|
|
result.append(operation())
|
|
except BaseException as exc:
|
|
errors.append(exc)
|
|
finally:
|
|
done.set()
|
|
|
|
worker = threading.Thread(target=run, name="mcp-upload-copy", daemon=True)
|
|
worker.start()
|
|
try:
|
|
while not done.is_set():
|
|
await asyncio.sleep(0.005)
|
|
except asyncio.CancelledError:
|
|
done.wait()
|
|
raise
|
|
worker.join()
|
|
if errors:
|
|
raise errors[0]
|
|
return result[0]
|
|
|
|
|
|
def run_http_server(
|
|
*,
|
|
vault_path: str | Path,
|
|
db_path: str | Path | None,
|
|
profile_name: str,
|
|
max_file_bytes: int,
|
|
expose_paths: bool,
|
|
allow_source_files: bool = False,
|
|
host: str,
|
|
port: int,
|
|
token_env: str,
|
|
streamable_http_path: str,
|
|
allowed_hosts: list[str] | None,
|
|
allowed_origins: list[str] | None,
|
|
enable_file_assets: bool = True,
|
|
enable_ingest: bool = False,
|
|
enable_query: bool = False,
|
|
project_root: str | Path | None = None,
|
|
ingest_runtime_root: str | Path | None = None,
|
|
query_runtime_root: str | Path | None = None,
|
|
opencode_path: str | Path = DEFAULT_OPENCODE_PATH,
|
|
ingest_opencode_model: str | None = DEFAULT_INGEST_OPENCODE_MODEL,
|
|
ingest_opencode_variant: str | None = DEFAULT_INGEST_OPENCODE_VARIANT,
|
|
query_opencode_model: str | None = DEFAULT_QUERY_OPENCODE_MODEL,
|
|
query_opencode_variant: str | None = DEFAULT_QUERY_OPENCODE_VARIANT,
|
|
ingest_max_file_bytes: int = DEFAULT_INGEST_MAX_FILE_BYTES,
|
|
ingest_max_chunk_bytes: int = DEFAULT_INGEST_MAX_CHUNK_BYTES,
|
|
ingest_max_files_per_job: int = DEFAULT_INGEST_MAX_FILES_PER_JOB,
|
|
ingest_max_request_bytes: int = DEFAULT_INGEST_MAX_REQUEST_BYTES,
|
|
ingest_job_timeout_seconds: int = DEFAULT_INGEST_JOB_TIMEOUT_SECONDS,
|
|
query_max_chars: int = DEFAULT_QUERY_MAX_CHARS,
|
|
query_job_timeout_seconds: int = DEFAULT_QUERY_JOB_TIMEOUT_SECONDS,
|
|
) -> None:
|
|
token = read_token_from_env(token_env)
|
|
try:
|
|
import uvicorn
|
|
except ImportError as exc:
|
|
raise RuntimeError('HTTP MCP support requires uvicorn. Run: pip install "obsidian-wiki[mcp]"') from exc
|
|
|
|
ingest_manager = None
|
|
if enable_ingest:
|
|
project = Path(project_root).expanduser().resolve() if project_root is not None else Path.cwd().resolve()
|
|
ingest_runtime = (
|
|
Path(ingest_runtime_root).expanduser().resolve()
|
|
if ingest_runtime_root is not None
|
|
else project / ".runtime" / "mcp-ingest"
|
|
)
|
|
ingest_manager = IngestManager(
|
|
vault_path=vault_path,
|
|
project_root=project,
|
|
runtime_root=ingest_runtime,
|
|
opencode_path=opencode_path,
|
|
opencode_model=ingest_opencode_model,
|
|
opencode_variant=ingest_opencode_variant,
|
|
max_file_bytes=ingest_max_file_bytes,
|
|
max_chunk_bytes=ingest_max_chunk_bytes,
|
|
max_files_per_job=ingest_max_files_per_job,
|
|
max_request_bytes=ingest_max_request_bytes,
|
|
job_timeout_seconds=ingest_job_timeout_seconds,
|
|
)
|
|
|
|
query_manager = None
|
|
if enable_query:
|
|
project = Path(project_root).expanduser().resolve() if project_root is not None else Path.cwd().resolve()
|
|
query_runtime = (
|
|
Path(query_runtime_root).expanduser().resolve()
|
|
if query_runtime_root is not None
|
|
else project / ".runtime" / "mcp-query"
|
|
)
|
|
try:
|
|
query_manager = QueryManager(
|
|
vault_path=vault_path,
|
|
project_root=project,
|
|
runtime_root=query_runtime,
|
|
opencode_path=opencode_path,
|
|
opencode_model=query_opencode_model,
|
|
opencode_variant=query_opencode_variant,
|
|
max_query_chars=query_max_chars,
|
|
job_timeout_seconds=query_job_timeout_seconds,
|
|
)
|
|
except Exception:
|
|
if ingest_manager is not None:
|
|
ingest_manager.close()
|
|
raise
|
|
|
|
server = None
|
|
try:
|
|
server = create_server(
|
|
vault_path=vault_path,
|
|
db_path=db_path,
|
|
profile_name=profile_name,
|
|
max_file_bytes=max_file_bytes,
|
|
expose_paths=expose_paths,
|
|
allow_source_files=allow_source_files,
|
|
host=host,
|
|
port=port,
|
|
streamable_http_path=streamable_http_path,
|
|
allowed_hosts=allowed_hosts,
|
|
allowed_origins=allowed_origins,
|
|
enable_file_assets=enable_file_assets,
|
|
enable_ingest=enable_ingest,
|
|
enable_query=enable_query,
|
|
project_root=project_root,
|
|
ingest_runtime_root=ingest_runtime_root,
|
|
query_runtime_root=query_runtime_root,
|
|
opencode_path=opencode_path,
|
|
ingest_opencode_model=ingest_opencode_model,
|
|
ingest_opencode_variant=ingest_opencode_variant,
|
|
query_opencode_model=query_opencode_model,
|
|
query_opencode_variant=query_opencode_variant,
|
|
ingest_max_file_bytes=ingest_max_file_bytes,
|
|
ingest_max_chunk_bytes=ingest_max_chunk_bytes,
|
|
ingest_max_files_per_job=ingest_max_files_per_job,
|
|
ingest_max_request_bytes=ingest_max_request_bytes,
|
|
ingest_job_timeout_seconds=ingest_job_timeout_seconds,
|
|
query_max_chars=query_max_chars,
|
|
query_job_timeout_seconds=query_job_timeout_seconds,
|
|
ingest_manager=ingest_manager,
|
|
query_manager=query_manager,
|
|
)
|
|
app = create_http_app(
|
|
server,
|
|
token,
|
|
ingest_manager=ingest_manager,
|
|
allowed_hosts=allowed_hosts,
|
|
allowed_origins=allowed_origins,
|
|
)
|
|
uvicorn.run(app, host=host, port=port, log_level="info")
|
|
finally:
|
|
if server is not None:
|
|
close_server_managers(server)
|
|
else:
|
|
for manager in (ingest_manager, query_manager):
|
|
if manager is not None:
|
|
manager.close()
|
|
|
|
|
|
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",
|
|
default=None,
|
|
help="include archive/absolute paths in tool output",
|
|
)
|
|
source_file_group = parser.add_mutually_exclusive_group()
|
|
source_file_group.add_argument(
|
|
"--allow-source-files",
|
|
dest="allow_source_files",
|
|
action="store_true",
|
|
default=None,
|
|
help="allow file resource bytes to be downloaded",
|
|
)
|
|
source_file_group.add_argument(
|
|
"--deny-source-files",
|
|
dest="allow_source_files",
|
|
action="store_false",
|
|
help="deny file resource bytes while retaining metadata tools",
|
|
)
|
|
parser.add_argument(
|
|
"--transport",
|
|
choices=["stdio", "streamable-http"],
|
|
default=None,
|
|
help="MCP transport. HTTP mode requires a bearer token.",
|
|
)
|
|
parser.add_argument("--host", default=None, help="HTTP bind host. Used only with --transport streamable-http.")
|
|
parser.add_argument("--port", default=None, type=int, help="HTTP port. Used only with --transport streamable-http.")
|
|
parser.add_argument("--path", default=None, help="Streamable HTTP MCP path.")
|
|
parser.add_argument("--token-env", default=None, help="Environment variable containing the HTTP bearer token.")
|
|
parser.add_argument(
|
|
"--allowed-host",
|
|
action="append",
|
|
dest="allowed_hosts",
|
|
help="Allowed HTTP Host pattern for DNS rebinding protection. Can be repeated.",
|
|
)
|
|
parser.add_argument(
|
|
"--allowed-origin",
|
|
action="append",
|
|
dest="allowed_origins",
|
|
help="Allowed Origin pattern for DNS rebinding protection. Can be repeated.",
|
|
)
|
|
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,
|
|
allow_source_files=args.allow_source_files,
|
|
)
|
|
transport = args.transport or runtime_config_value("OBSIDIAN_MCP_TRANSPORT", "stdio")
|
|
host = args.host or runtime_config_value("OBSIDIAN_MCP_HOST", DEFAULT_HTTP_HOST)
|
|
port = args.port if args.port is not None else runtime_config_int("OBSIDIAN_MCP_PORT", DEFAULT_HTTP_PORT)
|
|
http_path = args.path or runtime_config_value("OBSIDIAN_MCP_PATH", DEFAULT_HTTP_PATH)
|
|
token_env = args.token_env or runtime_config_value("OBSIDIAN_MCP_TOKEN_ENV", DEFAULT_TOKEN_ENV)
|
|
enabled_tools = enabled_tool_names()
|
|
enable_file_assets = TOOL_FILE_ASSETS in enabled_tools
|
|
enable_ingest = TOOL_INGEST in enabled_tools
|
|
enable_query = TOOL_QUERY in enabled_tools
|
|
ingest_max_file_bytes = runtime_config_int("OBSIDIAN_MCP_INGEST_MAX_FILE_BYTES", DEFAULT_INGEST_MAX_FILE_BYTES)
|
|
ingest_max_chunk_bytes = runtime_config_int("OBSIDIAN_MCP_INGEST_MAX_CHUNK_BYTES", DEFAULT_INGEST_MAX_CHUNK_BYTES)
|
|
ingest_max_files_per_job = runtime_config_int(
|
|
"OBSIDIAN_MCP_INGEST_MAX_FILES_PER_JOB",
|
|
DEFAULT_INGEST_MAX_FILES_PER_JOB,
|
|
)
|
|
ingest_max_request_bytes = runtime_config_int(
|
|
"OBSIDIAN_MCP_INGEST_MAX_REQUEST_BYTES",
|
|
DEFAULT_INGEST_MAX_REQUEST_BYTES,
|
|
)
|
|
opencode_path = runtime_config_value("OBSIDIAN_MCP_OPENCODE_PATH", "")
|
|
if not opencode_path:
|
|
opencode_path = runtime_config_value("OBSIDIAN_MCP_INGEST_OPENCODE_PATH", DEFAULT_OPENCODE_PATH)
|
|
ingest_runtime_root = runtime_config_value(
|
|
"OBSIDIAN_MCP_INGEST_RUNTIME_ROOT",
|
|
str(Path.cwd() / ".runtime" / "mcp-ingest"),
|
|
)
|
|
query_runtime_root = runtime_config_value(
|
|
"OBSIDIAN_MCP_QUERY_RUNTIME_ROOT",
|
|
str(Path.cwd() / ".runtime" / "mcp-query"),
|
|
)
|
|
ingest_opencode_model = runtime_config_value("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL", DEFAULT_INGEST_OPENCODE_MODEL)
|
|
ingest_opencode_variant = runtime_config_value("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT", DEFAULT_INGEST_OPENCODE_VARIANT)
|
|
query_opencode_model = runtime_config_value("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL", DEFAULT_QUERY_OPENCODE_MODEL)
|
|
query_opencode_variant = runtime_config_value("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT", DEFAULT_QUERY_OPENCODE_VARIANT)
|
|
query_max_chars = runtime_config_int("OBSIDIAN_MCP_QUERY_MAX_CHARS", DEFAULT_QUERY_MAX_CHARS)
|
|
ingest_job_timeout_seconds = runtime_config_int(
|
|
"OBSIDIAN_MCP_INGEST_JOB_TIMEOUT_SECONDS",
|
|
DEFAULT_INGEST_JOB_TIMEOUT_SECONDS,
|
|
)
|
|
query_job_timeout_seconds = runtime_config_int(
|
|
"OBSIDIAN_MCP_QUERY_JOB_TIMEOUT_SECONDS",
|
|
DEFAULT_QUERY_JOB_TIMEOUT_SECONDS,
|
|
)
|
|
allowed_hosts = args.allowed_hosts if args.allowed_hosts is not None else runtime_config_list(ALLOWED_HOSTS_ENV)
|
|
allowed_origins = (
|
|
args.allowed_origins if args.allowed_origins is not None else runtime_config_list(ALLOWED_ORIGINS_ENV)
|
|
)
|
|
try:
|
|
if transport == "stdio":
|
|
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"],
|
|
allow_source_files=options["allow_source_files"],
|
|
enable_file_assets=enable_file_assets,
|
|
enable_ingest=enable_ingest,
|
|
enable_query=enable_query,
|
|
project_root=Path.cwd(),
|
|
ingest_runtime_root=ingest_runtime_root,
|
|
query_runtime_root=query_runtime_root,
|
|
opencode_path=opencode_path,
|
|
ingest_opencode_model=ingest_opencode_model,
|
|
ingest_opencode_variant=ingest_opencode_variant,
|
|
query_opencode_model=query_opencode_model,
|
|
query_opencode_variant=query_opencode_variant,
|
|
ingest_max_file_bytes=ingest_max_file_bytes,
|
|
ingest_max_chunk_bytes=ingest_max_chunk_bytes,
|
|
ingest_max_files_per_job=ingest_max_files_per_job,
|
|
ingest_max_request_bytes=ingest_max_request_bytes,
|
|
ingest_job_timeout_seconds=ingest_job_timeout_seconds,
|
|
query_max_chars=query_max_chars,
|
|
query_job_timeout_seconds=query_job_timeout_seconds,
|
|
)
|
|
try:
|
|
try:
|
|
server.run(transport="stdio")
|
|
except TypeError:
|
|
server.run()
|
|
finally:
|
|
close_server_managers(server)
|
|
return 0
|
|
if transport != "streamable-http":
|
|
raise RuntimeError(f"unsupported MCP transport: {transport}")
|
|
run_http_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"],
|
|
allow_source_files=options["allow_source_files"],
|
|
host=host,
|
|
port=port,
|
|
token_env=token_env,
|
|
streamable_http_path=http_path,
|
|
allowed_hosts=allowed_hosts,
|
|
allowed_origins=allowed_origins,
|
|
enable_file_assets=enable_file_assets,
|
|
enable_ingest=enable_ingest,
|
|
enable_query=enable_query,
|
|
project_root=Path.cwd(),
|
|
ingest_runtime_root=ingest_runtime_root,
|
|
query_runtime_root=query_runtime_root,
|
|
opencode_path=opencode_path,
|
|
ingest_opencode_model=ingest_opencode_model,
|
|
ingest_opencode_variant=ingest_opencode_variant,
|
|
query_opencode_model=query_opencode_model,
|
|
query_opencode_variant=query_opencode_variant,
|
|
ingest_max_file_bytes=ingest_max_file_bytes,
|
|
ingest_max_chunk_bytes=ingest_max_chunk_bytes,
|
|
ingest_max_files_per_job=ingest_max_files_per_job,
|
|
ingest_max_request_bytes=ingest_max_request_bytes,
|
|
ingest_job_timeout_seconds=ingest_job_timeout_seconds,
|
|
query_max_chars=query_max_chars,
|
|
query_job_timeout_seconds=query_job_timeout_seconds,
|
|
)
|
|
except (RuntimeError, FileNotFoundError, AssetAccessError, AssetNotFoundError, AssetTooLargeError, IngestError, QueryError) 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 _normalize_public_base_url(raw_url: str | None) -> str:
|
|
value = clean_config_value(str(raw_url or "")).strip().rstrip("/")
|
|
if not value:
|
|
return ""
|
|
parsed = urlsplit(value)
|
|
if not parsed.scheme or not parsed.netloc:
|
|
return ""
|
|
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", ""))
|
|
|
|
|
|
def _normalize_public_upload_url(raw_url: str | None) -> str:
|
|
value = clean_config_value(str(raw_url or "")).strip().rstrip("/")
|
|
if not value:
|
|
return ""
|
|
parsed = urlsplit(value)
|
|
if not parsed.scheme or not parsed.netloc:
|
|
return ""
|
|
path = parsed.path.rstrip("/") or DEFAULT_INGEST_UPLOAD_PATH
|
|
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
|
|
|
|
|
def _upload_url_from_public_base(public_base_url: str, upload_path: str) -> str | None:
|
|
if not public_base_url:
|
|
return None
|
|
parsed = urlsplit(public_base_url)
|
|
return urlunsplit((parsed.scheme, parsed.netloc, upload_path, "", ""))
|
|
|
|
|
|
def _recommended_first_steps(*, enable_file_assets: bool, enable_ingest: bool, enable_query: bool) -> list[str]:
|
|
steps: list[str] = []
|
|
if enable_query:
|
|
steps.append("Use ask_wiki for ordinary wiki, business facts, or hybrid questions.")
|
|
if enable_file_assets:
|
|
steps.append("Use search_file_assets before reading source file resources.")
|
|
if enable_ingest:
|
|
steps.append("Use ingest_text_note for short text notes.")
|
|
steps.append("Use create_ingest_upload_ticket before remote HTTP file upload.")
|
|
if not steps:
|
|
steps.append("No optional MCP tool groups are enabled; ask the operator to enable file-assets, query, or ingest.")
|
|
return steps
|
|
|
|
|
|
def _file_index_capabilities(vault: Path, db: Path) -> dict[str, Any]:
|
|
registry = registry_path_for_vault(vault)
|
|
record_count: int | None = None
|
|
last_rebuild_at = _path_mtime_iso(db) if db.is_file() else None
|
|
if db.is_file():
|
|
try:
|
|
conn = sqlite3.connect(str(db))
|
|
record_count = int(conn.execute("SELECT COUNT(*) FROM file_assets").fetchone()[0])
|
|
except sqlite3.Error:
|
|
record_count = None
|
|
finally:
|
|
try:
|
|
conn.close()
|
|
except UnboundLocalError:
|
|
pass
|
|
return {
|
|
"exists": db.is_file(),
|
|
"registry_exists": registry.is_file(),
|
|
"record_count": record_count,
|
|
"last_rebuild_at": last_rebuild_at,
|
|
"last_index_rebuild_at": last_rebuild_at,
|
|
}
|
|
|
|
|
|
def _path_mtime_iso(path: Path) -> str | None:
|
|
try:
|
|
from datetime import datetime, timezone
|
|
|
|
return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _ingest_queue_summary(manager: IngestManager) -> dict[str, int]:
|
|
jobs = manager._iter_jobs()
|
|
return {
|
|
"queued": sum(1 for job in jobs if job.get("state") == "queued"),
|
|
"running": sum(1 for job in jobs if job.get("state") == "running"),
|
|
"terminal": sum(1 for job in jobs if job.get("state") in {"succeeded", "failed", "cancelled"}),
|
|
}
|
|
|
|
|
|
def _last_ingest_at(manager: IngestManager) -> str | None:
|
|
timestamps = []
|
|
for job in manager._iter_jobs():
|
|
for key in ("finished_at", "started_at", "created_at"):
|
|
value = str(job.get(key) or "")
|
|
if value:
|
|
timestamps.append(value)
|
|
break
|
|
return max(timestamps) if timestamps else None
|
|
|
|
|
|
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())
|