feat(mcp): improve remote ingest guidance

Make upload-ticket auth the default remote-agent path by returning complete upload URLs and updating service output/docs.

Add validate_upload and preview_upload_metadata for lightweight file checks and exact sha256 duplicate warnings before ingest.

Expose version, capability schema, file-index freshness, queue state, and known limitations through get_mcp_capabilities.

Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests scripts; git diff --check.
This commit is contained in:
Codex
2026-07-08 17:12:51 +00:00
parent ee7defe8bf
commit d47bb2c549
8 changed files with 549 additions and 36 deletions
+75
View File
@@ -236,6 +236,81 @@ class McpIngestTests(unittest.TestCase):
with self.assertRaises(IngestError):
self.manager.create_completed_upload(file_name="too-large.pdf", payload=b"x" * 101)
def test_validate_upload_plan_reports_limits_and_exact_duplicates(self):
duplicate_digest = self._sha256(b"already archived")
registry = self.vault / "_meta" / "source-registry.jsonl"
registry.parent.mkdir(parents=True)
registry.write_text(
json.dumps(
{
"record_type": "source_archive",
"source_id": "sha256:" + duplicate_digest,
"archive_path": "_sources/archive/existing.pdf",
"original_name": "existing.pdf",
}
)
+ "\n",
encoding="utf-8",
)
result = self.manager.validate_upload_plan(
[
{"file_name": "existing.pdf", "size_bytes": 12, "sha256": duplicate_digest},
{"file_name": "too-large.pdf", "size_bytes": 101, "sha256": "b" * 64},
{"file_name": "../secret.pdf", "size_bytes": 10, "sha256": "c" * 64},
{"file_name": "bad-sha.pdf", "size_bytes": 10, "sha256": "not-a-sha"},
],
project="bjd",
doc_type_hint="drawing",
)
self.assertFalse(result["ok"])
self.assertEqual(result["project"], "bjd")
self.assertEqual(result["doc_type_hint"], "drawing")
self.assertEqual(result["max_file_bytes"], 100)
self.assertEqual(result["max_files_per_job"], 10)
self.assertEqual(result["files"][0]["status"], "warning")
self.assertTrue(result["files"][0]["duplicates"])
self.assertEqual(result["files"][1]["status"], "error")
self.assertIn("exceeds max upload size", result["files"][1]["errors"][0])
self.assertIn("unsafe file name", result["files"][2]["errors"][0])
self.assertIn("sha256", result["files"][3]["errors"][0])
def test_preview_upload_metadata_reports_duplicate_and_requires_completed_uploads(self):
payload = b"duplicate payload"
digest = self._sha256(payload)
registry = self.vault / "_meta" / "source-registry.jsonl"
registry.parent.mkdir(parents=True)
registry.write_text(
json.dumps(
{
"record_type": "source_archive",
"source_id": "sha256:" + digest,
"archive_path": "_sources/archive/duplicate.pdf",
"original_name": "duplicate.pdf",
}
)
+ "\n",
encoding="utf-8",
)
complete = self.manager.create_completed_upload(
"duplicate.pdf",
payload,
project="bjd",
doc_type_hint="drawing",
)
incomplete = self.manager.create_upload("pending.pdf", 7, self._sha256(b"pending"))
preview = self.manager.preview_upload_metadata([complete["upload_id"]])
self.assertTrue(preview["ok"])
self.assertEqual(preview["next_action"], "start_ingest_job")
self.assertEqual(preview["uploads"][0]["file_extension"], ".pdf")
self.assertTrue(preview["uploads"][0]["duplicates"])
self.assertEqual(preview["uploads"][0]["duplicate_count"], 1)
with self.assertRaises(IngestError):
self.manager.preview_upload_metadata([incomplete["upload_id"]])
def test_start_ingest_job_constructs_restricted_opencode_command(self):
payload = b"source data"
upload = self._complete_upload("source.pdf", payload)
+87 -1
View File
@@ -597,6 +597,7 @@ class RemoteMcpServerTests(unittest.TestCase):
project_root=root / "project",
ingest_runtime_root=root / "ingest-runtime",
query_runtime_root=root / "query-runtime",
public_base_url="https://wiki.example.test/mcp",
ingest_manager=IngestManager(
vault_path=root / "vault",
project_root=root / "project",
@@ -618,13 +619,28 @@ class RemoteMcpServerTests(unittest.TestCase):
capabilities = FakeMCP.tools["get_mcp_capabilities"]()
self.assertEqual(capabilities["server_name"], "wiki-file-assets")
self.assertIn("server_version", capabilities)
self.assertEqual(capabilities["capabilities_schema_version"], 1)
self.assertEqual(capabilities["tools_version"], 1)
self.assertEqual(capabilities["enabled_tools"], ["file-assets", "ingest", "query"])
self.assertIn("file_index", capabilities)
self.assertIn("record_count", capabilities["file_index"])
self.assertIn("last_rebuild_at", capabilities["file_index"])
self.assertTrue(capabilities["query"]["enabled"])
self.assertEqual(capabilities["query"]["preferred_tool"], "ask_wiki")
self.assertIn("ask_wiki", capabilities["query"]["tools"])
self.assertTrue(capabilities["query"]["supports_structured_result"])
self.assertTrue(capabilities["query"]["known_limitations"])
self.assertTrue(capabilities["ingest"]["enabled"])
self.assertEqual(capabilities["ingest"]["preferred_upload_auth"], "upload_ticket")
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.assertIn("validate_upload", capabilities["ingest"]["tools"])
self.assertIn("preview_upload_metadata", capabilities["ingest"]["tools"])
self.assertIn("queue", capabilities["ingest"])
self.assertIn("last_ingest_at", capabilities["ingest"])
self.assertTrue(capabilities["ingest"]["known_limitations"])
self.assertIn("search_file_assets", capabilities["file_assets"]["tools"])
self.assertNotIn("vault_path", json.dumps(capabilities))
self.assertNotIn("runtime", json.dumps(capabilities).lower())
@@ -753,6 +769,7 @@ class RemoteMcpServerTests(unittest.TestCase):
enable_ingest=True,
project_root=root / "project",
ingest_runtime_root=root / "runtime",
public_base_url="https://wiki.example.test/mcp",
)
ticket = FakeMCP.tools["create_ingest_upload_ticket"](
expires_seconds=60,
@@ -766,11 +783,80 @@ class RemoteMcpServerTests(unittest.TestCase):
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["upload_url"], "https://wiki.example.test/mcp-ingest/uploads")
self.assertIn("X-Obsidian-Wiki-Upload-Token", ticket["curl_example"])
self.assertNotIn("Authorization: Bearer", ticket["curl_example"])
self.assertEqual(ticket["max_files"], 1)
self.assertFalse(ticket["start_job"])
self.assertIn("ingest_text_note", instructions["preferred_method"])
self.assertEqual(instructions["preferred_upload_auth"], "upload_ticket")
self.assertEqual(instructions["http_upload"]["url"], "https://wiki.example.test/mcp-ingest/uploads")
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"])
self.assertEqual(instructions["direct_text_tool"]["name"], "ingest_text_note")
def test_ingest_tools_validate_upload_and_preview_metadata(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)
manager = IngestManager(
vault_path=root / "vault",
project_root=root / "project",
runtime_root=root / "runtime",
max_file_bytes=20,
)
mcp_server.create_server(
vault_path=root / "vault",
enable_ingest=True,
project_root=root / "project",
ingest_runtime_root=root / "runtime",
ingest_manager=manager,
)
validate = FakeMCP.tools["validate_upload"](
files=[
{"file_name": "ok.pdf", "size_bytes": 2, "sha256": "a" * 64},
{"file_name": "large.pdf", "size_bytes": 21, "sha256": "b" * 64},
],
project="bjd",
doc_type_hint="drawing",
)
upload = manager.create_completed_upload("ok.pdf", b"ok", project="bjd", doc_type_hint="drawing")
preview = FakeMCP.tools["preview_upload_metadata"]([upload["upload_id"]])
self.assertFalse(validate["ok"])
self.assertEqual(validate["files"][0]["status"], "ok")
self.assertEqual(validate["files"][1]["status"], "error")
self.assertTrue(preview["ok"])
self.assertEqual(preview["uploads"][0]["file_name"], "ok.pdf")
self.assertEqual(preview["next_action"], "start_ingest_job")
def test_ingest_text_note_tool_creates_completed_markdown_upload(self):
class FakeMCP:
tools = {}
+21 -1
View File
@@ -62,6 +62,7 @@ class McpServiceConfigTests(unittest.TestCase):
host="0.0.0.0",
port=8765,
path="/mcp",
public_base_url="http://192.168.1.10:8765/mcp",
enable_ingest=True,
)
@@ -70,6 +71,7 @@ class McpServiceConfigTests(unittest.TestCase):
self.assertIn("OBSIDIAN_MCP_HOST=0.0.0.0\n", content)
self.assertIn("OBSIDIAN_MCP_PORT=8765\n", content)
self.assertIn("OBSIDIAN_MCP_PATH=/mcp\n", content)
self.assertIn("OBSIDIAN_MCP_PUBLIC_BASE_URL=http://192.168.1.10:8765/mcp\n", content)
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", content)
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", content)
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5\n", content)
@@ -125,6 +127,21 @@ class McpServiceConfigTests(unittest.TestCase):
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)
self.assertIn("Remote agents should upload with create_ingest_upload_ticket", output)
self.assertIn("Bearer upload is for local/admin use", output)
def test_render_remote_config_uses_public_base_url_for_upload_endpoint(self):
output = manage_mcp_service.render_remote_config(
hosts=["192.168.1.10"],
token="test-token",
port=8765,
path="/mcp",
public_base_url="https://wiki.example.test/custom-mcp",
enabled_tools=("file-assets", "ingest"),
)
self.assertIn("https://wiki.example.test/mcp-ingest/uploads", output)
self.assertNotIn("http://192.168.1.10:8765/mcp-ingest/uploads", output)
def test_new_token_writes_token_without_touching_systemd_when_unit_missing(self):
with tempfile.TemporaryDirectory() as tmp:
@@ -236,6 +253,7 @@ class McpServiceConfigTests(unittest.TestCase):
self.assertIn("OBSIDIAN_MCP_TOKEN=created-token\n", env_content)
self.assertIn("OBSIDIAN_MCP_PORT=9876\n", env_content)
self.assertIn("OBSIDIAN_MCP_PATH=/wiki-mcp\n", env_content)
self.assertIn("OBSIDIAN_MCP_PUBLIC_BASE_URL=http://10.0.0.4:9876/wiki-mcp\n", env_content)
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", env_content)
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", env_content)
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_PATH=/home/nac/.opencode/bin/opencode\n", env_content)
@@ -279,7 +297,8 @@ class McpServiceConfigTests(unittest.TestCase):
+ "OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/custom-strong\n"
+ "OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=max\n"
+ "OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/custom-fast\n"
+ "OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=low\n",
+ "OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=low\n"
+ "OBSIDIAN_MCP_PUBLIC_BASE_URL=https://wiki.example.test/mcp\n",
encoding="utf-8",
)
@@ -301,6 +320,7 @@ class McpServiceConfigTests(unittest.TestCase):
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=max\n", env_content)
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/custom-fast\n", env_content)
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=low\n", env_content)
self.assertIn("OBSIDIAN_MCP_PUBLIC_BASE_URL=https://wiki.example.test/mcp\n", env_content)
def test_start_service_can_enable_query_without_ingest(self):
with tempfile.TemporaryDirectory() as tmp: