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:
@@ -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)
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
Reference in New Issue
Block a user