Files
obsidian-wiki/tests/test_mcp_server_remote.py
T
Codex d3bd7f0c08 feat(mcp): issue upload tickets for ingest
Add short-lived single-use HTTP upload tickets that authenticated MCP clients can request before uploading files to /mcp-ingest/uploads. This lets remote agents use curl without needing to read or expose the main MCP bearer token, avoiding oversized base64 tool arguments for larger files.

The upload endpoint now accepts either the normal bearer token or X-Obsidian-Wiki-Upload-Token. Ticket defaults can carry project, doc_type_hint, note, start_job, and file-count limits while uploads still stage outside the vault.

Validation: python3 -m unittest discover -v; python3 -m py_compile obsidian_wiki/mcp_ingest.py obsidian_wiki/mcp_server.py tests/test_mcp_ingest.py tests/test_mcp_server_remote.py; git diff --check
2026-07-05 16:45:37 +00:00

525 lines
19 KiB
Python

import asyncio
import base64
import hashlib
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from obsidian_wiki import mcp_server
from obsidian_wiki.mcp_ingest import IngestManager
class FakeApp:
def __init__(self):
self.called = False
async def __call__(self, scope, receive, send):
self.called = True
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok"})
def run_asgi_app(app, *, headers=None, scope_type="http"):
messages = []
scope = {"type": scope_type, "headers": headers or []}
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
messages.append(message)
asyncio.run(app(scope, receive, send))
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()
middleware = mcp_server.BearerTokenAuthMiddleware(inner, "secret-token")
messages = run_asgi_app(
middleware,
headers=[(b"authorization", b"Bearer secret-token")],
)
self.assertTrue(inner.called)
self.assertEqual(messages[0]["status"], 200)
def test_bearer_token_middleware_rejects_missing_or_wrong_token(self):
inner = FakeApp()
middleware = mcp_server.BearerTokenAuthMiddleware(inner, "secret-token")
messages = run_asgi_app(
middleware,
headers=[(b"authorization", b"Bearer wrong-token")],
)
self.assertFalse(inner.called)
self.assertEqual(messages[0]["status"], 401)
self.assertIn((b"www-authenticate", b"Bearer"), messages[0]["headers"])
def test_bearer_token_middleware_requires_non_empty_token(self):
with self.assertRaises(ValueError):
mcp_server.BearerTokenAuthMiddleware(FakeApp(), "")
def test_read_token_from_env_trims_and_requires_value(self):
with patch.dict(os.environ, {"TEST_MCP_TOKEN": " secret-token "}, clear=False):
self.assertEqual(mcp_server.read_token_from_env("TEST_MCP_TOKEN"), "secret-token")
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(RuntimeError):
mcp_server.read_token_from_env("TEST_MCP_TOKEN")
def test_runtime_config_reads_environment_with_defaults(self):
with patch.dict(os.environ, {"OBSIDIAN_MCP_HOST": " 0.0.0.0 ", "OBSIDIAN_MCP_PORT": "9876"}, clear=True):
self.assertEqual(mcp_server.runtime_config_value("OBSIDIAN_MCP_HOST", "127.0.0.1"), "0.0.0.0")
self.assertEqual(mcp_server.runtime_config_int("OBSIDIAN_MCP_PORT", 8765), 9876)
with patch.dict(os.environ, {"OBSIDIAN_MCP_PORT": "not-an-int"}, clear=True):
self.assertEqual(mcp_server.runtime_config_int("OBSIDIAN_MCP_PORT", 8765), 8765)
def test_enabled_tools_config_prefers_tool_list_over_legacy_ingest_flag(self):
with patch.dict(
os.environ,
{
"OBSIDIAN_MCP_ENABLED_TOOLS": "file-assets",
"OBSIDIAN_MCP_ENABLE_INGEST": "1",
},
clear=True,
):
self.assertEqual(mcp_server.enabled_tool_names(), {"file-assets"})
with patch.dict(os.environ, {"OBSIDIAN_MCP_ENABLE_INGEST": "1"}, clear=True):
self.assertEqual(mcp_server.enabled_tool_names(), {"file-assets", "ingest"})
def test_create_http_app_wraps_streamable_http_app_with_auth(self):
class FakeServer:
def __init__(self):
self.inner = FakeApp()
def streamable_http_app(self):
return self.inner
fake_server = FakeServer()
app = mcp_server.create_http_app(fake_server, "secret-token")
messages = run_asgi_app(
app,
headers=[(b"authorization", b"Bearer secret-token")],
)
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_accepts_upload_ticket_without_main_bearer_token(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
manager = IngestManager(
vault_path=root / "vault",
project_root=root / "project",
runtime_root=root / "runtime",
)
ticket = manager.create_upload_ticket(
expires_seconds=60,
max_files=1,
project="bjd",
doc_type_hint="drawing",
start_job=False,
)
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
payload = b"ticket uploaded drawing"
body, boundary = multipart_body(
{},
[("files", "ticket-drawing.pdf", "application/pdf", payload)],
)
messages = run_asgi_http_request(
app,
method="POST",
path="/mcp-ingest/uploads",
headers=[
(b"x-obsidian-wiki-upload-token", ticket["upload_token"].encode("ascii")),
(b"content-type", f"multipart/form-data; boundary={boundary}".encode("ascii")),
],
body=body,
)
reused = run_asgi_http_request(
app,
method="POST",
path="/mcp-ingest/uploads",
headers=[
(b"x-obsidian-wiki-upload-token", ticket["upload_token"].encode("ascii")),
(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]["project"], "bjd")
self.assertEqual(data["uploads"][0]["doc_type_hint"], "drawing")
self.assertEqual(data["uploads"][0]["sha256"], "sha256:" + hashlib.sha256(payload).hexdigest())
self.assertEqual(reused[0]["status"], 401)
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 = []
tool_names = []
prompt_names = []
def __init__(self, name, **kwargs):
self.names.append(name)
def tool(self):
def decorator(func):
self.tool_names.append(func.__name__)
return func
return decorator
def resource(self, *_args, **_kwargs):
def decorator(func):
return func
return decorator
def prompt(self, name=None, **_kwargs):
def decorator(func):
self.prompt_names.append(name or func.__name__)
return func
return decorator
with (
tempfile.TemporaryDirectory() as tmp,
patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}),
):
server = mcp_server.create_server(vault_path=Path(tmp))
self.assertIsInstance(server, FakeMCP)
self.assertEqual(FakeMCP.names[-1], "wiki-file-assets")
self.assertIn("search_file_assets", FakeMCP.tool_names)
self.assertIn("list_file_assets", FakeMCP.tool_names)
self.assertIn("get_file_asset_metadata", FakeMCP.tool_names)
self.assertIn("how_to_use_wiki_file_assets", FakeMCP.prompt_names)
self.assertNotIn("create_ingest_upload", FakeMCP.tool_names)
def test_create_server_registers_ingest_tools_when_enabled(self):
class FakeMCP:
tool_names = []
def __init__(self, _name, **_kwargs):
pass
def tool(self):
def decorator(func):
self.tool_names.append(func.__name__)
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)
server = mcp_server.create_server(
vault_path=root / "vault",
enable_ingest=True,
project_root=root / "project",
ingest_runtime_root=root / "runtime",
)
self.assertIsInstance(server, FakeMCP)
self.assertNotIn("create_ingest_upload", FakeMCP.tool_names)
self.assertNotIn("append_ingest_upload_chunk", FakeMCP.tool_names)
self.assertNotIn("complete_ingest_upload", FakeMCP.tool_names)
self.assertIn("start_ingest_job", FakeMCP.tool_names)
self.assertIn("get_ingest_job_status", FakeMCP.tool_names)
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("create_ingest_upload_ticket", FakeMCP.tool_names)
self.assertIn("ingest_file_base64", FakeMCP.tool_names)
def test_create_ingest_upload_ticket_tool_returns_http_only_token(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",
)
ticket = FakeMCP.tools["create_ingest_upload_ticket"](
expires_seconds=60,
max_files=1,
project="bjd",
doc_type_hint="drawing",
start_job=False,
)
self.assertIn("upload_token", ticket)
self.assertEqual(ticket["token_header"], "X-Obsidian-Wiki-Upload-Token")
self.assertEqual(ticket["upload_path"], "/mcp-ingest/uploads")
self.assertEqual(ticket["max_files"], 1)
self.assertFalse(ticket["start_job"])
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:
tool_names = []
prompt_names = []
def __init__(self, _name, **_kwargs):
pass
def tool(self):
def decorator(func):
self.tool_names.append(func.__name__)
return func
return decorator
def resource(self, *_args, **_kwargs):
def decorator(func):
return func
return decorator
def prompt(self, name=None, **_kwargs):
def decorator(func):
self.prompt_names.append(name or func.__name__)
return func
return decorator
with (
tempfile.TemporaryDirectory() as tmp,
patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}),
):
server = mcp_server.create_server(vault_path=Path(tmp), enable_file_assets=False)
self.assertIsInstance(server, FakeMCP)
self.assertIn("file_index_status", FakeMCP.tool_names)
self.assertNotIn("search_file_assets", FakeMCP.tool_names)
self.assertNotIn("list_file_assets", FakeMCP.tool_names)
self.assertNotIn("get_file_asset_metadata", FakeMCP.tool_names)
self.assertNotIn("how_to_use_wiki_file_assets", FakeMCP.prompt_names)
def test_run_http_server_fails_before_server_creation_without_token(self):
with tempfile.TemporaryDirectory() as tmp:
with patch.dict(os.environ, {}, clear=True):
with patch.object(mcp_server, "create_server") as create_server:
with self.assertRaises(RuntimeError):
mcp_server.run_http_server(
vault_path=Path(tmp),
db_path=None,
profile_name="bjd-general-bot",
max_file_bytes=1024,
expose_paths=False,
host="127.0.0.1",
port=8765,
token_env="TEST_MCP_TOKEN",
streamable_http_path="/mcp",
allowed_hosts=None,
allowed_origins=None,
)
create_server.assert_not_called()
if __name__ == "__main__":
unittest.main()