feat(mcp): add simplified ingest uploads

Add a one-shot IngestManager upload path, an ingest_file_base64 MCP tool, and an authenticated /mcp-ingest/uploads multipart HTTP endpoint so remote agents can transfer files without manual chunk orchestration.

Keep uploaded files in runtime staging outside the vault and let wiki-ingest/OpenCode decide archival, registry updates, and index rebuilds.

Update service output, docs, and agent rules to make the preferred upload path discoverable while preserving chunked tools as a fallback.

Validation: python3 -m unittest discover -v; python3 -m py_compile obsidian_wiki/mcp_ingest.py obsidian_wiki/mcp_server.py scripts/manage_mcp_service.py tests/test_mcp_ingest.py tests/test_mcp_server_remote.py tests/test_mcp_service.py; git diff --check
This commit is contained in:
Codex
2026-07-05 16:32:33 +00:00
parent ef2e44656d
commit d7daf2f210
9 changed files with 493 additions and 15 deletions
+3
View File
@@ -56,6 +56,9 @@ OBSIDIAN_MCP_ENABLED_TOOLS=file-assets
# After enabling, call the MCP tool `check_ingest_opencode_readiness` before
# the first upload; set run_smoke_test=true to verify OpenCode can load the
# project wiki-ingest skill without editing files.
# Preferred remote file upload path: POST multipart/form-data to
# /mcp-ingest/uploads with the same Authorization bearer token as /mcp. Agents
# can also call `get_ingest_upload_instructions` for exact fields/examples.
# Keep this runtime root outside OBSIDIAN_VAULT_PATH; uploads are staging inputs,
# and wiki-ingest decides how sources are archived into the vault.
# Prefer OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest for new configs. This
+1 -1
View File
@@ -130,7 +130,7 @@ See `wiki-query` and `wiki-export` skills for how the filter is applied.
- **Keep product lookup fields explicit.** Carry forward `bjd_part_numbers`, `competitor_part_numbers`, `series`, `pitch`, `pin_count`, `product_lines`, `customers`, `competitors`, `standards`, `business_categories`, `related_pages`, and `catalog_paths` whenever they are known.
- **Merge, do not overwrite.** Automatic identifier extraction may add terms, but it must not discard curated registry metadata. Registry values are the durable source for future index rebuilds and remote MCP lookup.
- **Prefer precise identifiers in search ranking.** Exact or prefix matches in structured part-number and series fields should rank above incidental filename substrings such as timestamps or archive hashes.
- **Keep remote ingest gated.** MCP ingest tools are write-capable and must remain disabled unless the service config explicitly enables the `ingest` tool group via `OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest`, `mcp_service.sh tools enable ingest`, or `mcp_service.sh start --enable-ingest`. Uploaded files must stage outside the vault under the MCP runtime root; `wiki-ingest` is responsible for accepting, archiving, and indexing sources inside the vault. They may launch local OpenCode only through the fixed `/wiki-ingest` prompt; never expose arbitrary prompt or shell execution through MCP. Remote ingest must keep the OpenCode readiness preflight in front of job launch, and `check_ingest_opencode_readiness(run_smoke_test=true)` should stay available for operators to verify that OpenCode can load the project `wiki-ingest` skill before accepting uploads.
- **Keep remote ingest gated.** MCP ingest tools are write-capable and must remain disabled unless the service config explicitly enables the `ingest` tool group via `OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest`, `mcp_service.sh tools enable ingest`, or `mcp_service.sh start --enable-ingest`. Uploaded files must stage outside the vault under the MCP runtime root; `wiki-ingest` is responsible for accepting, archiving, and indexing sources inside the vault. Prefer the HTTP multipart endpoint `/mcp-ingest/uploads` for remote file transfer, then `ingest_file_base64` for small files, and keep chunked upload tools only as a compatibility fallback. They may launch local OpenCode only through the fixed `/wiki-ingest` prompt; never expose arbitrary prompt or shell execution through MCP. Remote ingest must keep the OpenCode readiness preflight in front of job launch, and `check_ingest_opencode_readiness(run_smoke_test=true)` should stay available for operators to verify that OpenCode can load the project `wiki-ingest` skill before accepting uploads.
## Core Principles
+16 -1
View File
@@ -368,7 +368,7 @@ For long-running access from another machine, use the managed user service:
bash scripts/mcp_service.sh start
```
`mcp_service.sh start` creates `.runtime/obsidian-wiki-mcp.env` with `0600` permissions if it does not exist, writes `~/.config/systemd/user/obsidian-wiki-mcp.service`, starts the service, and prints the remote URL, `Authorization` header, enabled tool groups, config file path, and client JSON to copy into another agent. It serves the configured `OBSIDIAN_VAULT_PATH` by default; pass `--vault /path/to/vault` to override. The service defaults to `0.0.0.0:8765/mcp` because it is intended for remote clients, but it still requires the bearer token. Prefer SSH tunnel, Tailscale, WireGuard, or another trusted network boundary instead of directly exposing the port to the public internet.
`mcp_service.sh start` creates `.runtime/obsidian-wiki-mcp.env` with `0600` permissions if it does not exist, writes `~/.config/systemd/user/obsidian-wiki-mcp.service`, starts the service, and prints the remote URL, `Authorization` header, enabled tool groups, config file path, and client JSON to copy into another agent. When `ingest` is enabled, it also prints the `/mcp-ingest/uploads` file upload endpoint. It serves the configured `OBSIDIAN_VAULT_PATH` by default; pass `--vault /path/to/vault` to override. The service defaults to `0.0.0.0:8765/mcp` because it is intended for remote clients, but it still requires the bearer token. Prefer SSH tunnel, Tailscale, WireGuard, or another trusted network boundary instead of directly exposing the port to the public internet.
Customize the first startup if needed:
@@ -400,6 +400,8 @@ After `ingest` is enabled once, later `bash scripts/mcp_service.sh start` preser
This adds write-capable MCP tools to the same `wiki-file-assets` server and bearer token. The server is tool-first: some MCP clients may show empty `resources` or `prompts` lists until a concrete `asset://source/sha256/...` URI is returned by `get_file_asset_metadata`, so use the tool list as the source of truth.
- `get_ingest_upload_instructions` — returns the preferred upload methods, HTTP endpoint path, fields, limits, and curl example for remote agents.
- `ingest_file_base64` — uploads one complete base64 file and optionally starts the local OpenCode ingest job; useful for small files or MCP clients that cannot make multipart HTTP requests.
- `create_ingest_upload` — reserves a controlled runtime staging path for one uploaded file.
- `append_ingest_upload_chunk` — appends base64 file chunks; defaults to 4MB decoded chunks.
- `complete_ingest_upload` — verifies declared size and SHA-256.
@@ -407,6 +409,19 @@ This adds write-capable MCP tools to the same `wiki-file-assets` server and bear
- `start_ingest_job` — queues a local `opencode run --auto --format json` job with a fixed `/wiki-ingest` prompt for the completed uploads.
- `get_ingest_job_status`, `get_ingest_job_result`, `cancel_ingest_job` — inspect or cancel the local job.
For binary files, prefer the HTTP upload endpoint over manual chunk tools:
```bash
curl -H "Authorization: Bearer <long-random-token>" \
-F "files=@drawing.pdf" \
-F "project=bjd" \
-F "doc_type_hint=drawing" \
-F "start_job=true" \
http://127.0.0.1:8765/mcp-ingest/uploads
```
The endpoint accepts repeated `files` fields plus optional `project`, `doc_type_hint`, `note`, and `start_job` fields. It uses the same bearer token as `/mcp`; there is no second token to manage. Keep `create_ingest_upload` / `append_ingest_upload_chunk` / `complete_ingest_upload` as a fallback for MCP clients that cannot call the HTTP endpoint and cannot send a complete base64 file in one tool call.
Run `check_ingest_opencode_readiness` before the first remote upload. Use `run_smoke_test: true` when you want OpenCode itself to prove it can load the project `wiki-ingest` skill; otherwise the static preflight is also run automatically before every `start_ingest_job`.
Uploads land under `.runtime/mcp-ingest/uploads/`, outside the Obsidian vault, and are treated only as temporary source staging. Job state and logs live under `.runtime/mcp-ingest/jobs/`. The MCP server refuses to enable ingest if `OBSIDIAN_MCP_INGEST_RUNTIME_ROOT` points inside the vault; let `wiki-ingest` decide how to archive sources into `_sources/archive/`, update registries, and rebuild the file index. The ingest prompt is generated by the MCP service, requires OpenCode to load `wiki-ingest`, and restricts OpenCode to the uploaded file list; clients cannot pass arbitrary shell commands through this interface. Because enabling ingest gives the remote token write access to the vault through local OpenCode, keep it on a trusted network and rotate the token if it has been shared too broadly.
+27
View File
@@ -120,6 +120,33 @@ class IngestManager:
self._write_json(self._upload_meta_path(upload_dir), meta)
return self._public_upload(meta)
def create_completed_upload(
self,
file_name: str,
payload: bytes,
*,
project: str | None = None,
doc_type_hint: str | None = None,
) -> dict[str, Any]:
data = bytes(payload)
digest = hashlib.sha256(data).hexdigest()
with self._lock:
upload = self.create_upload(
file_name=file_name,
size_bytes=len(data),
sha256=digest,
project=project,
doc_type_hint=doc_type_hint,
)
meta = self._load_upload(upload["upload_id"])
path = Path(meta["path"])
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
meta["received_bytes"] = len(data)
meta["updated_at"] = utc_now()
self._write_json(self._upload_meta_path(path.parent), meta)
return self.complete_upload(upload["upload_id"])
def append_upload_chunk(self, upload_id: str, *, offset: int, data_base64: str) -> dict[str, Any]:
with self._lock:
meta = self._load_upload(upload_id)
+243 -13
View File
@@ -3,9 +3,14 @@
from __future__ import annotations
import argparse
import base64
import binascii
import json
import hmac
import os
import sys
from email import policy
from email.parser import BytesParser
from pathlib import Path
from typing import Any
@@ -36,6 +41,7 @@ from obsidian_wiki.mcp_assets import (
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"
MCP_SERVER_NAME = "wiki-file-assets"
TOOL_FILE_ASSETS = "file-assets"
@@ -137,6 +143,7 @@ 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_manager: IngestManager | None = None,
):
if sys.version_info < (3, 10):
raise RuntimeError("obsidian-wiki MCP server requires Python 3.10 or newer")
@@ -192,17 +199,82 @@ def create_server(
"ingest_enabled": enable_ingest,
}
ingest_manager: IngestManager | None = None
if enable_ingest:
ingest_manager = IngestManager(
vault_path=vault,
project_root=project,
runtime_root=ingest_runtime,
opencode_path=opencode_path,
max_file_bytes=ingest_max_file_bytes,
max_chunk_bytes=ingest_max_chunk_bytes,
max_files_per_job=ingest_max_files_per_job,
)
if ingest_manager is None:
ingest_manager = IngestManager(
vault_path=vault,
project_root=project,
runtime_root=ingest_runtime,
opencode_path=opencode_path,
max_file_bytes=ingest_max_file_bytes,
max_chunk_bytes=ingest_max_chunk_bytes,
max_files_per_job=ingest_max_files_per_job,
)
@server.tool()
def get_ingest_upload_instructions() -> dict[str, Any]:
"""Return preferred upload methods and limits for remote MCP ingest clients."""
return {
"preferred_method": "POST multipart/form-data files to the HTTP upload endpoint",
"http_upload": {
"method": "POST",
"path": DEFAULT_INGEST_UPLOAD_PATH,
"file_field": "files",
"auth": "same Authorization: Bearer <token> header as the MCP endpoint",
"optional_fields": ["project", "doc_type_hint", "note", "start_job"],
"start_job_default": True,
"curl_example": (
"curl -H 'Authorization: Bearer <token>' "
"-F 'files=@drawing.pdf' -F 'project=bjd' "
"-F 'doc_type_hint=drawing' -F 'start_job=true' "
"http://<host>:<port>/mcp-ingest/uploads"
),
},
"mcp_base64_tool": {
"name": "ingest_file_base64",
"best_for": "small files or MCP clients that cannot make HTTP multipart requests",
"start_job_default": True,
},
"chunked_fallback_tools": [
"create_ingest_upload",
"append_ingest_upload_chunk",
"complete_ingest_upload",
"start_ingest_job",
],
"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,
},
"staging": {
"runtime_upload_root": str(ingest_manager.upload_root),
"vault_path": str(ingest_manager.vault),
"note": "uploads are temporary staging files outside the vault; wiki-ingest decides final archive/index placement",
},
}
@server.tool()
def ingest_file_base64(
file_name: str,
data_base64: str,
project: str | None = None,
doc_type_hint: str | None = None,
note: str | None = None,
start_job: bool = True,
) -> dict[str, Any]:
"""Upload one complete base64 file and optionally start the local OpenCode ingest job."""
try:
payload = base64.b64decode(str(data_base64).strip(), validate=True)
except (binascii.Error, ValueError) as exc:
raise IngestError("data_base64 is not valid base64") from exc
upload = ingest_manager.create_completed_upload(
file_name=file_name,
payload=payload,
project=project,
doc_type_hint=doc_type_hint,
)
job = ingest_manager.start_ingest_job([upload["upload_id"]], note=note) if start_job else None
return {"upload": upload, "job": job}
@server.tool()
def create_ingest_upload(
@@ -378,8 +450,147 @@ def runtime_config_int(key: str, default: int) -> int:
return default
def create_http_app(server: Any, token: str) -> Any:
return BearerTokenAuthMiddleware(server.streamable_http_app(), token)
class IngestUploadRouter:
def __init__(
self,
app: Any,
*,
ingest_manager: IngestManager | None = None,
upload_path: str = DEFAULT_INGEST_UPLOAD_PATH,
) -> None:
self.app = app
self.ingest_manager = ingest_manager
self.upload_path = upload_path
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 scope.get("method", "").upper() != "POST":
await _send_json(send, 405, {"error": "method_not_allowed"})
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)
except IngestError as exc:
await _send_json(send, 400, {"error": str(exc)})
return
await _send_json(send, 200, data)
async def _handle_upload(self, scope: dict[str, Any], receive: Any) -> dict[str, Any]:
body = await _read_http_body(receive)
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")
manager = self.ingest_manager
if manager is None:
raise IngestError("ingest is not enabled")
if len(files) > manager.max_files_per_job:
raise IngestError(f"too many files for one ingest job: {len(files)} > {manager.max_files_per_job}")
project = _first_field(fields, "project")
doc_type_hint = _first_field(fields, "doc_type_hint")
note = _first_field(fields, "note")
start_job = parse_bool(_first_field(fields, "start_job"), default=True)
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}
def create_http_app(
server: Any,
token: str,
*,
ingest_manager: IngestManager | None = None,
upload_path: str = DEFAULT_INGEST_UPLOAD_PATH,
) -> 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)
return BearerTokenAuthMiddleware(app, token)
async def _read_http_body(receive: Any) -> bytes:
chunks: list[bytes] = []
while True:
message = await receive()
if message.get("type") == "http.disconnect":
break
if message.get("type") != "http.request":
continue
chunks.append(message.get("body", b""))
if not message.get("more_body", False):
break
return b"".join(chunks)
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 _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]
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})
def run_http_server(
@@ -410,6 +621,24 @@ def run_http_server(
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,
max_file_bytes=ingest_max_file_bytes,
max_chunk_bytes=ingest_max_chunk_bytes,
max_files_per_job=ingest_max_files_per_job,
)
server = create_server(
vault_path=vault_path,
db_path=db_path,
@@ -429,8 +658,9 @@ def run_http_server(
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_manager=ingest_manager,
)
app = create_http_app(server, token)
app = create_http_app(server, token, ingest_manager=ingest_manager)
uvicorn.run(app, host=host, port=port, log_level="info")
+18
View File
@@ -28,6 +28,7 @@ MCP_CLIENT_NAME = "wiki-file-assets"
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8765
DEFAULT_PATH = "/mcp"
DEFAULT_INGEST_UPLOAD_PATH = "/mcp-ingest/uploads"
DEFAULT_TOKEN_BYTES = 32
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
DEFAULT_OPENCODE_PATH = "opencode"
@@ -340,6 +341,22 @@ def render_remote_config(
enabled_section = ""
if enabled_tools is not None:
enabled_section = f"\nEnabled tool groups:\n{format_enabled_tools(enabled_tools) or 'none'}\n"
ingest_upload_section = ""
if enabled_tools is not None and TOOL_INGEST in normalize_enabled_tools(enabled_tools):
upload_url = f"http://{host}:{port}{DEFAULT_INGEST_UPLOAD_PATH}"
upload_alternatives = "\n".join(
f"- http://{candidate}:{port}{DEFAULT_INGEST_UPLOAD_PATH}" for candidate in hosts[1:]
)
upload_alternatives_section = (
f"\nOther ingest upload addresses:\n{upload_alternatives}\n" if upload_alternatives else ""
)
ingest_upload_section = (
"\n"
"Ingest file upload endpoint:\n"
f"{upload_url}\n"
f"{upload_alternatives_section}"
"Use the same Authorization header. POST multipart/form-data with one or more `files` fields.\n"
)
return (
"MCP service is running.\n"
"\n"
@@ -348,6 +365,7 @@ def render_remote_config(
f"{alternatives_section}"
f"{config_section}"
f"{enabled_section}"
f"{ingest_upload_section}"
"\n"
"Use this header:\n"
f"Authorization: Bearer {token}\n"
+21
View File
@@ -167,6 +167,24 @@ class McpIngestTests(unittest.TestCase):
self.assertEqual(complete["state"], "complete")
self.assertEqual(Path(complete["path"]).read_bytes(), payload)
def test_create_completed_upload_accepts_bytes_without_agent_chunking(self):
payload = b"direct upload payload"
upload = self.manager.create_completed_upload(
file_name="drawing.pdf",
payload=payload,
project="bjd",
doc_type_hint="drawing",
)
self.assertEqual(upload["state"], "complete")
self.assertEqual(upload["received_bytes"], len(payload))
self.assertEqual(upload["size_bytes"], len(payload))
self.assertEqual(upload["sha256"], "sha256:" + self._sha256(payload))
self.assertEqual(upload["project"], "bjd")
self.assertEqual(upload["doc_type_hint"], "drawing")
self.assertEqual(Path(upload["path"]).read_bytes(), payload)
def test_upload_rejects_unsafe_names_oversized_files_and_large_chunks(self):
with self.assertRaises(IngestError):
self.manager.create_upload(file_name="../secret.pdf", size_bytes=10, sha256="a" * 64)
@@ -182,6 +200,9 @@ class McpIngestTests(unittest.TestCase):
data_base64=base64.b64encode(b"x" * 9).decode("ascii"),
)
with self.assertRaises(IngestError):
self.manager.create_completed_upload(file_name="too-large.pdf", payload=b"x" * 101)
def test_start_ingest_job_constructs_restricted_opencode_command(self):
payload = b"source data"
upload = self._complete_upload("source.pdf", payload)
+151
View File
@@ -1,4 +1,7 @@
import asyncio
import base64
import hashlib
import json
import os
import tempfile
import unittest
@@ -6,6 +9,7 @@ from pathlib import Path
from unittest.mock import patch
from obsidian_wiki import mcp_server
from obsidian_wiki.mcp_ingest import IngestManager
class FakeApp:
@@ -32,6 +36,53 @@ def run_asgi_app(app, *, headers=None, scope_type="http"):
return messages
def run_asgi_http_request(app, *, method="GET", path="/", headers=None, body=b""):
messages = []
sent = False
scope = {
"type": "http",
"method": method,
"path": path,
"raw_path": path.encode("ascii"),
"query_string": b"",
"headers": headers or [],
}
async def receive():
nonlocal sent
if sent:
return {"type": "http.disconnect"}
sent = True
return {"type": "http.request", "body": body, "more_body": False}
async def send(message):
messages.append(message)
asyncio.run(app(scope, receive, send))
return messages
def multipart_body(fields, files, boundary="test-boundary"):
chunks = []
for name, value in fields.items():
chunks.append(f"--{boundary}\r\n".encode("ascii"))
chunks.append(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode("ascii"))
chunks.append(str(value).encode("utf-8"))
chunks.append(b"\r\n")
for field_name, file_name, content_type, payload in files:
chunks.append(f"--{boundary}\r\n".encode("ascii"))
chunks.append(
(
f'Content-Disposition: form-data; name="{field_name}"; filename="{file_name}"\r\n'
f"Content-Type: {content_type}\r\n\r\n"
).encode("ascii")
)
chunks.append(payload)
chunks.append(b"\r\n")
chunks.append(f"--{boundary}--\r\n".encode("ascii"))
return b"".join(chunks), boundary
class RemoteMcpServerTests(unittest.TestCase):
def test_bearer_token_middleware_accepts_matching_token(self):
inner = FakeApp()
@@ -111,6 +162,53 @@ class RemoteMcpServerTests(unittest.TestCase):
self.assertTrue(fake_server.inner.called)
self.assertEqual(messages[0]["status"], 200)
def test_http_upload_endpoint_accepts_multipart_when_ingest_enabled(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
manager = IngestManager(
vault_path=root / "vault",
project_root=root / "project",
runtime_root=root / "runtime",
)
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
payload = b"uploaded drawing"
body, boundary = multipart_body(
{"project": "bjd", "doc_type_hint": "drawing", "start_job": "false"},
[("files", "drawing.pdf", "application/pdf", payload)],
)
messages = run_asgi_http_request(
app,
method="POST",
path="/mcp-ingest/uploads",
headers=[
(b"authorization", b"Bearer secret-token"),
(b"content-type", f"multipart/form-data; boundary={boundary}".encode("ascii")),
],
body=body,
)
self.assertEqual(messages[0]["status"], 200)
response = b"".join(message.get("body", b"") for message in messages if message["type"] == "http.response.body")
data = json.loads(response.decode("utf-8"))
self.assertEqual(data["job"], None)
self.assertEqual(data["uploads"][0]["state"], "complete")
self.assertEqual(data["uploads"][0]["file_name"], "drawing.pdf")
self.assertEqual(data["uploads"][0]["sha256"], "sha256:" + hashlib.sha256(payload).hexdigest())
def test_http_upload_endpoint_requires_ingest_and_bearer_token(self):
no_ingest_app = mcp_server.create_http_app(FakeApp(), "secret-token")
missing_token = run_asgi_http_request(no_ingest_app, method="POST", path="/mcp-ingest/uploads")
self.assertEqual(missing_token[0]["status"], 401)
disabled = run_asgi_http_request(
no_ingest_app,
method="POST",
path="/mcp-ingest/uploads",
headers=[(b"authorization", b"Bearer secret-token")],
)
self.assertEqual(disabled[0]["status"], 404)
def test_create_server_uses_file_asset_service_name(self):
class FakeMCP:
names = []
@@ -201,6 +299,59 @@ class RemoteMcpServerTests(unittest.TestCase):
self.assertIn("get_ingest_job_result", FakeMCP.tool_names)
self.assertIn("cancel_ingest_job", FakeMCP.tool_names)
self.assertIn("check_ingest_opencode_readiness", FakeMCP.tool_names)
self.assertIn("get_ingest_upload_instructions", FakeMCP.tool_names)
self.assertIn("ingest_file_base64", FakeMCP.tool_names)
def test_ingest_file_base64_tool_uploads_without_chunking(self):
class FakeMCP:
tools = {}
def __init__(self, _name, **_kwargs):
pass
def tool(self):
def decorator(func):
self.tools[func.__name__] = func
return func
return decorator
def resource(self, *_args, **_kwargs):
def decorator(func):
return func
return decorator
def prompt(self, *_args, **_kwargs):
def decorator(func):
return func
return decorator
with (
tempfile.TemporaryDirectory() as tmp,
patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}),
):
root = Path(tmp)
mcp_server.create_server(
vault_path=root / "vault",
enable_ingest=True,
project_root=root / "project",
ingest_runtime_root=root / "runtime",
)
payload = b"one shot upload"
result = FakeMCP.tools["ingest_file_base64"](
file_name="source.pdf",
data_base64=base64.b64encode(payload).decode("ascii"),
project="bjd",
doc_type_hint="drawing",
start_job=False,
)
self.assertEqual(result["job"], None)
self.assertEqual(result["upload"]["state"], "complete")
self.assertEqual(result["upload"]["file_name"], "source.pdf")
self.assertEqual(result["upload"]["sha256"], "sha256:" + hashlib.sha256(payload).hexdigest())
def test_create_server_can_disable_file_asset_tools(self):
class FakeMCP:
+13
View File
@@ -106,6 +106,19 @@ class McpServiceConfigTests(unittest.TestCase):
self.assertIn('"wiki-file-assets"', output)
self.assertNotIn('"obsidian-wiki"', output)
def test_render_remote_config_prints_ingest_upload_endpoint_when_enabled(self):
output = manage_mcp_service.render_remote_config(
hosts=["192.168.1.10", "10.0.0.2"],
token="test-token",
port=8765,
path="/mcp",
enabled_tools=("file-assets", "ingest"),
)
self.assertIn("http://192.168.1.10:8765/mcp", output)
self.assertIn("http://192.168.1.10:8765/mcp-ingest/uploads", output)
self.assertIn("http://10.0.0.2:8765/mcp-ingest/uploads", output)
def test_new_token_writes_token_without_touching_systemd_when_unit_missing(self):
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"