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:
@@ -37,6 +37,17 @@ OBSIDIAN_MCP_BOT_PROFILE=bjd-general-bot
|
||||
OBSIDIAN_MCP_MAX_FILE_BYTES=26214400
|
||||
OBSIDIAN_MCP_EXPOSE_PATHS=false
|
||||
|
||||
# Optional remote Streamable HTTP MCP mode.
|
||||
# Run locally:
|
||||
# OBSIDIAN_MCP_TOKEN='<long-random-token>' obsidian-wiki-mcp --transport streamable-http
|
||||
# Or manage a user-level service:
|
||||
# bash scripts/new_mcp_service_token.sh
|
||||
# bash scripts/start_mcp_service.sh
|
||||
OBSIDIAN_MCP_TOKEN=
|
||||
OBSIDIAN_MCP_HOST=127.0.0.1
|
||||
OBSIDIAN_MCP_PORT=8765
|
||||
OBSIDIAN_MCP_PATH=/mcp
|
||||
|
||||
# Wiki categories (directories created in the vault)
|
||||
OBSIDIAN_CATEGORIES=concepts,entities,skills,references,synthesis,journal
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.runtime/
|
||||
|
||||
# Python build artifacts (pyproject.toml / `python -m build`)
|
||||
/dist/
|
||||
|
||||
@@ -332,6 +332,53 @@ Configure your agent to run `obsidian-wiki-mcp` as a stdio MCP server. The serve
|
||||
|
||||
By default the MCP layer is stricter than local CLI lookup: it only serves engineering files such as drawings, 3D/STEP models, specifications, product parameters, and test reports. It blocks raw orders, prices, email correspondence, complaint evidence, claims, personal data, and files missing from `_meta/source-registry.jsonl`. Use `OBSIDIAN_MCP_BOT_PROFILE`, `OBSIDIAN_MCP_MAX_FILE_BYTES`, and `OBSIDIAN_MCP_EXPOSE_PATHS` to tune the runtime behavior.
|
||||
|
||||
Optional: run the same MCP server over Streamable HTTP for a remote MCP client:
|
||||
|
||||
```bash
|
||||
export OBSIDIAN_MCP_TOKEN='<long-random-token>'
|
||||
obsidian-wiki-mcp --transport streamable-http --host 127.0.0.1 --port 8765 --path /mcp
|
||||
```
|
||||
|
||||
Remote HTTP requests must include:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <long-random-token>
|
||||
```
|
||||
|
||||
Client configuration example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"obsidian-wiki": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://127.0.0.1:8765/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <long-random-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For long-running access from another machine, use the managed user service:
|
||||
|
||||
```bash
|
||||
bash scripts/new_mcp_service_token.sh
|
||||
bash scripts/start_mcp_service.sh
|
||||
```
|
||||
|
||||
`new_mcp_service_token.sh` writes `.runtime/obsidian-wiki-mcp.env` with `0600` permissions and prints the remote URL, `Authorization` header, and client JSON. `start_mcp_service.sh` writes `~/.config/systemd/user/obsidian-wiki-mcp.service` and starts it. 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.
|
||||
|
||||
Useful service commands:
|
||||
|
||||
```bash
|
||||
bash scripts/show_mcp_service_config.sh
|
||||
bash scripts/stop_mcp_service.sh
|
||||
bash scripts/uninstall_mcp_service.sh
|
||||
python3 scripts/manage_mcp_service.py status
|
||||
```
|
||||
|
||||
### `_raw/` Staging Directory
|
||||
|
||||
`_raw/` is a staging area inside your vault for unprocessed captures — rough notes, clipboard pastes, quick voice-memo transcripts. Drop files there and the next `wiki-ingest` run will promote them to proper wiki pages and remove the originals.
|
||||
|
||||
+22
-1
@@ -494,6 +494,20 @@ def cmd_mcp_server(args: argparse.Namespace) -> int:
|
||||
argv.extend(["--max-file-bytes", str(args.max_file_bytes)])
|
||||
if args.expose_paths:
|
||||
argv.append("--expose-paths")
|
||||
if args.transport:
|
||||
argv.extend(["--transport", args.transport])
|
||||
if args.host:
|
||||
argv.extend(["--host", args.host])
|
||||
if args.port is not None:
|
||||
argv.extend(["--port", str(args.port)])
|
||||
if args.path:
|
||||
argv.extend(["--path", args.path])
|
||||
if args.token_env:
|
||||
argv.extend(["--token-env", args.token_env])
|
||||
for allowed_host in args.allowed_hosts or []:
|
||||
argv.extend(["--allowed-host", allowed_host])
|
||||
for allowed_origin in args.allowed_origins or []:
|
||||
argv.extend(["--allowed-origin", allowed_origin])
|
||||
return mcp_main(argv)
|
||||
|
||||
|
||||
@@ -588,11 +602,18 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
ftp.add_argument("--visibility", default="internal", help="initial visibility")
|
||||
ftp.set_defaults(func=cmd_file_index_stage)
|
||||
|
||||
mp = sub.add_parser("mcp-server", help="run the stdio MCP server for safe source-file lookup")
|
||||
mp = sub.add_parser("mcp-server", help="run the MCP server for safe source-file lookup")
|
||||
_add_file_index_storage_args(mp)
|
||||
mp.add_argument("--profile", help="bot profile name for ACL checks")
|
||||
mp.add_argument("--max-file-bytes", type=int, help="maximum resource size, 0 disables the limit")
|
||||
mp.add_argument("--expose-paths", action="store_true", help="include archive/absolute paths in tool output")
|
||||
mp.add_argument("--transport", choices=["stdio", "streamable-http"], help="MCP transport")
|
||||
mp.add_argument("--host", help="HTTP bind host. Used only with --transport streamable-http")
|
||||
mp.add_argument("--port", type=int, help="HTTP port. Used only with --transport streamable-http")
|
||||
mp.add_argument("--path", help="Streamable HTTP MCP path")
|
||||
mp.add_argument("--token-env", help="Environment variable containing the HTTP bearer token")
|
||||
mp.add_argument("--allowed-host", action="append", dest="allowed_hosts", help="allowed HTTP Host pattern")
|
||||
mp.add_argument("--allowed-origin", action="append", dest="allowed_origins", help="allowed Origin pattern")
|
||||
mp.set_defaults(func=cmd_mcp_server)
|
||||
|
||||
bp = sub.add_parser("bot-view", help="derive isolated chatbot views from the mother vault")
|
||||
|
||||
+180
-8
@@ -1,13 +1,15 @@
|
||||
"""stdio MCP server exposing safe wiki source-file resources."""
|
||||
"""MCP server exposing safe wiki source-file resources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hmac
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from obsidian_wiki.config import resolve_vault_path
|
||||
from obsidian_wiki.config import config_value, resolve_vault_path
|
||||
from obsidian_wiki.file_assets import clean_config_value, db_path_for_vault, registry_path_for_vault
|
||||
from obsidian_wiki.mcp_assets import (
|
||||
DEFAULT_MAX_FILE_BYTES,
|
||||
@@ -22,6 +24,48 @@ from obsidian_wiki.mcp_assets import (
|
||||
search_file_assets as core_search_file_assets,
|
||||
)
|
||||
|
||||
DEFAULT_HTTP_HOST = "127.0.0.1"
|
||||
DEFAULT_HTTP_PORT = 8765
|
||||
DEFAULT_HTTP_PATH = "/mcp"
|
||||
DEFAULT_TOKEN_ENV = "OBSIDIAN_MCP_TOKEN"
|
||||
|
||||
|
||||
def bearer_token(authorization: str) -> str:
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() != "bearer" or not token:
|
||||
return ""
|
||||
return token.strip()
|
||||
|
||||
|
||||
class BearerTokenAuthMiddleware:
|
||||
def __init__(self, app: Any, token: str) -> None:
|
||||
if not token:
|
||||
raise ValueError("HTTP MCP token must not be empty")
|
||||
self.app = app
|
||||
self.token = token
|
||||
|
||||
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||
if scope.get("type") != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
headers = {key.lower(): value for key, value in scope.get("headers", [])}
|
||||
authorization = headers.get(b"authorization", b"").decode("latin1")
|
||||
token = bearer_token(authorization)
|
||||
if not hmac.compare_digest(token, self.token):
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 401,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"www-authenticate", b"Bearer"),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": b'{"error":"unauthorized"}'})
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
def create_server(
|
||||
*,
|
||||
@@ -30,17 +74,38 @@ def create_server(
|
||||
profile_name: str = DEFAULT_PROFILE_NAME,
|
||||
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
|
||||
expose_paths: bool = False,
|
||||
host: str = DEFAULT_HTTP_HOST,
|
||||
port: int = DEFAULT_HTTP_PORT,
|
||||
streamable_http_path: str = DEFAULT_HTTP_PATH,
|
||||
allowed_hosts: list[str] | None = None,
|
||||
allowed_origins: list[str] | None = None,
|
||||
):
|
||||
if sys.version_info < (3, 10):
|
||||
raise RuntimeError("obsidian-wiki MCP server requires Python 3.10 or newer")
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
try:
|
||||
from mcp.server.fastmcp.server import TransportSecuritySettings
|
||||
except ImportError:
|
||||
TransportSecuritySettings = None
|
||||
except ImportError as exc:
|
||||
raise RuntimeError('MCP support is not installed. Run: pip install "obsidian-wiki[mcp]"') from exc
|
||||
|
||||
vault = Path(clean_config_value(str(vault_path))).expanduser()
|
||||
db = _resolve_db_path(vault, db_path)
|
||||
server = FastMCP("obsidian-wiki")
|
||||
transport_security = None
|
||||
if TransportSecuritySettings is not None and (allowed_hosts is not None or allowed_origins is not None):
|
||||
transport_security = TransportSecuritySettings(
|
||||
allowed_hosts=allowed_hosts or [],
|
||||
allowed_origins=allowed_origins or [],
|
||||
)
|
||||
server = FastMCP(
|
||||
"obsidian-wiki",
|
||||
host=host,
|
||||
port=port,
|
||||
streamable_http_path=streamable_http_path,
|
||||
transport_security=transport_security,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def file_index_status() -> dict[str, Any]:
|
||||
@@ -113,6 +178,69 @@ def create_server(
|
||||
return server
|
||||
|
||||
|
||||
def read_token_from_env(env_name: str = DEFAULT_TOKEN_ENV) -> str:
|
||||
token = os.environ.get(env_name, "").strip()
|
||||
if not token:
|
||||
raise RuntimeError(f"{env_name} must be set for HTTP MCP transport")
|
||||
return token
|
||||
|
||||
|
||||
def runtime_config_value(key: str, default: str) -> str:
|
||||
raw = os.environ.get(key)
|
||||
if raw is None:
|
||||
raw = config_value(key, default)
|
||||
value = clean_config_value(raw).strip()
|
||||
return value or default
|
||||
|
||||
|
||||
def runtime_config_int(key: str, default: int) -> int:
|
||||
raw = runtime_config_value(key, str(default))
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def create_http_app(server: Any, token: str) -> Any:
|
||||
return BearerTokenAuthMiddleware(server.streamable_http_app(), token)
|
||||
|
||||
|
||||
def run_http_server(
|
||||
*,
|
||||
vault_path: str | Path,
|
||||
db_path: str | Path | None,
|
||||
profile_name: str,
|
||||
max_file_bytes: int,
|
||||
expose_paths: bool,
|
||||
host: str,
|
||||
port: int,
|
||||
token_env: str,
|
||||
streamable_http_path: str,
|
||||
allowed_hosts: list[str] | None,
|
||||
allowed_origins: list[str] | None,
|
||||
) -> None:
|
||||
token = read_token_from_env(token_env)
|
||||
try:
|
||||
import uvicorn
|
||||
except ImportError as exc:
|
||||
raise RuntimeError('HTTP MCP support requires uvicorn. Run: pip install "obsidian-wiki[mcp]"') from exc
|
||||
|
||||
server = create_server(
|
||||
vault_path=vault_path,
|
||||
db_path=db_path,
|
||||
profile_name=profile_name,
|
||||
max_file_bytes=max_file_bytes,
|
||||
expose_paths=expose_paths,
|
||||
host=host,
|
||||
port=port,
|
||||
streamable_http_path=streamable_http_path,
|
||||
allowed_hosts=allowed_hosts,
|
||||
allowed_origins=allowed_origins,
|
||||
)
|
||||
app = create_http_app(server, token)
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="obsidian-wiki-mcp", description="Run the obsidian-wiki MCP server")
|
||||
parser.add_argument("--vault", metavar="PATH", help="absolute path to the Obsidian vault")
|
||||
@@ -120,6 +248,28 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser.add_argument("--profile", default=None, help=f"bot profile name, default: {DEFAULT_PROFILE_NAME}")
|
||||
parser.add_argument("--max-file-bytes", type=int, default=None, help="maximum resource size, 0 disables the limit")
|
||||
parser.add_argument("--expose-paths", action="store_true", help="include archive/absolute paths in tool output")
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
choices=["stdio", "streamable-http"],
|
||||
default=None,
|
||||
help="MCP transport. HTTP mode requires a bearer token.",
|
||||
)
|
||||
parser.add_argument("--host", default=None, help="HTTP bind host. Used only with --transport streamable-http.")
|
||||
parser.add_argument("--port", default=None, type=int, help="HTTP port. Used only with --transport streamable-http.")
|
||||
parser.add_argument("--path", default=None, help="Streamable HTTP MCP path.")
|
||||
parser.add_argument("--token-env", default=None, help="Environment variable containing the HTTP bearer token.")
|
||||
parser.add_argument(
|
||||
"--allowed-host",
|
||||
action="append",
|
||||
dest="allowed_hosts",
|
||||
help="Allowed HTTP Host pattern for DNS rebinding protection. Can be repeated.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allowed-origin",
|
||||
action="append",
|
||||
dest="allowed_origins",
|
||||
help="Allowed Origin pattern for DNS rebinding protection. Can be repeated.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
vault = resolve_vault_path(args.vault)
|
||||
@@ -131,18 +281,40 @@ def main(argv: list[str] | None = None) -> int:
|
||||
max_file_bytes=args.max_file_bytes,
|
||||
expose_paths=args.expose_paths,
|
||||
)
|
||||
transport = args.transport or runtime_config_value("OBSIDIAN_MCP_TRANSPORT", "stdio")
|
||||
host = args.host or runtime_config_value("OBSIDIAN_MCP_HOST", DEFAULT_HTTP_HOST)
|
||||
port = args.port if args.port is not None else runtime_config_int("OBSIDIAN_MCP_PORT", DEFAULT_HTTP_PORT)
|
||||
http_path = args.path or runtime_config_value("OBSIDIAN_MCP_PATH", DEFAULT_HTTP_PATH)
|
||||
token_env = args.token_env or runtime_config_value("OBSIDIAN_MCP_TOKEN_ENV", DEFAULT_TOKEN_ENV)
|
||||
try:
|
||||
server = create_server(
|
||||
if transport == "stdio":
|
||||
server = create_server(
|
||||
vault_path=vault,
|
||||
db_path=args.db,
|
||||
profile_name=options["profile_name"],
|
||||
max_file_bytes=options["max_file_bytes"],
|
||||
expose_paths=options["expose_paths"],
|
||||
)
|
||||
try:
|
||||
server.run(transport="stdio")
|
||||
except TypeError:
|
||||
server.run()
|
||||
return 0
|
||||
if transport != "streamable-http":
|
||||
raise RuntimeError(f"unsupported MCP transport: {transport}")
|
||||
run_http_server(
|
||||
vault_path=vault,
|
||||
db_path=args.db,
|
||||
profile_name=options["profile_name"],
|
||||
max_file_bytes=options["max_file_bytes"],
|
||||
expose_paths=options["expose_paths"],
|
||||
host=host,
|
||||
port=port,
|
||||
token_env=token_env,
|
||||
streamable_http_path=http_path,
|
||||
allowed_hosts=args.allowed_hosts,
|
||||
allowed_origins=args.allowed_origins,
|
||||
)
|
||||
try:
|
||||
server.run(transport="stdio")
|
||||
except TypeError:
|
||||
server.run()
|
||||
except (RuntimeError, FileNotFoundError, AssetAccessError, AssetNotFoundError, AssetTooLargeError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ classifiers = [
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
mcp = ["mcp>=1.28,<2"]
|
||||
mcp = ["mcp[cli]>=1.28,<2"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/Ar9av/obsidian-wiki"
|
||||
|
||||
Executable
+358
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Manage the user-level HTTP MCP service for obsidian-wiki source files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import secrets
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
SERVICE_NAME = "obsidian-wiki-mcp.service"
|
||||
ENV_RELATIVE_PATH = Path(".runtime") / "obsidian-wiki-mcp.env"
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 8765
|
||||
DEFAULT_PATH = "/mcp"
|
||||
DEFAULT_TOKEN_BYTES = 32
|
||||
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServicePaths:
|
||||
project_root: Path
|
||||
unit_path: Path
|
||||
env_path: Path
|
||||
|
||||
|
||||
def generate_token(num_bytes: int = DEFAULT_TOKEN_BYTES) -> str:
|
||||
return secrets.token_urlsafe(num_bytes)
|
||||
|
||||
|
||||
def systemd_quote(value: str | Path) -> str:
|
||||
return shlex.quote(str(value))
|
||||
|
||||
|
||||
def find_uv_path() -> Path:
|
||||
configured = os.environ.get("UV_PATH", "").strip()
|
||||
discovered = configured or shutil.which("uv")
|
||||
if not discovered:
|
||||
raise RuntimeError("Could not find uv on PATH. Install uv or set UV_PATH to its absolute path.")
|
||||
return Path(discovered).expanduser().resolve()
|
||||
|
||||
|
||||
def resolve_service_paths(project_root: Path, config_home: Path | None = None) -> ServicePaths:
|
||||
root = project_root.expanduser().resolve()
|
||||
base_config = config_home or Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
unit_path = base_config.expanduser() / "systemd" / "user" / SERVICE_NAME
|
||||
return ServicePaths(project_root=root, unit_path=unit_path, env_path=root / ENV_RELATIVE_PATH)
|
||||
|
||||
|
||||
def render_service_unit(
|
||||
*,
|
||||
project_root: Path,
|
||||
env_file: Path,
|
||||
host: str = DEFAULT_HOST,
|
||||
port: int = DEFAULT_PORT,
|
||||
path: str = DEFAULT_PATH,
|
||||
uv_cache_dir: str = DEFAULT_UV_CACHE_DIR,
|
||||
uv_path: Path | None = None,
|
||||
) -> str:
|
||||
resolved_uv_path = uv_path or find_uv_path()
|
||||
command = " ".join(
|
||||
[
|
||||
systemd_quote(resolved_uv_path),
|
||||
"run",
|
||||
"--no-sync",
|
||||
"obsidian-wiki-mcp",
|
||||
"--vault",
|
||||
systemd_quote(project_root),
|
||||
"--transport",
|
||||
"streamable-http",
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
str(port),
|
||||
"--path",
|
||||
path,
|
||||
]
|
||||
)
|
||||
return (
|
||||
"[Unit]\n"
|
||||
"Description=obsidian-wiki readonly HTTP MCP server\n"
|
||||
"After=network-online.target\n"
|
||||
"Wants=network-online.target\n"
|
||||
"\n"
|
||||
"[Service]\n"
|
||||
"Type=simple\n"
|
||||
f"WorkingDirectory={systemd_quote(project_root)}\n"
|
||||
f"EnvironmentFile={systemd_quote(env_file)}\n"
|
||||
f"Environment=UV_CACHE_DIR={systemd_quote(uv_cache_dir)}\n"
|
||||
f"ExecStart={command}\n"
|
||||
"Restart=on-failure\n"
|
||||
"RestartSec=5\n"
|
||||
"\n"
|
||||
"[Install]\n"
|
||||
"WantedBy=default.target\n"
|
||||
)
|
||||
|
||||
|
||||
def write_token_env_file(
|
||||
env_path: Path,
|
||||
token: str,
|
||||
*,
|
||||
host: str = DEFAULT_HOST,
|
||||
port: int = DEFAULT_PORT,
|
||||
path: str = DEFAULT_PATH,
|
||||
) -> None:
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
env_path.parent.chmod(0o700)
|
||||
fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as env_file:
|
||||
env_file.write(f"OBSIDIAN_MCP_TOKEN={token}\n")
|
||||
env_file.write(f"OBSIDIAN_MCP_HOST={host}\n")
|
||||
env_file.write(f"OBSIDIAN_MCP_PORT={port}\n")
|
||||
env_file.write(f"OBSIDIAN_MCP_PATH={path}\n")
|
||||
env_path.chmod(0o600)
|
||||
|
||||
|
||||
def read_service_env_file(env_path: Path) -> dict[str, str]:
|
||||
if not env_path.exists():
|
||||
raise RuntimeError(f"Missing MCP service env file: {env_path}. Run scripts/new_mcp_service_token.sh first.")
|
||||
|
||||
values: dict[str, str] = {}
|
||||
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
key, separator, value = line.partition("=")
|
||||
if separator:
|
||||
values[key.strip()] = value.strip()
|
||||
return values
|
||||
|
||||
|
||||
def saved_service_config(project_root: Path) -> tuple[str, str, int, str]:
|
||||
paths = resolve_service_paths(project_root)
|
||||
values = read_service_env_file(paths.env_path)
|
||||
token = values.get("OBSIDIAN_MCP_TOKEN", "").strip()
|
||||
if not token:
|
||||
raise RuntimeError(f"Missing OBSIDIAN_MCP_TOKEN in {paths.env_path}")
|
||||
host = values.get("OBSIDIAN_MCP_HOST") or DEFAULT_HOST
|
||||
port = int(values.get("OBSIDIAN_MCP_PORT") or DEFAULT_PORT)
|
||||
path = values.get("OBSIDIAN_MCP_PATH") or DEFAULT_PATH
|
||||
return token, host, port, path
|
||||
|
||||
|
||||
def write_service_unit(
|
||||
paths: ServicePaths,
|
||||
*,
|
||||
host: str = DEFAULT_HOST,
|
||||
port: int = DEFAULT_PORT,
|
||||
path: str = DEFAULT_PATH,
|
||||
) -> None:
|
||||
paths.unit_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
unit = render_service_unit(
|
||||
project_root=paths.project_root,
|
||||
env_file=paths.env_path,
|
||||
host=host,
|
||||
port=port,
|
||||
path=path,
|
||||
)
|
||||
paths.unit_path.write_text(unit, encoding="utf-8")
|
||||
|
||||
|
||||
def detect_remote_hosts() -> list[str]:
|
||||
hosts: list[str] = []
|
||||
try:
|
||||
result = subprocess.run(["hostname", "-I"], capture_output=True, text=True, check=False)
|
||||
except OSError:
|
||||
result = None
|
||||
if result and result.returncode == 0:
|
||||
hosts.extend(result.stdout.split())
|
||||
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None):
|
||||
hosts.append(info[4][0])
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
cleaned: list[str] = []
|
||||
for host in hosts:
|
||||
normalized = host.strip()
|
||||
if not normalized or normalized.startswith("127.") or normalized == "::1" or normalized.startswith("fe80:"):
|
||||
continue
|
||||
if normalized not in cleaned:
|
||||
cleaned.append(normalized)
|
||||
return cleaned
|
||||
|
||||
|
||||
def render_remote_config(
|
||||
*,
|
||||
hosts: Sequence[str],
|
||||
token: str,
|
||||
port: int = DEFAULT_PORT,
|
||||
path: str = DEFAULT_PATH,
|
||||
) -> str:
|
||||
host = hosts[0] if hosts else "<server-ip>"
|
||||
url = f"http://{host}:{port}{path}"
|
||||
alternatives = "\n".join(f"- http://{candidate}:{port}{path}" for candidate in hosts[1:])
|
||||
alternatives_section = f"\nOther detected addresses:\n{alternatives}\n" if alternatives else ""
|
||||
return (
|
||||
"MCP service is running.\n"
|
||||
"\n"
|
||||
"Use this URL from another machine:\n"
|
||||
f"{url}\n"
|
||||
f"{alternatives_section}"
|
||||
"\n"
|
||||
"Use this header:\n"
|
||||
f"Authorization: Bearer {token}\n"
|
||||
"\n"
|
||||
"Example MCP client config:\n"
|
||||
"{\n"
|
||||
' "mcpServers": {\n'
|
||||
' "obsidian-wiki": {\n'
|
||||
' "type": "streamable-http",\n'
|
||||
f' "url": "{url}",\n'
|
||||
' "headers": {\n'
|
||||
f' "Authorization": "Bearer {token}"\n'
|
||||
" }\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
|
||||
|
||||
def show_config(project_root: Path) -> str:
|
||||
token, _host, port, path = saved_service_config(project_root)
|
||||
return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path)
|
||||
|
||||
|
||||
def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(["systemctl", "--user", *args], text=True, check=check)
|
||||
|
||||
|
||||
def restart_service_if_installed(paths: ServicePaths) -> bool:
|
||||
if not paths.unit_path.exists():
|
||||
return False
|
||||
run_systemctl(["restart", SERVICE_NAME])
|
||||
return True
|
||||
|
||||
|
||||
def new_token(
|
||||
*,
|
||||
project_root: Path,
|
||||
host: str = DEFAULT_HOST,
|
||||
port: int = DEFAULT_PORT,
|
||||
path: str = DEFAULT_PATH,
|
||||
token_bytes: int = DEFAULT_TOKEN_BYTES,
|
||||
) -> str:
|
||||
paths = resolve_service_paths(project_root)
|
||||
token = generate_token(token_bytes)
|
||||
write_token_env_file(paths.env_path, token, host=host, port=port, path=path)
|
||||
restart_service_if_installed(paths)
|
||||
return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path)
|
||||
|
||||
|
||||
def start_service(
|
||||
*,
|
||||
project_root: Path,
|
||||
) -> str:
|
||||
paths = resolve_service_paths(project_root)
|
||||
token, host, port, path = saved_service_config(project_root)
|
||||
write_service_unit(paths, host=host, port=port, path=path)
|
||||
|
||||
run_systemctl(["daemon-reload"])
|
||||
run_systemctl(["enable", SERVICE_NAME])
|
||||
run_systemctl(["restart", SERVICE_NAME])
|
||||
return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path)
|
||||
|
||||
|
||||
def stop_service() -> None:
|
||||
run_systemctl(["stop", SERVICE_NAME])
|
||||
|
||||
|
||||
def uninstall_service(project_root: Path) -> None:
|
||||
paths = resolve_service_paths(project_root)
|
||||
run_systemctl(["disable", "--now", SERVICE_NAME], check=False)
|
||||
if paths.unit_path.exists():
|
||||
paths.unit_path.unlink()
|
||||
if paths.env_path.exists():
|
||||
paths.env_path.unlink()
|
||||
try:
|
||||
paths.env_path.parent.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
run_systemctl(["daemon-reload"], check=False)
|
||||
run_systemctl(["reset-failed", SERVICE_NAME], check=False)
|
||||
|
||||
|
||||
def service_status() -> None:
|
||||
run_systemctl(["status", SERVICE_NAME], check=False)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Manage the readonly HTTP obsidian-wiki MCP user service.")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
new_token_parser = subparsers.add_parser("new-token", help="Generate a token and print remote MCP client configuration.")
|
||||
new_token_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
||||
new_token_parser.add_argument("--host", default=DEFAULT_HOST)
|
||||
new_token_parser.add_argument("--port", default=DEFAULT_PORT, type=int)
|
||||
new_token_parser.add_argument("--path", default=DEFAULT_PATH)
|
||||
new_token_parser.add_argument("--token-bytes", default=DEFAULT_TOKEN_BYTES, type=int)
|
||||
|
||||
start_parser = subparsers.add_parser("start", help="Install or update the user service using the saved token.")
|
||||
start_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
||||
|
||||
subparsers.add_parser("stop", help="Stop the user service without deleting service files.")
|
||||
|
||||
uninstall_parser = subparsers.add_parser("uninstall", help="Stop and remove the managed user service and token file.")
|
||||
uninstall_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
||||
|
||||
show_config_parser = subparsers.add_parser("show-config", help="Print the saved token and remote MCP client configuration.")
|
||||
show_config_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
||||
|
||||
subparsers.add_parser("status", help="Show systemd status for the managed service.")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "new-token":
|
||||
print(
|
||||
new_token(
|
||||
project_root=args.project_root,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
path=args.path,
|
||||
token_bytes=args.token_bytes,
|
||||
),
|
||||
end="",
|
||||
)
|
||||
return 0
|
||||
if args.command == "start":
|
||||
print(start_service(project_root=args.project_root), end="")
|
||||
return 0
|
||||
if args.command == "stop":
|
||||
stop_service()
|
||||
print(f"Stopped {SERVICE_NAME}.")
|
||||
return 0
|
||||
if args.command == "uninstall":
|
||||
uninstall_service(args.project_root)
|
||||
print(f"Uninstalled {SERVICE_NAME}.")
|
||||
return 0
|
||||
if args.command == "show-config":
|
||||
print(show_config(args.project_root), end="")
|
||||
return 0
|
||||
if args.command == "status":
|
||||
service_status()
|
||||
return 0
|
||||
raise AssertionError(f"unhandled command: {args.command}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/manage_mcp_service.py" new-token "$@"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/manage_mcp_service.py" show-config "$@"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/manage_mcp_service.py" start "$@"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/manage_mcp_service.py" stop "$@"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/manage_mcp_service.py" uninstall "$@"
|
||||
@@ -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