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:
+203
-14
@@ -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}")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user