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:
+546
-2
@@ -45,12 +45,14 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("/home/nac/.local/bin/uv run --no-sync python -m obsidian_wiki.mcp_server", unit)
|
||||
self.assertIn("--vault /data/bjd-vault", unit)
|
||||
self.assertIn("--transport streamable-http", unit)
|
||||
self.assertIn("--host 0.0.0.0", unit)
|
||||
self.assertIn("--port 8765", unit)
|
||||
self.assertNotIn("--host", unit)
|
||||
self.assertNotIn("--port", unit)
|
||||
self.assertNotIn("--path", unit)
|
||||
self.assertIn("PassEnvironment=http_proxy https_proxy all_proxy no_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY", unit)
|
||||
self.assertIn("MemoryMax=2G", unit)
|
||||
self.assertIn("Restart=on-failure", unit)
|
||||
self.assertNotIn("OBSIDIAN_MCP_TOKEN=", unit)
|
||||
self.assertNotIn("readonly", unit.lower())
|
||||
|
||||
def test_write_token_env_file_is_private(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -63,7 +65,12 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
port=8765,
|
||||
path="/mcp",
|
||||
public_base_url="http://192.168.1.10:8765/mcp",
|
||||
public_upload_url="https://uploads.example.test/custom/upload",
|
||||
enable_ingest=True,
|
||||
allow_source_files=False,
|
||||
ingest_job_timeout_seconds=111,
|
||||
query_job_timeout_seconds=22,
|
||||
ingest_max_request_bytes=333,
|
||||
)
|
||||
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
@@ -72,15 +79,112 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
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_INGEST_PUBLIC_UPLOAD_URL=https://uploads.example.test/custom/upload\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_ALLOW_SOURCE_FILES=0\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_ALLOWED_HOSTS=", content)
|
||||
self.assertIn("192.168.1.10:*", content)
|
||||
self.assertIn("OBSIDIAN_MCP_ALLOWED_ORIGINS=", content)
|
||||
self.assertIn("http://192.168.1.10:8765", content)
|
||||
self.assertIn("https://uploads.example.test", 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)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_JOB_TIMEOUT_SECONDS=111\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_JOB_TIMEOUT_SECONDS=22\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_MAX_REQUEST_BYTES=333\n", content)
|
||||
self.assertEqual(stat.S_IMODE(os.stat(env_path).st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(os.stat(env_path.parent).st_mode), 0o700)
|
||||
|
||||
def test_saved_service_config_returns_dataclass_and_preserves_explicit_source_file_setting(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
env_path,
|
||||
"saved-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
public_base_url="https://wiki.example.test/saved-mcp",
|
||||
public_upload_url="https://uploads.example.test/saved",
|
||||
enabled_tools=("file-assets", "query"),
|
||||
allow_source_files=False,
|
||||
ingest_job_timeout_seconds=444,
|
||||
query_job_timeout_seconds=55,
|
||||
ingest_max_request_bytes=666,
|
||||
)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
|
||||
self.assertIsInstance(config, manage_mcp_service.ServiceConfig)
|
||||
self.assertEqual(config.token, "saved-token")
|
||||
self.assertEqual(config.host, "127.0.0.1")
|
||||
self.assertEqual(config.port, 9988)
|
||||
self.assertEqual(config.path, "/saved-mcp")
|
||||
self.assertEqual(config.public_base_url, "https://wiki.example.test/saved-mcp")
|
||||
self.assertEqual(config.public_upload_url, "https://uploads.example.test/saved")
|
||||
self.assertEqual(config.enabled_tools, ("file-assets", "query"))
|
||||
self.assertFalse(config.allow_source_files)
|
||||
self.assertEqual(config.ingest_job_timeout_seconds, 444)
|
||||
self.assertEqual(config.query_job_timeout_seconds, 55)
|
||||
self.assertEqual(config.ingest_max_request_bytes, 666)
|
||||
self.assertIn("wiki.example.test:*", config.allowed_hosts)
|
||||
self.assertIn("uploads.example.test:*", config.allowed_hosts)
|
||||
self.assertIn("https://wiki.example.test", config.allowed_origins)
|
||||
self.assertIn("https://uploads.example.test", config.allowed_origins)
|
||||
|
||||
def test_saved_service_config_migrates_legacy_source_file_setting(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env"
|
||||
env_path.parent.mkdir(parents=True)
|
||||
env_path.write_text(
|
||||
"OBSIDIAN_MCP_TOKEN=saved-token\n"
|
||||
"OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,query\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
|
||||
self.assertTrue(config.allow_source_files)
|
||||
|
||||
env_path.write_text(
|
||||
"OBSIDIAN_MCP_TOKEN=saved-token\n"
|
||||
"OBSIDIAN_MCP_ENABLED_TOOLS=query\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertFalse(config.allow_source_files)
|
||||
|
||||
def test_ensure_service_config_returns_paths_and_default_service_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="created-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
):
|
||||
paths, config = manage_mcp_service.ensure_service_config(project_root)
|
||||
|
||||
self.assertIsInstance(paths, manage_mcp_service.ServicePaths)
|
||||
self.assertIsInstance(config, manage_mcp_service.ServiceConfig)
|
||||
self.assertEqual(config.host, "0.0.0.0")
|
||||
self.assertEqual(config.port, 8765)
|
||||
self.assertEqual(config.path, "/mcp")
|
||||
self.assertEqual(config.public_base_url, "http://10.0.0.2:8765/mcp")
|
||||
self.assertFalse(config.allow_source_files)
|
||||
self.assertIn("10.0.0.2:*", config.allowed_hosts)
|
||||
self.assertIn("http://10.0.0.2:8765", config.allowed_origins)
|
||||
self.assertIn(
|
||||
"OBSIDIAN_MCP_ALLOW_SOURCE_FILES=0\n",
|
||||
paths.env_path.read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
def test_resolve_vault_for_service_uses_explicit_or_configured_vault(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
@@ -115,6 +219,17 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn('"wiki-file-assets"', output)
|
||||
self.assertNotIn('"obsidian-wiki"', output)
|
||||
|
||||
def test_remote_urls_bracket_ipv6_hosts(self):
|
||||
self.assertEqual(
|
||||
manage_mcp_service.public_base_url_from_hosts(["2001:db8::1"]),
|
||||
"http://[2001:db8::1]:8765/mcp",
|
||||
)
|
||||
output = manage_mcp_service.render_remote_config(
|
||||
hosts=["2001:db8::1"],
|
||||
token="secret-token",
|
||||
)
|
||||
self.assertIn('"url": "http://[2001:db8::1]:8765/mcp"', output)
|
||||
|
||||
def test_render_remote_config_prints_ingest_upload_endpoint_when_enabled(self):
|
||||
output = manage_mcp_service.render_remote_config(
|
||||
hosts=["192.168.1.10", "10.0.0.2"],
|
||||
@@ -143,6 +258,41 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("https://wiki.example.test/mcp-ingest/uploads", output)
|
||||
self.assertNotIn("http://192.168.1.10:8765/mcp-ingest/uploads", output)
|
||||
|
||||
def test_render_remote_config_uses_normalized_public_url_as_primary_client_endpoint(self):
|
||||
output = manage_mcp_service.render_remote_config(
|
||||
hosts=["192.168.1.10", "10.0.0.2"],
|
||||
token="test-token",
|
||||
port=8765,
|
||||
path="/mcp",
|
||||
public_base_url=" https://wiki.example.test/proxy/mcp/?ignored=yes#fragment ",
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
"Use this URL from another machine:\nhttps://wiki.example.test/proxy/mcp\n",
|
||||
output,
|
||||
)
|
||||
self.assertIn('"url": "https://wiki.example.test/proxy/mcp"', output)
|
||||
self.assertIn(
|
||||
"Other detected addresses:\n"
|
||||
"- http://192.168.1.10:8765/mcp\n"
|
||||
"- http://10.0.0.2:8765/mcp\n",
|
||||
output,
|
||||
)
|
||||
|
||||
def test_render_remote_config_prefers_explicit_upload_url(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/proxy/mcp",
|
||||
public_upload_url="https://uploads.example.test/custom/upload",
|
||||
enabled_tools=("file-assets", "ingest"),
|
||||
)
|
||||
|
||||
self.assertIn("https://uploads.example.test/custom/upload", output)
|
||||
self.assertNotIn("https://wiki.example.test/mcp-ingest/uploads", output)
|
||||
|
||||
def test_new_token_writes_token_without_touching_systemd_when_unit_missing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
@@ -160,6 +310,7 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
env_content = env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_TOKEN=new-token\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PORT=8765\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_ALLOW_SOURCE_FILES=0\n", env_content)
|
||||
self.assertIn("http://10.0.0.2:8765/mcp", output)
|
||||
run_systemctl.assert_not_called()
|
||||
|
||||
@@ -184,6 +335,158 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("http://10.0.0.2:8765/mcp", output)
|
||||
run_systemctl.assert_called_once_with(["restart", manage_mcp_service.SERVICE_NAME])
|
||||
|
||||
def test_new_token_preserves_saved_service_config_when_endpoint_arguments_are_omitted(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
env_path,
|
||||
"old-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
public_base_url="https://wiki.example.test/saved-mcp",
|
||||
public_upload_url="https://uploads.example.test/saved",
|
||||
enabled_tools=("file-assets", "query"),
|
||||
allow_source_files=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
|
||||
):
|
||||
output = manage_mcp_service.new_token(project_root=project_root)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.token, "new-token")
|
||||
self.assertEqual(config.host, "127.0.0.1")
|
||||
self.assertEqual(config.port, 9988)
|
||||
self.assertEqual(config.path, "/saved-mcp")
|
||||
self.assertEqual(config.public_base_url, "https://wiki.example.test/saved-mcp")
|
||||
self.assertEqual(config.public_upload_url, "https://uploads.example.test/saved")
|
||||
self.assertEqual(config.enabled_tools, ("file-assets", "query"))
|
||||
self.assertFalse(config.allow_source_files)
|
||||
self.assertIn('"url": "https://wiki.example.test/saved-mcp"', output)
|
||||
run_systemctl.assert_not_called()
|
||||
|
||||
def test_new_token_only_changes_token_when_saved_public_urls_are_empty(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
env_path,
|
||||
"old-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
allow_source_files=False,
|
||||
)
|
||||
original_lines = env_path.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
output = manage_mcp_service.new_token(project_root=project_root)
|
||||
|
||||
updated_lines = env_path.read_text(encoding="utf-8").splitlines()
|
||||
self.assertEqual(updated_lines[1:], original_lines[1:])
|
||||
self.assertEqual(updated_lines[0], "OBSIDIAN_MCP_TOKEN=new-token")
|
||||
self.assertNotIn("OBSIDIAN_MCP_PUBLIC_BASE_URL=", "\n".join(updated_lines))
|
||||
self.assertIn('"url": "http://10.0.0.2:9988/saved-mcp"', output)
|
||||
|
||||
def test_new_token_explicit_endpoint_and_public_urls_override_saved_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
project_root / ".runtime" / "obsidian-wiki-mcp.env",
|
||||
"old-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
public_base_url="https://wiki.example.test/saved-mcp",
|
||||
public_upload_url="https://uploads.example.test/saved",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
output = manage_mcp_service.new_token(
|
||||
project_root=project_root,
|
||||
host="0.0.0.0",
|
||||
port=8877,
|
||||
path="/new-mcp",
|
||||
public_base_url="https://new.example.test/api/mcp/",
|
||||
public_upload_url="https://uploads-new.example.test/api/upload/",
|
||||
)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.host, "0.0.0.0")
|
||||
self.assertEqual(config.port, 8877)
|
||||
self.assertEqual(config.path, "/new-mcp")
|
||||
self.assertEqual(config.public_base_url, "https://new.example.test/api/mcp")
|
||||
self.assertEqual(config.public_upload_url, "https://uploads-new.example.test/api/upload")
|
||||
self.assertIn("new.example.test", config.allowed_hosts)
|
||||
self.assertIn("uploads-new.example.test", config.allowed_hosts)
|
||||
self.assertIn("https://new.example.test", config.allowed_origins)
|
||||
self.assertIn("https://uploads-new.example.test", config.allowed_origins)
|
||||
self.assertIn('"url": "https://new.example.test/api/mcp"', output)
|
||||
|
||||
def test_new_token_recomputes_public_url_when_only_endpoint_changes(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
manage_mcp_service.write_token_env_file(
|
||||
project_root / ".runtime" / "obsidian-wiki-mcp.env",
|
||||
"old-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
public_base_url="https://wiki.example.test/saved-mcp",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
manage_mcp_service.new_token(
|
||||
project_root=project_root,
|
||||
port=8877,
|
||||
path="/new-mcp",
|
||||
)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.public_base_url, "http://127.0.0.1:8877/new-mcp")
|
||||
|
||||
def test_new_token_first_install_uses_concrete_bind_host_as_public_url(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
|
||||
with (
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
manage_mcp_service.new_token(project_root=project_root, host="127.0.0.1")
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.public_base_url, "http://127.0.0.1:8765/mcp")
|
||||
|
||||
def test_start_service_uses_saved_token_and_restarts_service(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
@@ -216,6 +519,8 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("Bearer saved-token", output)
|
||||
env_content = (project_root / ".runtime" / "obsidian-wiki-mcp.env").read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets\n", env_content)
|
||||
self.assertNotIn("OBSIDIAN_MCP_PUBLIC_BASE_URL=", env_content)
|
||||
self.assertNotIn("OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL=", env_content)
|
||||
generate_token.assert_not_called()
|
||||
prepare_runtime.assert_called_once_with(project_root, uv_path=Path("/home/nac/.local/bin/uv"))
|
||||
run_systemctl.assert_has_calls(
|
||||
@@ -226,6 +531,156 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
]
|
||||
)
|
||||
|
||||
def test_start_service_preserves_saved_endpoint_and_public_urls_when_arguments_are_omitted(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
env_path,
|
||||
"saved-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
public_base_url="https://wiki.example.test/saved-mcp",
|
||||
public_upload_url="https://uploads.example.test/saved",
|
||||
allow_source_files=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
|
||||
patch.object(manage_mcp_service, "resolve_vault_for_service", return_value=Path("/data/bjd-vault")),
|
||||
patch.object(manage_mcp_service, "prepare_runtime"),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
output = manage_mcp_service.start_service(project_root=project_root)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.host, "127.0.0.1")
|
||||
self.assertEqual(config.port, 9988)
|
||||
self.assertEqual(config.path, "/saved-mcp")
|
||||
self.assertEqual(config.public_base_url, "https://wiki.example.test/saved-mcp")
|
||||
self.assertEqual(config.public_upload_url, "https://uploads.example.test/saved")
|
||||
self.assertFalse(config.allow_source_files)
|
||||
unit_content = (
|
||||
config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertNotIn("--host", unit_content)
|
||||
env_content = env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_HOST=127.0.0.1", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PORT=9988", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PATH=/saved-mcp", env_content)
|
||||
self.assertIn('"url": "https://wiki.example.test/saved-mcp"', output)
|
||||
|
||||
def test_start_service_explicit_arguments_override_saved_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
project_root / ".runtime" / "obsidian-wiki-mcp.env",
|
||||
"saved-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
public_base_url="https://wiki.example.test/saved-mcp",
|
||||
public_upload_url="https://uploads.example.test/saved",
|
||||
allow_source_files=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
|
||||
patch.object(manage_mcp_service, "resolve_vault_for_service", return_value=Path("/data/bjd-vault")),
|
||||
patch.object(manage_mcp_service, "prepare_runtime"),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
output = manage_mcp_service.start_service(
|
||||
project_root=project_root,
|
||||
host="0.0.0.0",
|
||||
port=8877,
|
||||
path="/new-mcp",
|
||||
public_base_url="https://new.example.test/api/mcp/",
|
||||
public_upload_url="https://uploads-new.example.test/api/upload/",
|
||||
allow_source_files=True,
|
||||
)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.host, "0.0.0.0")
|
||||
self.assertEqual(config.port, 8877)
|
||||
self.assertEqual(config.path, "/new-mcp")
|
||||
self.assertEqual(config.public_base_url, "https://new.example.test/api/mcp")
|
||||
self.assertEqual(config.public_upload_url, "https://uploads-new.example.test/api/upload")
|
||||
self.assertTrue(config.allow_source_files)
|
||||
self.assertIn("new.example.test", config.allowed_hosts)
|
||||
self.assertIn("uploads-new.example.test", config.allowed_hosts)
|
||||
self.assertIn("https://new.example.test", config.allowed_origins)
|
||||
self.assertIn("https://uploads-new.example.test", config.allowed_origins)
|
||||
unit_content = (
|
||||
config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertNotIn("--host", unit_content)
|
||||
env_content = (project_root / ".runtime" / "obsidian-wiki-mcp.env").read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_HOST=0.0.0.0", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PORT=8877", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PATH=/new-mcp", env_content)
|
||||
self.assertIn('"url": "https://new.example.test/api/mcp"', output)
|
||||
|
||||
def test_start_service_recomputes_public_url_when_only_endpoint_changes(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
project_root / ".runtime" / "obsidian-wiki-mcp.env",
|
||||
"saved-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
public_base_url="https://wiki.example.test/saved-mcp",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
|
||||
patch.object(manage_mcp_service, "resolve_vault_for_service", return_value=Path("/data/bjd-vault")),
|
||||
patch.object(manage_mcp_service, "prepare_runtime"),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
manage_mcp_service.start_service(
|
||||
project_root=project_root,
|
||||
port=8877,
|
||||
path="/new-mcp",
|
||||
)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.public_base_url, "http://127.0.0.1:8877/new-mcp")
|
||||
|
||||
def test_start_service_first_install_uses_concrete_bind_host_as_public_url(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="created-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
|
||||
patch.object(manage_mcp_service, "resolve_vault_for_service", return_value=Path("/data/bjd-vault")),
|
||||
patch.object(manage_mcp_service, "prepare_runtime"),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
manage_mcp_service.start_service(project_root=project_root, host="127.0.0.1")
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.public_base_url, "http://127.0.0.1:8765/mcp")
|
||||
|
||||
def test_start_service_generates_token_when_missing_and_prints_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
@@ -251,9 +706,11 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env"
|
||||
env_content = env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_TOKEN=created-token\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_HOST=0.0.0.0\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_ALLOW_SOURCE_FILES=0\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)
|
||||
@@ -301,6 +758,11 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
+ "OBSIDIAN_MCP_PUBLIC_BASE_URL=https://wiki.example.test/mcp\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
env_path.write_text(
|
||||
env_path.read_text(encoding="utf-8")
|
||||
+ "OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL=https://uploads.example.test/custom/upload\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
@@ -321,6 +783,7 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
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)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL=https://uploads.example.test/custom/upload\n", env_content)
|
||||
|
||||
def test_start_service_can_enable_query_without_ingest(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -496,6 +959,82 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertTrue(start_service.call_args.kwargs["enable_query"])
|
||||
print_fn.assert_called_once_with("config\n", end="")
|
||||
|
||||
def test_main_start_passes_none_for_omitted_overrides_and_source_file_policy_flags(self):
|
||||
with (
|
||||
patch.object(manage_mcp_service, "start_service", return_value="config\n") as start_service,
|
||||
patch("builtins.print"),
|
||||
):
|
||||
status = manage_mcp_service.main(["start", "--deny-source-files"])
|
||||
|
||||
self.assertEqual(status, 0)
|
||||
kwargs = start_service.call_args.kwargs
|
||||
self.assertIsNone(kwargs["host"])
|
||||
self.assertIsNone(kwargs["port"])
|
||||
self.assertIsNone(kwargs["path"])
|
||||
self.assertIsNone(kwargs["public_base_url"])
|
||||
self.assertIsNone(kwargs["public_upload_url"])
|
||||
self.assertFalse(kwargs["allow_source_files"])
|
||||
|
||||
with (
|
||||
patch.object(manage_mcp_service, "start_service", return_value="config\n") as start_service,
|
||||
patch("builtins.print"),
|
||||
):
|
||||
status = manage_mcp_service.main(["start", "--allow-source-files"])
|
||||
|
||||
self.assertEqual(status, 0)
|
||||
self.assertTrue(start_service.call_args.kwargs["allow_source_files"])
|
||||
|
||||
def test_main_passes_explicit_endpoint_and_public_url_overrides(self):
|
||||
endpoint_args = [
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"9988",
|
||||
"--path",
|
||||
"/custom-mcp",
|
||||
"--public-base-url",
|
||||
"https://wiki.example.test/custom-mcp",
|
||||
"--public-upload-url",
|
||||
"https://uploads.example.test/custom",
|
||||
]
|
||||
with (
|
||||
patch.object(manage_mcp_service, "start_service", return_value="config\n") as start_service,
|
||||
patch("builtins.print"),
|
||||
):
|
||||
start_status = manage_mcp_service.main(["start", *endpoint_args])
|
||||
|
||||
self.assertEqual(start_status, 0)
|
||||
self.assertEqual(start_service.call_args.kwargs["host"], "127.0.0.1")
|
||||
self.assertEqual(start_service.call_args.kwargs["port"], 9988)
|
||||
self.assertEqual(start_service.call_args.kwargs["path"], "/custom-mcp")
|
||||
self.assertEqual(
|
||||
start_service.call_args.kwargs["public_base_url"],
|
||||
"https://wiki.example.test/custom-mcp",
|
||||
)
|
||||
self.assertEqual(
|
||||
start_service.call_args.kwargs["public_upload_url"],
|
||||
"https://uploads.example.test/custom",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(manage_mcp_service, "new_token", return_value="config\n") as new_token,
|
||||
patch("builtins.print"),
|
||||
):
|
||||
token_status = manage_mcp_service.main(["new-token", *endpoint_args])
|
||||
|
||||
self.assertEqual(token_status, 0)
|
||||
self.assertEqual(new_token.call_args.kwargs["host"], "127.0.0.1")
|
||||
self.assertEqual(new_token.call_args.kwargs["port"], 9988)
|
||||
self.assertEqual(new_token.call_args.kwargs["path"], "/custom-mcp")
|
||||
self.assertEqual(
|
||||
new_token.call_args.kwargs["public_base_url"],
|
||||
"https://wiki.example.test/custom-mcp",
|
||||
)
|
||||
self.assertEqual(
|
||||
new_token.call_args.kwargs["public_upload_url"],
|
||||
"https://uploads.example.test/custom",
|
||||
)
|
||||
|
||||
def test_main_new_token_does_not_expect_enable_ingest_argument(self):
|
||||
with (
|
||||
patch.object(manage_mcp_service, "new_token", return_value="config\n") as new_token,
|
||||
@@ -506,6 +1045,11 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertEqual(status, 0)
|
||||
new_token.assert_called_once()
|
||||
self.assertNotIn("enable_ingest", new_token.call_args.kwargs)
|
||||
self.assertIsNone(new_token.call_args.kwargs["host"])
|
||||
self.assertIsNone(new_token.call_args.kwargs["port"])
|
||||
self.assertIsNone(new_token.call_args.kwargs["path"])
|
||||
self.assertIsNone(new_token.call_args.kwargs["public_base_url"])
|
||||
self.assertIsNone(new_token.call_args.kwargs["public_upload_url"])
|
||||
print_fn.assert_called_once_with("config\n", end="")
|
||||
|
||||
def test_main_cleanup_delegates_to_runtime_cleanup(self):
|
||||
|
||||
Reference in New Issue
Block a user