feat(mcp): harden remote assets and job execution

Gate source-file downloads and verify registry paths and content hashes. Stream multipart uploads with bounded limits, supervise OpenCode jobs with explicit result markers and timeouts, and preserve managed endpoint security settings. Document the trusted-client boundary and add focused regression coverage.

Validation: PYTHONPATH=<staged-tree> python -m unittest discover -s tests (182 tests)
This commit is contained in:
Codex
2026-07-09 21:44:48 +00:00
parent d47bb2c549
commit 7eb9b987ab
16 changed files with 3709 additions and 500 deletions
+452 -193
View File
@@ -3,14 +3,13 @@
from __future__ import annotations
import argparse
import asyncio
import json
import hmac
import os
import sqlite3
import sys
from email import policy
from email.parser import BytesParser
from fnmatch import fnmatch
import threading
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
@@ -19,9 +18,11 @@ 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,
@@ -32,6 +33,7 @@ from obsidian_wiki.mcp_ingest import (
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,
@@ -56,9 +58,13 @@ 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 = 1
TOOLS_VERSION = 1
CAPABILITIES_SCHEMA_VERSION = 2
TOOLS_VERSION = 2
TOOL_FILE_ASSETS = "file-assets"
TOOL_INGEST = "ingest"
TOOL_QUERY = "query"
@@ -178,7 +184,7 @@ class BearerTokenAuthMiddleware:
await self.app(scope, receive, send)
class RequestBodyTooLarge(IngestError):
class RequestBodyTooLarge(IngestError, OSError):
"""Raised when an HTTP ingest upload body exceeds the configured request limit."""
@@ -189,6 +195,7 @@ def create_server(
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,
@@ -209,7 +216,10 @@ def create_server(
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,
):
@@ -227,8 +237,10 @@ def create_server(
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("OBSIDIAN_MCP_PUBLIC_BASE_URL", ""))
public_upload_url = _upload_url_from_public_base(public_base, DEFAULT_INGEST_UPLOAD_PATH)
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
@@ -264,6 +276,7 @@ def create_server(
"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 [])
@@ -297,15 +310,15 @@ def create_server(
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": [
"Use ask_wiki for ordinary wiki, business facts, or hybrid questions when query is enabled.",
"Use search_file_assets before reading source file resources.",
"Use ingest_text_note for short text notes when ingest is enabled.",
"Use create_ingest_upload_ticket before remote HTTP file upload.",
],
"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,
@@ -322,6 +335,7 @@ def create_server(
"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,
@@ -353,6 +367,7 @@ def create_server(
"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.",
@@ -370,7 +385,7 @@ def create_server(
"http_upload_url_warning": (
None
if public_upload_url or not enable_ingest
else "Set OBSIDIAN_MCP_PUBLIC_BASE_URL so remote agents can receive a complete 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."
),
"preferred_upload_auth": "upload_ticket" if enable_ingest else None,
"tools": (
@@ -391,6 +406,10 @@ def create_server(
),
"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": (
@@ -404,13 +423,21 @@ def create_server(
),
},
"safety": {
"arbitrary_shell": False,
"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(
@@ -423,7 +450,10 @@ def create_server(
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]:
@@ -431,7 +461,7 @@ def create_server(
upload_url_warning = (
None
if public_upload_url
else "Set OBSIDIAN_MCP_PUBLIC_BASE_URL so remote agents can receive a complete 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",
@@ -472,6 +502,7 @@ def create_server(
"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",
@@ -529,7 +560,7 @@ def create_server(
"upload_url_warning": (
None
if public_upload_url
else "Set OBSIDIAN_MCP_PUBLIC_BASE_URL so remote agents can receive a complete 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,
@@ -577,19 +608,25 @@ def create_server(
@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 ingest_manager.check_opencode_readiness(run_smoke_test=run_smoke_test)
return _public_readiness(ingest_manager, run_smoke_test=run_smoke_test)
if enable_query:
if query_manager is None:
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,
)
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(
@@ -643,7 +680,7 @@ def create_server(
@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 query_manager.check_opencode_readiness(run_smoke_test=run_smoke_test)
return _public_readiness(query_manager, run_smoke_test=run_smoke_test)
if enable_file_assets:
@@ -722,6 +759,7 @@ def create_server(
db_path=db,
profile_name=profile_name,
max_file_bytes=max_file_bytes,
allow_source_files=allow_source_files,
)
return resource["bytes"]
@@ -730,20 +768,40 @@ def create_server(
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. "
"This MCP is intentionally limited to safe engineering files such as drawings, CAD/3D models, "
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:
@@ -767,6 +825,30 @@ def runtime_config_int(key: str, default: int) -> int:
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,
@@ -782,8 +864,10 @@ class IngestUploadRouter:
self.ingest_manager = ingest_manager
self.upload_path = upload_path
self.bearer_auth_token = bearer_auth_token
self.allowed_hosts = allowed_hosts or []
self.allowed_origins = allowed_origins or []
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:
@@ -809,19 +893,19 @@ class IngestUploadRouter:
try:
data = await self._handle_upload(scope, receive, upload_ticket=upload_ticket)
except RequestBodyTooLarge as exc:
await _send_json(send, 413, {"error": str(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, {"error": str(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.allowed_hosts and (not host or not _matches_host(host, self.allowed_hosts)):
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.allowed_origins and not _matches_any(origin, self.allowed_origins):
if origin and self.enforce_allowed_origins and not _matches_any(origin, self.allowed_origins):
return False
return True
@@ -832,7 +916,7 @@ class IngestUploadRouter:
return True
if _header_value(scope, b"x-obsidian-wiki-upload-token"):
return True
return bool(_query_param(scope, "upload_token"))
return False
def _authorize_upload(self, scope: dict[str, Any]) -> dict[str, Any] | None:
authorization = _header_value(scope, b"authorization")
@@ -842,8 +926,6 @@ class IngestUploadRouter:
if self.ingest_manager is None:
raise IngestError("unauthorized")
upload_token = _header_value(scope, b"x-obsidian-wiki-upload-token")
if not upload_token:
upload_token = _query_param(scope, "upload_token")
if not upload_token:
raise IngestError("unauthorized")
return self.ingest_manager.consume_upload_ticket(upload_token)
@@ -858,30 +940,135 @@ class IngestUploadRouter:
manager = self.ingest_manager
if manager is None:
raise IngestError("ingest is not enabled")
body = await _read_http_body(receive, max_bytes=_max_upload_body_bytes(manager, upload_ticket))
content_type = _header_value(scope, b"content-type")
fields, files = _parse_multipart_form(content_type, body)
if not files:
raise IngestError("multipart field 'files' must include at least one file")
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
if len(files) > max_files:
raise IngestError(f"too many files for one ingest job: {len(files)} > {max_files}")
project = _first_field(fields, "project") or (upload_ticket or {}).get("project")
doc_type_hint = _first_field(fields, "doc_type_hint") or (upload_ticket or {}).get("doc_type_hint")
note = _first_field(fields, "note") or (upload_ticket or {}).get("note")
start_default = bool((upload_ticket or {}).get("start_job", True))
start_job = parse_bool(_first_field(fields, "start_job"), default=start_default)
uploads = [
manager.create_completed_upload(
file_name=file["file_name"],
payload=file["payload"],
project=project,
doc_type_hint=doc_type_hint,
)
for file in files
]
job = manager.start_ingest_job([upload["upload_id"] for upload in uploads], note=note) if start_job else None
return {"uploads": uploads, "job": 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=8,
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)
]
if not files:
raise IngestError("multipart field 'files' must include at least one file")
if len(files) > max_files:
raise IngestError(f"too many files for one ingest job: {len(files)} > {max_files}")
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")
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
uploads = []
try:
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)
job = manager.start_ingest_job([upload["upload_id"] for upload in uploads], note=note) if start_job else None
return {"uploads": uploads, "job": job}
except BaseException:
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(
@@ -905,31 +1092,16 @@ def create_http_app(
return BearerTokenAuthMiddleware(app, token, bypass_paths={upload_path})
async def _read_http_body(receive: Any, *, max_bytes: int | None = None) -> bytes:
chunks: list[bytes] = []
received = 0
while True:
message = await receive()
if message.get("type") == "http.disconnect":
break
if message.get("type") != "http.request":
continue
chunk = message.get("body", b"")
received += len(chunk)
if max_bytes is not None and received > max_bytes:
raise RequestBodyTooLarge(f"request body exceeds max upload size: {received} > {max_bytes}")
chunks.append(chunk)
if not message.get("more_body", False):
break
return b"".join(chunks)
def _max_upload_body_bytes(manager: IngestManager, upload_ticket: dict[str, Any] | None) -> int | None:
if manager.max_file_bytes <= 0:
return None
max_files = int(upload_ticket.get("max_files", manager.max_files_per_job)) if upload_ticket else manager.max_files_per_job
overhead = max(16 * 1024, max_files * 4096)
return manager.max_file_bytes * max_files + overhead
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:
@@ -937,68 +1109,29 @@ def _header_value(scope: dict[str, Any], name: bytes) -> str:
return headers.get(name.lower(), b"").decode("latin1")
def _query_param(scope: dict[str, Any], name: str) -> str:
from urllib.parse import parse_qs
raw = scope.get("query_string", b"")
query = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else str(raw)
values = parse_qs(query).get(name, [])
return values[0] if values else ""
def _matches_any(value: str, patterns: list[str]) -> bool:
return any(fnmatch(value, pattern) for pattern in patterns)
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:
if _matches_any(value, patterns):
return True
host_only = value
if value.startswith("[") and "]" in value:
host_only = value[1 : value.index("]")]
elif ":" in value:
host_only = value.split(":", 1)[0]
return _matches_any(host_only, patterns)
return _matches_any(value, patterns)
def _parse_multipart_form(content_type: str, body: bytes) -> tuple[dict[str, list[str]], list[dict[str, Any]]]:
if not content_type.lower().startswith("multipart/form-data"):
raise IngestError("content-type must be multipart/form-data")
message = BytesParser(policy=policy.default).parsebytes(
b"Content-Type: "
+ content_type.encode("latin1")
+ b"\r\nMIME-Version: 1.0\r\n\r\n"
+ body
)
if not message.is_multipart():
raise IngestError("multipart request body is invalid")
fields: dict[str, list[str]] = {}
files: list[dict[str, Any]] = []
for part in message.iter_parts():
if part.get_content_disposition() != "form-data":
continue
field_name = part.get_param("name", header="content-disposition")
if not field_name:
continue
payload = part.get_payload(decode=True)
if payload is None:
content = part.get_content()
payload = content.encode(part.get_content_charset() or "utf-8") if isinstance(content, str) else bytes(content)
file_name = part.get_filename()
if file_name is None:
fields.setdefault(field_name, []).append(payload.decode(part.get_content_charset() or "utf-8", errors="replace"))
continue
if field_name != "files":
continue
files.append({"file_name": file_name, "payload": payload})
return fields, files
def _first_field(fields: dict[str, list[str]], name: str) -> str | None:
values = fields.get(name) or []
if not values:
return None
return values[0]
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:
@@ -1016,6 +1149,33 @@ async def _send_json(send: Any, status: int, payload: dict[str, Any]) -> None:
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,
@@ -1023,6 +1183,7 @@ def run_http_server(
profile_name: str,
max_file_bytes: int,
expose_paths: bool,
allow_source_files: bool = False,
host: str,
port: int,
token_env: str,
@@ -1043,7 +1204,10 @@ def run_http_server(
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:
@@ -1069,6 +1233,8 @@ def run_http_server(
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
@@ -1079,53 +1245,72 @@ def run_http_server(
if query_runtime_root is not None
else project / ".runtime" / "mcp-query"
)
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,
)
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 = create_server(
vault_path=vault_path,
db_path=db_path,
profile_name=profile_name,
max_file_bytes=max_file_bytes,
expose_paths=expose_paths,
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,
query_max_chars=query_max_chars,
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")
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:
@@ -1134,7 +1319,26 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--db", metavar="PATH", help="SQLite file asset index path")
parser.add_argument("--profile", default=None, help=f"bot profile name, default: {DEFAULT_PROFILE_NAME}")
parser.add_argument("--max-file-bytes", type=int, default=None, help="maximum resource size, 0 disables the limit")
parser.add_argument("--expose-paths", action="store_true", help="include archive/absolute paths in tool output")
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"],
@@ -1167,6 +1371,7 @@ def main(argv: list[str] | None = None) -> int:
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)
@@ -1183,6 +1388,10 @@ def main(argv: list[str] | None = None) -> 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)
@@ -1199,6 +1408,18 @@ def main(argv: list[str] | None = None) -> int:
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(
@@ -1207,6 +1428,7 @@ def main(argv: list[str] | None = None) -> int:
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,
@@ -1221,12 +1443,18 @@ def main(argv: list[str] | None = None) -> int:
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:
server.run(transport="stdio")
except TypeError:
server.run()
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}")
@@ -1236,12 +1464,13 @@ def main(argv: list[str] | None = None) -> int:
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=args.allowed_hosts,
allowed_origins=args.allowed_origins,
allowed_hosts=allowed_hosts,
allowed_origins=allowed_origins,
enable_file_assets=enable_file_assets,
enable_ingest=enable_ingest,
enable_query=enable_query,
@@ -1256,7 +1485,10 @@ def main(argv: list[str] | None = None) -> int:
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)
@@ -1283,6 +1515,17 @@ def _normalize_public_base_url(raw_url: str | None) -> str:
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
@@ -1290,9 +1533,24 @@ def _upload_url_from_public_base(public_base_url: str, upload_path: str) -> str
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))
@@ -1308,7 +1566,8 @@ def _file_index_capabilities(vault: Path, db: Path) -> dict[str, Any]:
"exists": db.is_file(),
"registry_exists": registry.is_file(),
"record_count": record_count,
"last_rebuild_at": _path_mtime_iso(db) if db.is_file() else None,
"last_rebuild_at": last_rebuild_at,
"last_index_rebuild_at": last_rebuild_at,
}