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:
Codex
2026-07-05 11:30:42 +00:00
parent 76a480a0b7
commit 31b0e8dba2
14 changed files with 945 additions and 10 deletions
+11
View File
@@ -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
+1
View File
@@ -6,6 +6,7 @@ __pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.runtime/
# Python build artifacts (pyproject.toml / `python -m build`)
/dist/
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+358
View File
@@ -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())
+5
View File
@@ -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 "$@"
+5
View File
@@ -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 "$@"
+5
View File
@@ -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 "$@"
+5
View File
@@ -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 "$@"
+5
View File
@@ -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 "$@"
+122
View File
@@ -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()
+178
View File
@@ -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()