feat(mcp): configure enabled tool groups

Add OBSIDIAN_MCP_ENABLED_TOOLS as the service config source for MCP tool groups while keeping OBSIDIAN_MCP_ENABLE_INGEST as a legacy fallback.

Add mcp_service.sh tools list/enable/disable commands, preserve enabled tools across normal start and token rotation, and let start --enable-ingest/--disable-ingest update the same config file.

Teach the MCP runtime to derive file-assets and ingest registration from enabled tool groups so file asset tools can be disabled separately from ingest.

Validation: python3 -m unittest discover -v; python3 -m py_compile scripts/manage_mcp_service.py obsidian_wiki/mcp_server.py tests/test_mcp_service.py tests/test_mcp_server_remote.py; CLI tools enable/disable dry run; git diff --check.
This commit is contained in:
Codex
2026-07-05 15:47:16 +00:00
parent a1ca05f545
commit 937d9a002f
7 changed files with 502 additions and 102 deletions
+203 -14
View File
@@ -30,6 +30,64 @@ DEFAULT_PORT = 8765
DEFAULT_PATH = "/mcp"
DEFAULT_TOKEN_BYTES = 32
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
TOOL_FILE_ASSETS = "file-assets"
TOOL_INGEST = "ingest"
AVAILABLE_TOOL_GROUPS = (TOOL_FILE_ASSETS, TOOL_INGEST)
DEFAULT_ENABLED_TOOLS = (TOOL_FILE_ASSETS,)
def normalize_tool_name(name: str) -> str:
normalized = name.strip().lower().replace("_", "-")
aliases = {
"file": TOOL_FILE_ASSETS,
"files": TOOL_FILE_ASSETS,
"file-assets": TOOL_FILE_ASSETS,
"source-file": TOOL_FILE_ASSETS,
"source-files": TOOL_FILE_ASSETS,
"ingest": TOOL_INGEST,
"upload": TOOL_INGEST,
"uploads": TOOL_INGEST,
"opencode-ingest": TOOL_INGEST,
}
if normalized not in aliases:
available = ", ".join(AVAILABLE_TOOL_GROUPS)
raise ValueError(f"unknown MCP tool group: {name}. Available: {available}")
return aliases[normalized]
def parse_bool(value: str | None) -> bool:
return str(value or "").strip().lower() in {"1", "true", "yes", "on", "enabled"}
def normalize_enabled_tools(tools: Sequence[str]) -> tuple[str, ...]:
enabled = {normalize_tool_name(tool) for tool in tools if str(tool).strip()}
return tuple(tool for tool in AVAILABLE_TOOL_GROUPS if tool in enabled)
def enabled_tools_from_config(values: dict[str, str]) -> tuple[str, ...]:
raw_tools = values.get("OBSIDIAN_MCP_ENABLED_TOOLS", "").strip()
if raw_tools:
if raw_tools.lower() in {"none", "off", "disabled"}:
return ()
return normalize_enabled_tools(raw_tools.split(","))
enabled = set(DEFAULT_ENABLED_TOOLS)
if parse_bool(values.get("OBSIDIAN_MCP_ENABLE_INGEST")):
enabled.add(TOOL_INGEST)
return tuple(tool for tool in AVAILABLE_TOOL_GROUPS if tool in enabled)
def format_enabled_tools(enabled_tools: Sequence[str]) -> str:
return ",".join(normalize_enabled_tools(enabled_tools))
def update_ingest_tool(enabled_tools: Sequence[str], enable_ingest: bool | None) -> tuple[str, ...]:
current = set(normalize_enabled_tools(enabled_tools))
if enable_ingest is True:
current.add(TOOL_INGEST)
elif enable_ingest is False:
current.discard(TOOL_INGEST)
return tuple(tool for tool in AVAILABLE_TOOL_GROUPS if tool in current)
@dataclass(frozen=True)
@@ -155,8 +213,11 @@ def write_token_env_file(
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
enable_ingest: bool = False,
enabled_tools: Sequence[str] | None = None,
enable_ingest: bool | None = None,
) -> None:
tools = normalize_enabled_tools(DEFAULT_ENABLED_TOOLS if enabled_tools is None else enabled_tools)
tools = update_ingest_tool(tools, enable_ingest)
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)
@@ -165,7 +226,8 @@ def write_token_env_file(
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_file.write(f"OBSIDIAN_MCP_ENABLE_INGEST={1 if enable_ingest else 0}\n")
env_file.write(f"OBSIDIAN_MCP_ENABLED_TOOLS={format_enabled_tools(tools)}\n")
env_file.write(f"OBSIDIAN_MCP_ENABLE_INGEST={1 if TOOL_INGEST in tools else 0}\n")
env_path.chmod(0o600)
@@ -184,7 +246,7 @@ def read_service_env_file(env_path: Path) -> dict[str, str]:
return values
def saved_service_config(project_root: Path) -> tuple[str, str, int, str]:
def saved_service_config(project_root: Path) -> tuple[str, str, int, str, tuple[str, ...]]:
paths = resolve_service_paths(project_root)
values = read_service_env_file(paths.env_path)
token = values.get("OBSIDIAN_MCP_TOKEN", "").strip()
@@ -193,7 +255,8 @@ def saved_service_config(project_root: Path) -> tuple[str, str, int, str]:
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
enabled_tools = enabled_tools_from_config(values)
return token, host, port, path, enabled_tools
def write_service_unit(
@@ -249,17 +312,25 @@ def render_remote_config(
token: str,
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
config_path: Path | None = None,
enabled_tools: Sequence[str] | None = None,
) -> 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 ""
config_section = f"\nConfig file:\n{config_path}\n" if config_path is not None else ""
enabled_section = ""
if enabled_tools is not None:
enabled_section = f"\nEnabled tool groups:\n{format_enabled_tools(enabled_tools) or 'none'}\n"
return (
"MCP service is running.\n"
"\n"
"Use this URL from another machine:\n"
f"{url}\n"
f"{alternatives_section}"
f"{config_section}"
f"{enabled_section}"
"\n"
"Use this header:\n"
f"Authorization: Bearer {token}\n"
@@ -280,8 +351,16 @@ def render_remote_config(
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)
paths = resolve_service_paths(project_root)
token, _host, port, path, enabled_tools = saved_service_config(project_root)
return render_remote_config(
hosts=detect_remote_hosts(),
token=token,
port=port,
path=path,
config_path=paths.env_path,
enabled_tools=enabled_tools,
)
def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
@@ -304,10 +383,22 @@ def new_token(
token_bytes: int = DEFAULT_TOKEN_BYTES,
) -> str:
paths = resolve_service_paths(project_root)
enabled_tools = DEFAULT_ENABLED_TOOLS
try:
_old_token, _old_host, _old_port, _old_path, enabled_tools = saved_service_config(project_root)
except RuntimeError:
pass
token = generate_token(token_bytes)
write_token_env_file(paths.env_path, token, host=host, port=port, path=path)
write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=enabled_tools)
restart_service_if_installed(paths)
return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path)
return render_remote_config(
hosts=detect_remote_hosts(),
token=token,
port=port,
path=path,
config_path=paths.env_path,
enabled_tools=enabled_tools,
)
def start_service(
@@ -318,16 +409,18 @@ def start_service(
port: int = DEFAULT_PORT,
path: str = DEFAULT_PATH,
token_bytes: int = DEFAULT_TOKEN_BYTES,
enable_ingest: bool = False,
enable_ingest: bool | None = None,
) -> str:
paths = resolve_service_paths(project_root)
try:
token, host, port, path = saved_service_config(project_root)
token, host, port, path, enabled_tools = saved_service_config(project_root)
except RuntimeError:
if paths.env_path.exists():
raise
token = generate_token(token_bytes)
write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enable_ingest=enable_ingest)
enabled_tools = DEFAULT_ENABLED_TOOLS
enabled_tools = update_ingest_tool(enabled_tools, enable_ingest)
write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=enabled_tools)
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)
@@ -336,7 +429,70 @@ def start_service(
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)
return render_remote_config(
hosts=detect_remote_hosts(),
token=token,
port=port,
path=path,
config_path=paths.env_path,
enabled_tools=enabled_tools,
)
def render_tool_status(
*,
config_path: Path,
enabled_tools: Sequence[str],
restarted: bool = False,
) -> str:
enabled = set(normalize_enabled_tools(enabled_tools))
lines = [
"MCP tool configuration.",
"",
f"Config file: {config_path}",
f"Enabled tool groups: {format_enabled_tools(enabled_tools) or 'none'}",
"",
"Available tool groups:",
]
for tool in AVAILABLE_TOOL_GROUPS:
state = "enabled" if tool in enabled else "disabled"
lines.append(f"- {tool}: {state}")
lines.append("")
lines.append(f"Service restarted: {'yes' if restarted else 'no'}")
return "\n".join(lines) + "\n"
def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, int, str, tuple[str, ...]]:
paths = resolve_service_paths(project_root)
try:
token, host, port, path, enabled_tools = saved_service_config(project_root)
except RuntimeError:
token = generate_token()
host = DEFAULT_HOST
port = DEFAULT_PORT
path = DEFAULT_PATH
enabled_tools = DEFAULT_ENABLED_TOOLS
write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=enabled_tools)
return paths, token, host, port, path, enabled_tools
def list_tools(project_root: Path) -> str:
paths, _token, _host, _port, _path, enabled_tools = ensure_service_config(project_root)
return render_tool_status(config_path=paths.env_path, enabled_tools=enabled_tools)
def set_tool_enabled(project_root: Path, tool: str, enabled: bool) -> str:
tool_name = normalize_tool_name(tool)
paths, token, host, port, path, enabled_tools = ensure_service_config(project_root)
current = set(enabled_tools)
if enabled:
current.add(tool_name)
else:
current.discard(tool_name)
updated_tools = tuple(item for item in AVAILABLE_TOOL_GROUPS if item in current)
write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=updated_tools)
restarted = restart_service_if_installed(paths)
return render_tool_status(config_path=paths.env_path, enabled_tools=updated_tools, restarted=restarted)
def stop_service() -> None:
@@ -380,10 +536,19 @@ def main(argv: Sequence[str] | None = None) -> int:
start_parser.add_argument("--port", default=DEFAULT_PORT, type=int)
start_parser.add_argument("--path", default=DEFAULT_PATH)
start_parser.add_argument("--token-bytes", default=DEFAULT_TOKEN_BYTES, type=int)
start_parser.add_argument(
ingest_group = start_parser.add_mutually_exclusive_group()
ingest_group.add_argument(
"--enable-ingest",
dest="enable_ingest",
action="store_true",
help="Expose write-capable upload and local OpenCode ingest MCP tools. Defaults to off.",
default=None,
help="Expose write-capable upload and local OpenCode ingest MCP tools.",
)
ingest_group.add_argument(
"--disable-ingest",
dest="enable_ingest",
action="store_false",
help="Disable write-capable upload and local OpenCode ingest MCP tools.",
)
subparsers.add_parser("stop", help="Stop the user service without deleting service files.")
@@ -396,6 +561,20 @@ def main(argv: Sequence[str] | None = None) -> int:
subparsers.add_parser("status", help="Show systemd status for the managed service.")
tools_parser = subparsers.add_parser("tools", help="List or update enabled MCP tool groups in the service config.")
tools_subparsers = tools_parser.add_subparsers(dest="tools_command", required=True)
tools_list_parser = tools_subparsers.add_parser("list", help="List configured MCP tool groups.")
tools_list_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
tools_enable_parser = tools_subparsers.add_parser("enable", help="Enable an MCP tool group.")
tools_enable_parser.add_argument("tool", choices=AVAILABLE_TOOL_GROUPS)
tools_enable_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
tools_disable_parser = tools_subparsers.add_parser("disable", help="Disable an MCP tool group.")
tools_disable_parser.add_argument("tool", choices=AVAILABLE_TOOL_GROUPS)
tools_disable_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
args = parser.parse_args(argv)
if args.command == "new-token":
@@ -438,6 +617,16 @@ def main(argv: Sequence[str] | None = None) -> int:
if args.command == "status":
service_status()
return 0
if args.command == "tools":
if args.tools_command == "list":
print(list_tools(args.project_root), end="")
return 0
if args.tools_command == "enable":
print(set_tool_enabled(args.project_root, args.tool, True), end="")
return 0
if args.tools_command == "disable":
print(set_tool_enabled(args.project_root, args.tool, False), end="")
return 0
raise AssertionError(f"unhandled command: {args.command}")