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
This commit is contained in:
Codex
2026-07-05 16:45:37 +00:00
parent 75dea2cfaf
commit d3bd7f0c08
7 changed files with 325 additions and 18 deletions
+25
View File
@@ -185,6 +185,31 @@ class McpIngestTests(unittest.TestCase):
self.assertEqual(upload["doc_type_hint"], "drawing")
self.assertEqual(Path(upload["path"]).read_bytes(), payload)
def test_upload_ticket_is_single_use_and_preserves_defaults(self):
ticket = self.manager.create_upload_ticket(
expires_seconds=60,
max_files=1,
project="bjd",
doc_type_hint="drawing",
note="customer uploaded drawing",
start_job=False,
)
self.assertIn("upload_token", ticket)
self.assertEqual(ticket["max_files"], 1)
self.assertEqual(ticket["project"], "bjd")
self.assertEqual(ticket["doc_type_hint"], "drawing")
self.assertEqual(ticket["note"], "customer uploaded drawing")
self.assertFalse(ticket["start_job"])
self.assertEqual(ticket["used_at"], None)
used = self.manager.consume_upload_ticket(ticket["upload_token"])
self.assertEqual(used["project"], "bjd")
self.assertIsNotNone(used["used_at"])
with self.assertRaises(IngestError):
self.manager.consume_upload_ticket(ticket["upload_token"])
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)
+105
View File
@@ -196,6 +196,59 @@ class RemoteMcpServerTests(unittest.TestCase):
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")
@@ -300,8 +353,60 @@ class RemoteMcpServerTests(unittest.TestCase):
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 = {}