feat(mcp): support bundled text and attachment ingest
Allow one multipart request to stage body text and repeated files as a Bundle with bundle-level metadata. Preserve Bundle roles through preview, job launch, prompts, cleanup, and text-note ingestion.\n\nValidated with the full unittest suite (188 tests).
This commit is contained in:
@@ -209,6 +209,68 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertEqual(data["uploads"][0]["sha256"], "sha256:" + hashlib.sha256(payload).hexdigest())
|
||||
self.assertNotIn("path", data["uploads"][0])
|
||||
|
||||
def test_http_upload_endpoint_creates_bundle_from_body_and_attachments(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,
|
||||
)
|
||||
body, boundary = multipart_body(
|
||||
{
|
||||
"body_text": "First message\n\nSecond message",
|
||||
"body_title": "Customer thread",
|
||||
"bundle_id": "thread-123",
|
||||
"source_type": "email_thread",
|
||||
"label": "Customer quality thread",
|
||||
"start_job": "false",
|
||||
},
|
||||
[
|
||||
("files", "report.pdf", "application/pdf", b"report"),
|
||||
("files", "photo.jpg", "image/jpeg", b"photo"),
|
||||
],
|
||||
)
|
||||
|
||||
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.assertIsNone(data["job"])
|
||||
self.assertEqual(data["bundle"]["bundle_id"], "thread-123")
|
||||
self.assertEqual(data["bundle"]["source_type"], "email_thread")
|
||||
self.assertEqual(data["bundle"]["label"], "Customer quality thread")
|
||||
self.assertEqual(len(data["uploads"]), 3)
|
||||
self.assertEqual(data["uploads"][0]["bundle_role"], "primary")
|
||||
self.assertEqual(data["uploads"][1]["bundle_role"], "attachment")
|
||||
self.assertEqual(data["uploads"][2]["bundle_role"], "attachment")
|
||||
body_meta = manager._load_upload(data["bundle"]["body_upload_id"])
|
||||
body_payload = Path(body_meta["path"]).read_text(encoding="utf-8")
|
||||
self.assertIn("# Customer thread", body_payload)
|
||||
self.assertIn("First message", body_payload)
|
||||
|
||||
def test_http_upload_endpoint_streams_multipart_without_buffering_whole_body(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -346,6 +408,9 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
max_files=1,
|
||||
project="bjd",
|
||||
doc_type_hint="drawing",
|
||||
bundle_id="drawing-package-1",
|
||||
source_type="report_package",
|
||||
label="Customer drawing package",
|
||||
start_job=False,
|
||||
)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
@@ -970,9 +1035,9 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(capabilities["server_name"], "wiki-file-assets")
|
||||
self.assertIn("server_version", capabilities)
|
||||
self.assertEqual(capabilities["schema_version"], 2)
|
||||
self.assertEqual(capabilities["capabilities_schema_version"], 2)
|
||||
self.assertEqual(capabilities["tools_version"], 2)
|
||||
self.assertEqual(capabilities["schema_version"], 3)
|
||||
self.assertEqual(capabilities["capabilities_schema_version"], 3)
|
||||
self.assertEqual(capabilities["tools_version"], 3)
|
||||
self.assertEqual(capabilities["enabled_tools"], ["file-assets", "ingest", "query"])
|
||||
self.assertIn("file_index", capabilities)
|
||||
self.assertIn("record_count", capabilities["file_index"])
|
||||
@@ -988,6 +1053,10 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertEqual(capabilities["ingest"]["http_upload_path"], "/mcp-ingest/uploads")
|
||||
self.assertEqual(capabilities["ingest"]["http_upload_url"], "https://wiki.example.test/mcp-ingest/uploads")
|
||||
self.assertEqual(capabilities["ingest"]["preferred_text_tool"], "ingest_text_note")
|
||||
self.assertTrue(capabilities["ingest"]["supports_bundle_upload"])
|
||||
self.assertTrue(capabilities["ingest"]["supports_body_text"])
|
||||
self.assertIn("body_text", capabilities["ingest"]["multipart_optional_fields"])
|
||||
self.assertIn("source_type", capabilities["ingest"]["multipart_optional_fields"])
|
||||
self.assertIn("validate_upload", capabilities["ingest"]["tools"])
|
||||
self.assertIn("preview_upload_metadata", capabilities["ingest"]["tools"])
|
||||
self.assertIn("queue", capabilities["ingest"])
|
||||
@@ -1321,6 +1390,9 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
max_files=1,
|
||||
project="bjd",
|
||||
doc_type_hint="drawing",
|
||||
bundle_id="drawing-package-1",
|
||||
source_type="report_package",
|
||||
label="Customer drawing package",
|
||||
start_job=False,
|
||||
)
|
||||
instructions = FakeMCP.tools["get_ingest_upload_instructions"]()
|
||||
@@ -1332,9 +1404,14 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertIn("X-Obsidian-Wiki-Upload-Token", ticket["curl_example"])
|
||||
self.assertNotIn("Authorization: Bearer", ticket["curl_example"])
|
||||
self.assertEqual(ticket["max_files"], 1)
|
||||
self.assertEqual(ticket["bundle_id"], "drawing-package-1")
|
||||
self.assertEqual(ticket["source_type"], "report_package")
|
||||
self.assertEqual(ticket["label"], "Customer drawing package")
|
||||
self.assertFalse(ticket["start_job"])
|
||||
self.assertEqual(instructions["preferred_upload_auth"], "upload_ticket")
|
||||
self.assertEqual(instructions["http_upload"]["url"], "https://wiki.example.test/mcp-ingest/uploads")
|
||||
self.assertIn("body_text", instructions["http_upload"]["optional_fields"])
|
||||
self.assertIn("bundle_id", instructions["http_upload"]["optional_fields"])
|
||||
self.assertIn("create_ingest_upload_ticket", instructions["preferred_method"])
|
||||
self.assertEqual(instructions["http_upload"]["bearer_auth"], "admin_or_local_only")
|
||||
self.assertNotIn("Authorization: Bearer", instructions["http_upload"]["curl_example"])
|
||||
@@ -1514,10 +1591,16 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(result["job"], None)
|
||||
self.assertEqual(len(result["uploads"]), 1)
|
||||
self.assertEqual(result["bundle"]["source_type"], "text_note")
|
||||
self.assertEqual(
|
||||
result["bundle"]["body_upload_id"],
|
||||
result["uploads"][0]["upload_id"],
|
||||
)
|
||||
upload = result["uploads"][0]
|
||||
self.assertEqual(upload["state"], "complete")
|
||||
self.assertEqual(upload["project"], "bjd")
|
||||
self.assertEqual(upload["doc_type_hint"], "thought")
|
||||
self.assertEqual(upload["bundle_role"], "primary")
|
||||
self.assertNotIn("path", upload)
|
||||
self.assertTrue(upload["file_name"].endswith(".md"))
|
||||
self.assertNotIn("/", upload["file_name"])
|
||||
|
||||
Reference in New Issue
Block a user