From acff3c14db5e5ed32c260353a4b4e2a04c051499 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 5 Jul 2026 12:05:12 +0000 Subject: [PATCH] fix: make remote MCP service start reliably Use the Python module entrypoint in the user service, prepare the MCP optional runtime before restart, and serve the configured vault path instead of the repo root. Verify binary asset resources use an octet-stream MIME type and cover service vault resolution in tests. --- README.md | 4 +-- obsidian_wiki/mcp_server.py | 2 +- pyproject.toml | 2 +- scripts/manage_mcp_service.py | 60 +++++++++++++++++++++++++++++++++-- tests/test_mcp_service.py | 31 ++++++++++++++++-- 5 files changed, 90 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 15474ae..4963c28 100644 --- a/README.md +++ b/README.md @@ -367,12 +367,12 @@ For long-running access from another machine, use the managed user service: bash scripts/mcp_service.sh start ``` -`mcp_service.sh start` creates `.runtime/obsidian-wiki-mcp.env` with `0600` permissions if it does not exist, writes `~/.config/systemd/user/obsidian-wiki-mcp.service`, starts the service, and prints the remote URL, `Authorization` header, and client JSON to copy into another agent. The service defaults to `0.0.0.0:8765/mcp` because it is intended for remote clients, but it still requires the bearer token. Prefer SSH tunnel, Tailscale, WireGuard, or another trusted network boundary instead of directly exposing the port to the public internet. +`mcp_service.sh start` creates `.runtime/obsidian-wiki-mcp.env` with `0600` permissions if it does not exist, writes `~/.config/systemd/user/obsidian-wiki-mcp.service`, starts the service, and prints the remote URL, `Authorization` header, and client JSON to copy into another agent. It serves the configured `OBSIDIAN_VAULT_PATH` by default; pass `--vault /path/to/vault` to override. The service defaults to `0.0.0.0:8765/mcp` because it is intended for remote clients, but it still requires the bearer token. Prefer SSH tunnel, Tailscale, WireGuard, or another trusted network boundary instead of directly exposing the port to the public internet. Customize the first startup if needed: ```bash -bash scripts/mcp_service.sh start --host 0.0.0.0 --port 9876 --path /wiki-mcp +bash scripts/mcp_service.sh start --vault /path/to/vault --host 0.0.0.0 --port 9876 --path /wiki-mcp ``` Rotate the token later with: diff --git a/obsidian_wiki/mcp_server.py b/obsidian_wiki/mcp_server.py index 262f649..149ed57 100644 --- a/obsidian_wiki/mcp_server.py +++ b/obsidian_wiki/mcp_server.py @@ -163,7 +163,7 @@ def create_server( ) ) - @server.resource("asset://source/sha256/{digest}") + @server.resource("asset://source/sha256/{digest}", mime_type="application/octet-stream") def read_file_asset(digest: str) -> bytes: """Read one allowed source file as a binary MCP resource.""" resource = read_file_asset_resource( diff --git a/pyproject.toml b/pyproject.toml index 1edef6e..0958a2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ classifiers = [ dependencies = [] [project.optional-dependencies] -mcp = ["mcp[cli]>=1.28,<2"] +mcp = ["mcp[cli]>=1.28,<2; python_version >= '3.10'"] [project.urls] Homepage = "https://github.com/Ar9av/obsidian-wiki" diff --git a/scripts/manage_mcp_service.py b/scripts/manage_mcp_service.py index a5cf949..e66db72 100755 --- a/scripts/manage_mcp_service.py +++ b/scripts/manage_mcp_service.py @@ -10,10 +10,17 @@ import shlex import shutil import socket import subprocess +import sys from dataclasses import dataclass from pathlib import Path from typing import Sequence +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from obsidian_wiki.config import resolve_vault_path + SERVICE_NAME = "obsidian-wiki-mcp.service" ENV_RELATIVE_PATH = Path(".runtime") / "obsidian-wiki-mcp.env" @@ -54,9 +61,19 @@ def resolve_service_paths(project_root: Path, config_home: Path | None = None) - return ServicePaths(project_root=root, unit_path=unit_path, env_path=root / ENV_RELATIVE_PATH) +def resolve_vault_for_service(project_root: Path, vault_path: Path | str | None = None) -> Path: + if vault_path is not None: + return Path(vault_path).expanduser().resolve() + configured = resolve_vault_path(None, start=project_root) + if configured: + return Path(configured).expanduser().resolve() + return project_root.expanduser().resolve() + + def render_service_unit( *, project_root: Path, + vault_path: Path, env_file: Path, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, @@ -70,9 +87,11 @@ def render_service_unit( systemd_quote(resolved_uv_path), "run", "--no-sync", - "obsidian-wiki-mcp", + "python", + "-m", + "obsidian_wiki.mcp_server", "--vault", - systemd_quote(project_root), + systemd_quote(vault_path), "--transport", "streamable-http", "--host", @@ -103,6 +122,31 @@ def render_service_unit( ) +def prepare_runtime( + project_root: Path, + *, + uv_path: Path | None = None, + uv_cache_dir: str = DEFAULT_UV_CACHE_DIR, +) -> None: + resolved_uv_path = uv_path or find_uv_path() + env = {**os.environ, "UV_CACHE_DIR": uv_cache_dir} + subprocess.run( + [ + str(resolved_uv_path), + "run", + "--extra", + "mcp", + "python", + "-c", + "import mcp.server.fastmcp", + ], + cwd=project_root, + env=env, + text=True, + check=True, + ) + + def write_token_env_file( env_path: Path, token: str, @@ -152,17 +196,21 @@ def saved_service_config(project_root: Path) -> tuple[str, str, int, str]: def write_service_unit( paths: ServicePaths, *, + vault_path: Path, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, path: str = DEFAULT_PATH, + uv_path: Path | None = None, ) -> None: paths.unit_path.parent.mkdir(parents=True, exist_ok=True) unit = render_service_unit( project_root=paths.project_root, + vault_path=vault_path, env_file=paths.env_path, host=host, port=port, path=path, + uv_path=uv_path, ) paths.unit_path.write_text(unit, encoding="utf-8") @@ -262,6 +310,7 @@ def new_token( def start_service( *, project_root: Path, + vault_path: Path | str | None = None, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, path: str = DEFAULT_PATH, @@ -275,7 +324,10 @@ def start_service( raise token = generate_token(token_bytes) write_token_env_file(paths.env_path, token, host=host, port=port, path=path) - write_service_unit(paths, host=host, port=port, path=path) + uv_path = find_uv_path() + prepare_runtime(paths.project_root, uv_path=uv_path) + resolved_vault = resolve_vault_for_service(paths.project_root, vault_path=vault_path) + write_service_unit(paths, vault_path=resolved_vault, host=host, port=port, path=path, uv_path=uv_path) run_systemctl(["daemon-reload"]) run_systemctl(["enable", SERVICE_NAME]) @@ -319,6 +371,7 @@ def main(argv: Sequence[str] | None = None) -> int: start_parser = subparsers.add_parser("start", help="Install or update the user service and print remote MCP config.") start_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path) + start_parser.add_argument("--vault", dest="vault_path", type=Path, help="Vault path to serve. Defaults to OBSIDIAN_VAULT_PATH config.") start_parser.add_argument("--host", default=DEFAULT_HOST) start_parser.add_argument("--port", default=DEFAULT_PORT, type=int) start_parser.add_argument("--path", default=DEFAULT_PATH) @@ -352,6 +405,7 @@ def main(argv: Sequence[str] | None = None) -> int: print( start_service( project_root=args.project_root, + vault_path=args.vault_path, host=args.host, port=args.port, path=args.path, diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index 18180b9..6d1dadc 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -35,13 +35,15 @@ 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"), + vault_path=Path("/data/bjd-vault"), 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("/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) @@ -61,6 +63,25 @@ class McpServiceConfigTests(unittest.TestCase): 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_resolve_vault_for_service_uses_explicit_or_configured_vault(self): + with tempfile.TemporaryDirectory() as tmp: + project_root = Path(tmp) / "project" + explicit_vault = Path(tmp) / "explicit-vault" + configured_vault = Path(tmp) / "configured-vault" + project_root.mkdir() + explicit_vault.mkdir() + configured_vault.mkdir() + + self.assertEqual( + manage_mcp_service.resolve_vault_for_service(project_root, vault_path=explicit_vault), + explicit_vault.resolve(), + ) + with patch.object(manage_mcp_service, "resolve_vault_path", return_value=str(configured_vault)): + self.assertEqual( + manage_mcp_service.resolve_vault_for_service(project_root), + configured_vault.resolve(), + ) + def test_render_remote_config_prints_client_configuration(self): output = manage_mcp_service.render_remote_config( hosts=["192.168.1.10"], @@ -133,17 +154,21 @@ class McpServiceConfigTests(unittest.TestCase): 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, "resolve_vault_for_service", return_value=Path("/data/bjd-vault")), + patch.object(manage_mcp_service, "prepare_runtime") as prepare_runtime, 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("/home/nac/.local/bin/uv run --no-sync python -m obsidian_wiki.mcp_server", unit_content) + self.assertIn("--vault /data/bjd-vault", 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() + prepare_runtime.assert_called_once_with(project_root, uv_path=Path("/home/nac/.local/bin/uv")) run_systemctl.assert_has_calls( [ call(["daemon-reload"]), @@ -162,6 +187,7 @@ class McpServiceConfigTests(unittest.TestCase): patch.object(manage_mcp_service, "generate_token", return_value="created-token"), patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.4"]), patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")), + patch.object(manage_mcp_service, "prepare_runtime") as prepare_runtime, patch.object(manage_mcp_service, "run_systemctl") as run_systemctl, ): output = manage_mcp_service.start_service(project_root=project_root, host="0.0.0.0", port=9876, path="/wiki-mcp") @@ -174,6 +200,7 @@ class McpServiceConfigTests(unittest.TestCase): self.assertIn("http://10.0.0.4:9876/wiki-mcp", output) self.assertIn("Authorization: Bearer created-token", output) self.assertIn('"type": "streamable-http"', output) + prepare_runtime.assert_called_once_with(project_root, uv_path=Path("/home/nac/.local/bin/uv")) run_systemctl.assert_has_calls( [ call(["daemon-reload"]),