feat: support remote MCP asset server
Add Streamable HTTP transport for the source-file MCP server with bearer-token auth, configurable HTTP runtime options, and user-level systemd service helpers. Document remote client setup and cover auth/service behavior with unit tests.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from obsidian_wiki import mcp_server
|
||||
|
||||
|
||||
class FakeApp:
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
self.called = True
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b"ok"})
|
||||
|
||||
|
||||
def run_asgi_app(app, *, headers=None, scope_type="http"):
|
||||
messages = []
|
||||
scope = {"type": scope_type, "headers": headers or []}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(message):
|
||||
messages.append(message)
|
||||
|
||||
asyncio.run(app(scope, receive, send))
|
||||
return messages
|
||||
|
||||
|
||||
class RemoteMcpServerTests(unittest.TestCase):
|
||||
def test_bearer_token_middleware_accepts_matching_token(self):
|
||||
inner = FakeApp()
|
||||
middleware = mcp_server.BearerTokenAuthMiddleware(inner, "secret-token")
|
||||
|
||||
messages = run_asgi_app(
|
||||
middleware,
|
||||
headers=[(b"authorization", b"Bearer secret-token")],
|
||||
)
|
||||
|
||||
self.assertTrue(inner.called)
|
||||
self.assertEqual(messages[0]["status"], 200)
|
||||
|
||||
def test_bearer_token_middleware_rejects_missing_or_wrong_token(self):
|
||||
inner = FakeApp()
|
||||
middleware = mcp_server.BearerTokenAuthMiddleware(inner, "secret-token")
|
||||
|
||||
messages = run_asgi_app(
|
||||
middleware,
|
||||
headers=[(b"authorization", b"Bearer wrong-token")],
|
||||
)
|
||||
|
||||
self.assertFalse(inner.called)
|
||||
self.assertEqual(messages[0]["status"], 401)
|
||||
self.assertIn((b"www-authenticate", b"Bearer"), messages[0]["headers"])
|
||||
|
||||
def test_bearer_token_middleware_requires_non_empty_token(self):
|
||||
with self.assertRaises(ValueError):
|
||||
mcp_server.BearerTokenAuthMiddleware(FakeApp(), "")
|
||||
|
||||
def test_read_token_from_env_trims_and_requires_value(self):
|
||||
with patch.dict(os.environ, {"TEST_MCP_TOKEN": " secret-token "}, clear=False):
|
||||
self.assertEqual(mcp_server.read_token_from_env("TEST_MCP_TOKEN"), "secret-token")
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with self.assertRaises(RuntimeError):
|
||||
mcp_server.read_token_from_env("TEST_MCP_TOKEN")
|
||||
|
||||
def test_runtime_config_reads_environment_with_defaults(self):
|
||||
with patch.dict(os.environ, {"OBSIDIAN_MCP_HOST": " 0.0.0.0 ", "OBSIDIAN_MCP_PORT": "9876"}, clear=True):
|
||||
self.assertEqual(mcp_server.runtime_config_value("OBSIDIAN_MCP_HOST", "127.0.0.1"), "0.0.0.0")
|
||||
self.assertEqual(mcp_server.runtime_config_int("OBSIDIAN_MCP_PORT", 8765), 9876)
|
||||
|
||||
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)
|
||||
|
||||
def test_create_http_app_wraps_streamable_http_app_with_auth(self):
|
||||
class FakeServer:
|
||||
def __init__(self):
|
||||
self.inner = FakeApp()
|
||||
|
||||
def streamable_http_app(self):
|
||||
return self.inner
|
||||
|
||||
fake_server = FakeServer()
|
||||
app = mcp_server.create_http_app(fake_server, "secret-token")
|
||||
|
||||
messages = run_asgi_app(
|
||||
app,
|
||||
headers=[(b"authorization", b"Bearer secret-token")],
|
||||
)
|
||||
|
||||
self.assertTrue(fake_server.inner.called)
|
||||
self.assertEqual(messages[0]["status"], 200)
|
||||
|
||||
def test_run_http_server_fails_before_server_creation_without_token(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch.object(mcp_server, "create_server") as create_server:
|
||||
with self.assertRaises(RuntimeError):
|
||||
mcp_server.run_http_server(
|
||||
vault_path=Path(tmp),
|
||||
db_path=None,
|
||||
profile_name="bjd-general-bot",
|
||||
max_file_bytes=1024,
|
||||
expose_paths=False,
|
||||
host="127.0.0.1",
|
||||
port=8765,
|
||||
token_env="TEST_MCP_TOKEN",
|
||||
streamable_http_path="/mcp",
|
||||
allowed_hosts=None,
|
||||
allowed_origins=None,
|
||||
)
|
||||
create_server.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,178 @@
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import call, patch
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import manage_mcp_service
|
||||
|
||||
|
||||
class McpServiceConfigTests(unittest.TestCase):
|
||||
def test_render_service_unit_uses_http_transport_without_embedding_token(self):
|
||||
unit = manage_mcp_service.render_service_unit(
|
||||
project_root=Path("/opt/obsidian-wiki"),
|
||||
env_file=Path("/opt/obsidian-wiki/.runtime/obsidian-wiki-mcp.env"),
|
||||
uv_path=Path("/home/nac/.local/bin/uv"),
|
||||
)
|
||||
|
||||
self.assertIn("WorkingDirectory=/opt/obsidian-wiki", unit)
|
||||
self.assertIn("EnvironmentFile=/opt/obsidian-wiki/.runtime/obsidian-wiki-mcp.env", unit)
|
||||
self.assertIn("/home/nac/.local/bin/uv run --no-sync obsidian-wiki-mcp", unit)
|
||||
self.assertIn("--transport streamable-http", unit)
|
||||
self.assertIn("--host 0.0.0.0", unit)
|
||||
self.assertIn("--port 8765", unit)
|
||||
self.assertNotIn("OBSIDIAN_MCP_TOKEN=", unit)
|
||||
|
||||
def test_write_token_env_file_is_private(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = Path(tmp) / ".runtime" / "obsidian-wiki-mcp.env"
|
||||
|
||||
manage_mcp_service.write_token_env_file(env_path, "test-token", host="0.0.0.0", port=8765, path="/mcp")
|
||||
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_TOKEN=test-token\n", content)
|
||||
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.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_render_remote_config_prints_client_configuration(self):
|
||||
output = manage_mcp_service.render_remote_config(
|
||||
hosts=["192.168.1.10"],
|
||||
token="test-token",
|
||||
port=8765,
|
||||
path="/mcp",
|
||||
)
|
||||
|
||||
self.assertIn("http://192.168.1.10:8765/mcp", output)
|
||||
self.assertIn("Authorization", output)
|
||||
self.assertIn("Bearer test-token", output)
|
||||
self.assertIn('"type": "streamable-http"', output)
|
||||
self.assertIn('"obsidian-wiki"', output)
|
||||
|
||||
def test_new_token_writes_token_without_touching_systemd_when_unit_missing(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="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)
|
||||
|
||||
env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env"
|
||||
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("http://10.0.0.2:8765/mcp", output)
|
||||
run_systemctl.assert_not_called()
|
||||
|
||||
def test_new_token_restarts_service_when_unit_exists(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
paths = manage_mcp_service.resolve_service_paths(project_root, config_home=config_home)
|
||||
paths.unit_path.parent.mkdir(parents=True)
|
||||
paths.unit_path.write_text("unit\n", encoding="utf-8")
|
||||
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)
|
||||
|
||||
env_content = paths.env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_TOKEN=new-token\n", env_content)
|
||||
self.assertIn("http://10.0.0.2:8765/mcp", output)
|
||||
run_systemctl.assert_called_once_with(["restart", manage_mcp_service.SERVICE_NAME])
|
||||
|
||||
def test_start_service_uses_saved_token_and_restarts_service(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="0.0.0.0",
|
||||
port=8765,
|
||||
path="/mcp",
|
||||
)
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "generate_token") as generate_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, "run_systemctl") as run_systemctl,
|
||||
):
|
||||
output = manage_mcp_service.start_service(project_root=project_root)
|
||||
|
||||
unit_path = config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME
|
||||
unit_content = unit_path.read_text(encoding="utf-8")
|
||||
self.assertIn("/home/nac/.local/bin/uv run --no-sync obsidian-wiki-mcp", unit_content)
|
||||
self.assertIn("EnvironmentFile=", unit_content)
|
||||
self.assertIn("http://10.0.0.2:8765/mcp", output)
|
||||
self.assertIn("Bearer saved-token", output)
|
||||
generate_token.assert_not_called()
|
||||
run_systemctl.assert_has_calls(
|
||||
[
|
||||
call(["daemon-reload"]),
|
||||
call(["enable", manage_mcp_service.SERVICE_NAME]),
|
||||
call(["restart", manage_mcp_service.SERVICE_NAME]),
|
||||
]
|
||||
)
|
||||
|
||||
def test_uninstall_service_removes_only_managed_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
config_home = Path(tmp) / "config"
|
||||
paths = manage_mcp_service.resolve_service_paths(project_root, config_home=config_home)
|
||||
paths.unit_path.parent.mkdir(parents=True)
|
||||
paths.env_path.parent.mkdir(parents=True)
|
||||
paths.unit_path.write_text("unit\n", encoding="utf-8")
|
||||
paths.env_path.write_text("OBSIDIAN_MCP_TOKEN=old\n", encoding="utf-8")
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
|
||||
):
|
||||
manage_mcp_service.uninstall_service(project_root)
|
||||
|
||||
self.assertFalse(paths.unit_path.exists())
|
||||
self.assertFalse(paths.env_path.exists())
|
||||
run_systemctl.assert_has_calls(
|
||||
[
|
||||
call(["disable", "--now", manage_mcp_service.SERVICE_NAME], check=False),
|
||||
call(["daemon-reload"], check=False),
|
||||
call(["reset-failed", manage_mcp_service.SERVICE_NAME], check=False),
|
||||
]
|
||||
)
|
||||
|
||||
def test_show_config_reads_saved_token_and_prints_remote_config(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="0.0.0.0", port=9876, path="/wiki-mcp")
|
||||
|
||||
with patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.3"]):
|
||||
output = manage_mcp_service.show_config(project_root)
|
||||
|
||||
self.assertIn("http://10.0.0.3:9876/wiki-mcp", output)
|
||||
self.assertIn("Authorization: Bearer saved-token", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user