Expose authoritative ingest phases and supervisor-backed process liveness without semantic activity inference. Require deterministic registry, manifest, archive, file-index, and declared-output verification before an agent-successful job can finish succeeded.\n\nPreserve legacy state values, derive defaults for older job records, keep verification outside the manager lock, and retain partial outputs on verification failure.\n\nValidated with the full unittest suite (205 tests).
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": True,
|
|
"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=true' "
|
|
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": True,
|
|
"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 = True,
|
|
) -> 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 = True,
|
|
) -> 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", True))
|
|
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())
|