feat(mcp): harden remote assets and job execution
Gate source-file downloads and verify registry paths and content hashes. Stream multipart uploads with bounded limits, supervise OpenCode jobs with explicit result markers and timeouts, and preserve managed endpoint security settings. Document the trusted-client boundary and add focused regression coverage. Validation: PYTHONPATH=<staged-tree> python -m unittest discover -s tests (182 tests)
This commit is contained in:
@@ -3,6 +3,7 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -129,6 +130,14 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
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)
|
||||
|
||||
with patch.dict(os.environ, {"OBSIDIAN_MCP_ALLOWED_HOSTS": "wiki.example:*, 10.0.0.2:*"}, clear=True):
|
||||
self.assertEqual(
|
||||
mcp_server.runtime_config_list("OBSIDIAN_MCP_ALLOWED_HOSTS"),
|
||||
["wiki.example:*", "10.0.0.2:*"],
|
||||
)
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertIsNone(mcp_server.runtime_config_list("OBSIDIAN_MCP_ALLOWED_HOSTS"))
|
||||
|
||||
def test_enabled_tools_config_prefers_tool_list_over_legacy_ingest_flag(self):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
@@ -200,6 +209,130 @@ 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_streams_multipart_without_buffering_whole_body(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"stream this upload"
|
||||
body, boundary = multipart_body(
|
||||
{"start_job": "false"},
|
||||
[("files", "stream.txt", "text/plain", payload)],
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"obsidian_wiki.mcp_server._read_http_body",
|
||||
side_effect=AssertionError("buffered body"),
|
||||
create=True,
|
||||
),
|
||||
patch.object(manager, "create_completed_upload", side_effect=AssertionError("bytes upload")),
|
||||
):
|
||||
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["uploads"][0]["sha256"], "sha256:" + hashlib.sha256(payload).hexdigest())
|
||||
|
||||
def test_http_upload_file_copy_does_not_block_event_loop(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(
|
||||
{"start_job": "false"},
|
||||
[("files", "stream.txt", "text/plain", b"stream this upload")],
|
||||
)
|
||||
original = manager.create_completed_upload_from_stream
|
||||
|
||||
def slow_copy(*args, **kwargs):
|
||||
time.sleep(0.1)
|
||||
return original(*args, **kwargs)
|
||||
|
||||
async def exercise():
|
||||
messages = []
|
||||
sent = False
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp-ingest/uploads",
|
||||
"raw_path": b"/mcp-ingest/uploads",
|
||||
"query_string": b"",
|
||||
"headers": [
|
||||
(b"authorization", b"Bearer secret-token"),
|
||||
(b"content-type", f"multipart/form-data; boundary={boundary}".encode("ascii")),
|
||||
],
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
started = time.monotonic()
|
||||
request = asyncio.create_task(app(scope, receive, send))
|
||||
await asyncio.sleep(0.01)
|
||||
responsive_after = time.monotonic() - started
|
||||
await request
|
||||
return responsive_after, messages
|
||||
|
||||
with patch.object(manager, "create_completed_upload_from_stream", side_effect=slow_copy):
|
||||
responsive_after, messages = asyncio.run(exercise())
|
||||
|
||||
self.assertLess(responsive_after, 0.08)
|
||||
self.assertEqual(messages[0]["status"], 200)
|
||||
|
||||
def test_http_upload_endpoint_rejects_oversized_content_length_before_reading(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
manager = IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
runtime_root=root / "runtime",
|
||||
max_file_bytes=1000,
|
||||
max_files_per_job=1,
|
||||
max_request_bytes=10,
|
||||
)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
|
||||
with patch("starlette.formparsers.MultiPartParser.parse", side_effect=AssertionError("body parsed")):
|
||||
messages = run_asgi_http_request(
|
||||
app,
|
||||
method="POST",
|
||||
path="/mcp-ingest/uploads",
|
||||
headers=[
|
||||
(b"authorization", b"Bearer secret-token"),
|
||||
(b"content-type", b"multipart/form-data; boundary=test"),
|
||||
(b"content-length", b"999999"),
|
||||
],
|
||||
body=b"",
|
||||
)
|
||||
|
||||
self.assertEqual(messages[0]["status"], 413)
|
||||
|
||||
def test_http_upload_endpoint_accepts_upload_ticket_without_main_bearer_token(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -254,6 +387,91 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertNotIn("path", data["uploads"][0])
|
||||
self.assertEqual(reused[0]["status"], 401)
|
||||
|
||||
def test_http_upload_ticket_constraints_cannot_be_escalated_by_form_fields(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",
|
||||
note="review first",
|
||||
start_job=False,
|
||||
)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
body, boundary = multipart_body(
|
||||
{
|
||||
"project": "attacker-project",
|
||||
"doc_type_hint": "attacker-type",
|
||||
"note": "ignore preview",
|
||||
"start_job": "true",
|
||||
},
|
||||
[("files", "drawing.pdf", "application/pdf", b"drawing")],
|
||||
)
|
||||
|
||||
with patch.object(manager, "start_ingest_job", side_effect=AssertionError("job started")):
|
||||
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,
|
||||
)
|
||||
|
||||
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["uploads"][0]["project"], "bjd")
|
||||
self.assertEqual(data["uploads"][0]["doc_type_hint"], "drawing")
|
||||
|
||||
def test_http_upload_ticket_error_response_says_ticket_was_consumed(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, start_job=False)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
|
||||
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", b"text/plain"),
|
||||
],
|
||||
body=b"not multipart",
|
||||
)
|
||||
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", b"text/plain"),
|
||||
],
|
||||
body=b"not multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(messages[0]["status"], 400)
|
||||
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["retry_action"], "create_new_upload_ticket")
|
||||
self.assertTrue(data["ticket_consumed"])
|
||||
self.assertEqual(reused[0]["status"], 401)
|
||||
|
||||
def test_http_upload_ticket_is_not_consumed_by_non_post_request(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -287,6 +505,40 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertEqual(get_messages[0]["status"], 405)
|
||||
self.assertEqual(post_messages[0]["status"], 200)
|
||||
|
||||
def test_http_upload_ticket_is_not_accepted_in_query_string(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, start_job=False)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
body, boundary = multipart_body({}, [("files", "safe.txt", "text/plain", b"safe")])
|
||||
|
||||
query_auth = run_asgi_http_request(
|
||||
app,
|
||||
method="POST",
|
||||
path="/mcp-ingest/uploads",
|
||||
query_string="upload_token=" + ticket["upload_token"],
|
||||
headers=[(b"content-type", f"multipart/form-data; boundary={boundary}".encode("ascii"))],
|
||||
body=body,
|
||||
)
|
||||
header_auth = 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(query_auth[0]["status"], 401)
|
||||
self.assertEqual(header_auth[0]["status"], 200)
|
||||
|
||||
def test_http_upload_endpoint_rejects_oversized_body_with_413(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -313,6 +565,74 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(messages[0]["status"], 413)
|
||||
|
||||
def test_http_upload_endpoint_rejects_oversized_file_part_during_parsing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
manager = IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
runtime_root=root / "runtime",
|
||||
max_file_bytes=5,
|
||||
max_files_per_job=2,
|
||||
)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
body, boundary = multipart_body(
|
||||
{"start_job": "false"},
|
||||
[("files", "too-large.txt", "text/plain", b"123456")],
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
manager,
|
||||
"create_completed_upload_from_stream",
|
||||
side_effect=AssertionError("file was fully parsed"),
|
||||
):
|
||||
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"], 413)
|
||||
|
||||
def test_http_upload_endpoint_rolls_back_files_when_one_file_is_invalid(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
manager = IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
runtime_root=root / "runtime",
|
||||
max_file_bytes=5,
|
||||
max_files_per_job=2,
|
||||
)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
body, boundary = multipart_body(
|
||||
{"start_job": "false"},
|
||||
[
|
||||
("files", "valid.txt", "text/plain", b"12345"),
|
||||
("files", "../invalid.txt", "text/plain", b"123"),
|
||||
],
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
remaining_uploads = list(manager.upload_root.rglob("upload.json"))
|
||||
|
||||
self.assertEqual(messages[0]["status"], 400)
|
||||
self.assertEqual(remaining_uploads, [])
|
||||
|
||||
def test_http_upload_endpoint_enforces_allowed_host_and_origin(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -325,7 +645,7 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
FakeApp(),
|
||||
"secret-token",
|
||||
ingest_manager=manager,
|
||||
allowed_hosts=["good.example"],
|
||||
allowed_hosts=["good.example:*"],
|
||||
allowed_origins=["https://app.example"],
|
||||
)
|
||||
body, boundary = multipart_body(
|
||||
@@ -363,6 +683,35 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertEqual(bad_origin[0]["status"], 403)
|
||||
self.assertEqual(allowed[0]["status"], 200)
|
||||
|
||||
def test_http_upload_endpoint_rejects_present_origin_when_origin_allowlist_is_empty(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,
|
||||
allowed_hosts=["good.example"],
|
||||
allowed_origins=[],
|
||||
)
|
||||
|
||||
messages = run_asgi_http_request(
|
||||
app,
|
||||
method="POST",
|
||||
path="/mcp-ingest/uploads",
|
||||
headers=[
|
||||
(b"authorization", b"Bearer secret-token"),
|
||||
(b"host", b"good.example"),
|
||||
(b"origin", b"https://unexpected.example"),
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(messages[0]["status"], 403)
|
||||
|
||||
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")
|
||||
@@ -598,6 +947,7 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
ingest_runtime_root=root / "ingest-runtime",
|
||||
query_runtime_root=root / "query-runtime",
|
||||
public_base_url="https://wiki.example.test/mcp",
|
||||
allow_source_files=True,
|
||||
ingest_manager=IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
@@ -620,12 +970,14 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
|
||||
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["schema_version"], 2)
|
||||
self.assertEqual(capabilities["capabilities_schema_version"], 2)
|
||||
self.assertEqual(capabilities["tools_version"], 2)
|
||||
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.assertIn("last_index_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"])
|
||||
@@ -642,11 +994,204 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertIn("last_ingest_at", capabilities["ingest"])
|
||||
self.assertTrue(capabilities["ingest"]["known_limitations"])
|
||||
self.assertIn("search_file_assets", capabilities["file_assets"]["tools"])
|
||||
self.assertTrue(capabilities["file_assets"]["source_file_download_enabled"])
|
||||
self.assertTrue(capabilities["safety"]["arbitrary_shell"])
|
||||
self.assertFalse(capabilities["safety"]["shell_sandbox_enforced"])
|
||||
self.assertTrue(capabilities["safety"]["opencode_auto_approve"])
|
||||
self.assertTrue(capabilities["safety"]["trusted_clients_required"])
|
||||
self.assertNotIn("vault_path", json.dumps(capabilities))
|
||||
self.assertNotIn("runtime", json.dumps(capabilities).lower())
|
||||
self.assertNotIn("Bearer ", json.dumps(capabilities))
|
||||
self.assertNotIn("upload_token", json.dumps(capabilities))
|
||||
|
||||
def test_server_resource_read_honors_source_file_gate(self):
|
||||
class FakeMCP:
|
||||
resources = {}
|
||||
|
||||
def __init__(self, _name, **_kwargs):
|
||||
pass
|
||||
|
||||
def tool(self):
|
||||
return lambda func: func
|
||||
|
||||
def resource(self, template, **_kwargs):
|
||||
def decorator(func):
|
||||
self.resources[template] = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def prompt(self, *_args, **_kwargs):
|
||||
return lambda func: func
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp,
|
||||
patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}),
|
||||
patch.object(
|
||||
mcp_server,
|
||||
"read_file_asset_resource",
|
||||
return_value={"bytes": b"content"},
|
||||
) as read_resource,
|
||||
):
|
||||
root = Path(tmp)
|
||||
mcp_server.create_server(vault_path=root, allow_source_files=False)
|
||||
resource = FakeMCP.resources["asset://source/sha256/{digest}"]
|
||||
self.assertEqual(resource("a" * 64), b"content")
|
||||
self.assertFalse(read_resource.call_args.kwargs["allow_source_files"])
|
||||
|
||||
mcp_server.create_server(vault_path=root, allow_source_files=True)
|
||||
resource = FakeMCP.resources["asset://source/sha256/{digest}"]
|
||||
self.assertEqual(resource("b" * 64), b"content")
|
||||
self.assertTrue(read_resource.call_args.kwargs["allow_source_files"])
|
||||
|
||||
def test_readiness_tools_hide_local_paths(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):
|
||||
return lambda func: func
|
||||
|
||||
def prompt(self, *_args, **_kwargs):
|
||||
return lambda func: func
|
||||
|
||||
class FakeManager:
|
||||
def check_opencode_readiness(self, *, run_smoke_test=False):
|
||||
return {
|
||||
"ok": False,
|
||||
"checked_paths": ["/secret/project/.agents/skills/wiki-ingest/SKILL.md"],
|
||||
"errors": ["missing /secret/project/.agents/skills/wiki-ingest/SKILL.md"],
|
||||
}
|
||||
|
||||
def _public_preflight(self, result):
|
||||
return {"ok": result["ok"], "errors": ["missing <project> skill"]}
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp,
|
||||
patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}),
|
||||
):
|
||||
mcp_server.create_server(
|
||||
vault_path=Path(tmp) / "vault",
|
||||
enable_ingest=True,
|
||||
ingest_manager=FakeManager(),
|
||||
)
|
||||
readiness = FakeMCP.tools["check_ingest_opencode_readiness"]()
|
||||
|
||||
self.assertNotIn("checked_paths", readiness)
|
||||
self.assertNotIn("/secret/project", json.dumps(readiness))
|
||||
|
||||
def test_create_server_passes_job_timeouts_to_managers(self):
|
||||
class FakeMCP:
|
||||
def __init__(self, _name, **_kwargs):
|
||||
pass
|
||||
|
||||
def tool(self):
|
||||
return lambda func: func
|
||||
|
||||
def resource(self, *_args, **_kwargs):
|
||||
return lambda func: func
|
||||
|
||||
def prompt(self, *_args, **_kwargs):
|
||||
return lambda func: func
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp,
|
||||
patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}),
|
||||
patch.object(mcp_server, "IngestManager") as ingest_manager,
|
||||
patch.object(mcp_server, "QueryManager") as query_manager,
|
||||
):
|
||||
root = Path(tmp)
|
||||
mcp_server.create_server(
|
||||
vault_path=root / "vault",
|
||||
enable_ingest=True,
|
||||
enable_query=True,
|
||||
project_root=root / "project",
|
||||
ingest_runtime_root=root / "ingest",
|
||||
query_runtime_root=root / "query",
|
||||
ingest_job_timeout_seconds=17,
|
||||
query_job_timeout_seconds=23,
|
||||
)
|
||||
|
||||
self.assertEqual(ingest_manager.call_args.kwargs["job_timeout_seconds"], 17)
|
||||
self.assertEqual(query_manager.call_args.kwargs["job_timeout_seconds"], 23)
|
||||
|
||||
def test_get_mcp_capabilities_recommends_only_enabled_tool_groups(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
|
||||
|
||||
class FakeQueryManager:
|
||||
def ask_wiki(self, query, mode="normal", public_only=False, note=None, timeout_seconds=90, poll_interval_seconds=2):
|
||||
return {}
|
||||
|
||||
def start_query_job(self, query, mode="normal", public_only=False, note=None):
|
||||
return {}
|
||||
|
||||
def get_query_job_status(self, job_id):
|
||||
return {}
|
||||
|
||||
def get_query_job_result(self, job_id):
|
||||
return {}
|
||||
|
||||
def cancel_query_job(self, job_id):
|
||||
return {}
|
||||
|
||||
def check_opencode_readiness(self, *, run_smoke_test=False):
|
||||
return {}
|
||||
|
||||
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_file_assets=False,
|
||||
enable_ingest=False,
|
||||
enable_query=True,
|
||||
project_root=root / "project",
|
||||
query_runtime_root=root / "query-runtime",
|
||||
query_manager=FakeQueryManager(),
|
||||
)
|
||||
capabilities = FakeMCP.tools["get_mcp_capabilities"]()
|
||||
|
||||
first_steps = "\n".join(capabilities["recommended_first_steps"])
|
||||
self.assertEqual(capabilities["enabled_tools"], ["query"])
|
||||
self.assertIn("ask_wiki", first_steps)
|
||||
self.assertNotIn("search_file_assets", first_steps)
|
||||
self.assertNotIn("ingest_text_note", first_steps)
|
||||
self.assertNotIn("create_ingest_upload_ticket", first_steps)
|
||||
|
||||
def test_file_index_status_reports_opencode_model_settings(self):
|
||||
class FakeMCP:
|
||||
tools = {}
|
||||
@@ -795,6 +1340,61 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertNotIn("Authorization: Bearer", instructions["http_upload"]["curl_example"])
|
||||
self.assertEqual(instructions["direct_text_tool"]["name"], "ingest_text_note")
|
||||
|
||||
def test_create_ingest_upload_ticket_prefers_explicit_upload_url(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(
|
||||
os.environ,
|
||||
{"OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL": "https://uploads.example.test/custom/upload"},
|
||||
clear=False,
|
||||
),
|
||||
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",
|
||||
public_base_url="https://wiki.example.test/proxy/mcp",
|
||||
)
|
||||
capabilities = FakeMCP.tools["get_mcp_capabilities"]()
|
||||
ticket = FakeMCP.tools["create_ingest_upload_ticket"](
|
||||
expires_seconds=60,
|
||||
max_files=1,
|
||||
start_job=False,
|
||||
)
|
||||
instructions = FakeMCP.tools["get_ingest_upload_instructions"]()
|
||||
|
||||
self.assertEqual(capabilities["ingest"]["http_upload_url"], "https://uploads.example.test/custom/upload")
|
||||
self.assertEqual(ticket["upload_url"], "https://uploads.example.test/custom/upload")
|
||||
self.assertEqual(instructions["http_upload"]["url"], "https://uploads.example.test/custom/upload")
|
||||
|
||||
def test_ingest_tools_validate_upload_and_preview_metadata(self):
|
||||
class FakeMCP:
|
||||
tools = {}
|
||||
@@ -1034,6 +1634,64 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
)
|
||||
create_server.assert_not_called()
|
||||
|
||||
def test_main_forwards_managed_security_and_timeout_environment(self):
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OBSIDIAN_MCP_TRANSPORT": "streamable-http",
|
||||
"OBSIDIAN_MCP_ALLOW_SOURCE_FILES": "1",
|
||||
"OBSIDIAN_MCP_ALLOWED_HOSTS": "wiki.example:*,10.0.0.2:*",
|
||||
"OBSIDIAN_MCP_ALLOWED_ORIGINS": "https://app.example",
|
||||
"OBSIDIAN_MCP_INGEST_JOB_TIMEOUT_SECONDS": "123",
|
||||
"OBSIDIAN_MCP_QUERY_JOB_TIMEOUT_SECONDS": "45",
|
||||
"OBSIDIAN_MCP_INGEST_MAX_REQUEST_BYTES": "999",
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch.object(mcp_server, "resolve_vault_path", return_value="/tmp/test-vault"),
|
||||
patch.object(mcp_server, "run_http_server") as run_http_server,
|
||||
):
|
||||
result = mcp_server.main([])
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
kwargs = run_http_server.call_args.kwargs
|
||||
self.assertTrue(kwargs["allow_source_files"])
|
||||
self.assertEqual(kwargs["allowed_hosts"], ["wiki.example:*", "10.0.0.2:*"])
|
||||
self.assertEqual(kwargs["allowed_origins"], ["https://app.example"])
|
||||
self.assertEqual(kwargs["ingest_job_timeout_seconds"], 123)
|
||||
self.assertEqual(kwargs["query_job_timeout_seconds"], 45)
|
||||
self.assertEqual(kwargs["ingest_max_request_bytes"], 999)
|
||||
|
||||
def test_main_closes_job_managers_after_stdio_server_stops(self):
|
||||
manager = type("Manager", (), {"close": unittest.mock.Mock()})()
|
||||
|
||||
class FakeServer:
|
||||
_obsidian_job_managers = (manager,)
|
||||
|
||||
def run(self, **_kwargs):
|
||||
return None
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"OBSIDIAN_MCP_TRANSPORT": "stdio"}, clear=True),
|
||||
patch.object(mcp_server, "resolve_vault_path", return_value="/tmp/test-vault"),
|
||||
patch.object(mcp_server, "create_server", return_value=FakeServer()),
|
||||
):
|
||||
result = mcp_server.main([])
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
manager.close.assert_called_once_with()
|
||||
|
||||
def test_close_server_managers_attempts_every_manager(self):
|
||||
first = type("Manager", (), {"close": lambda self: (_ for _ in ()).throw(OSError("disk full"))})()
|
||||
second = type("Manager", (), {"close": unittest.mock.Mock()})()
|
||||
server = type("Server", (), {"_obsidian_job_managers": (first, second)})()
|
||||
|
||||
errors = mcp_server.close_server_managers(server)
|
||||
|
||||
self.assertEqual(len(errors), 1)
|
||||
second.close.assert_called_once_with()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user