Consolidate portable MCPs in Unraid MCPHub
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
FROM ghcr.io/github/github-mcp-server@sha256:1817b57d43916532dc002bdc5f344d639bd9fb54a9148d42168458f7c3280567 AS github
|
||||
|
||||
FROM ghcr.io/blakeem/navidrome-mcp:2.2.0@sha256:047f911a5a8f7cc8f185bb4d6e7ca6c435542edefff4694a00c2f718ab0ee7f5 AS navidrome
|
||||
|
||||
FROM samanhappy/mcphub:1.0.32
|
||||
|
||||
ARG MCP_VERSION=1.29.0
|
||||
ARG ARR_MCP_VERSION=1.0.1
|
||||
ARG YT_DLP_VERSION=2026.7.4
|
||||
|
||||
USER root
|
||||
|
||||
# One image, multiple isolated MCP subprocesses. MCPHub already contains
|
||||
# Python, Node.js, uv/uvx and build tools; only the pinned runtime packages
|
||||
# used by our local servers are added here.
|
||||
RUN python3 -m pip install --no-cache-dir \
|
||||
"mcp==${MCP_VERSION}" \
|
||||
"arr-mcp[mcp]==${ARR_MCP_VERSION}" \
|
||||
"yt-dlp==${YT_DLP_VERSION}"
|
||||
|
||||
COPY --from=github /server/github-mcp-server /usr/local/bin/github-mcp-server
|
||||
COPY --from=navidrome /app /opt/casaderoll/navidrome
|
||||
|
||||
COPY platform/mcp/deemix_mcp.py /opt/casaderoll/mcps/deemix_mcp.py
|
||||
COPY platform/web-search/web_search_mcp.py /opt/casaderoll/mcps/web_search_mcp.py
|
||||
COPY platform/mcp/patches/mcp_sonarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_sonarr.py
|
||||
COPY platform/mcp/patches/mcp_radarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_radarr.py
|
||||
COPY platform/mcphub/run-with-env.py /usr/local/bin/run-with-env
|
||||
COPY platform/mcphub/casaderoll-entrypoint.sh /usr/local/bin/casaderoll-mcphub-entrypoint
|
||||
|
||||
# Keep the llama.cpp-compatible schema correction from the former dedicated
|
||||
# Navidrome image. Abort the image build if upstream changes unexpectedly.
|
||||
RUN node -e 'const fs=require("node:fs"); const p="/opt/casaderoll/navidrome/dist/tools/handlers/radio-handlers.js"; let s=fs.readFileSync(p,"utf8"); const a="pattern: '\''^https?://.+'\''"; const b="pattern: '\''^https?://.+$'\''"; const n=s.split(a).length-1; if(n!==2) throw new Error(`expected 2 schema patterns, found ${n}`); fs.writeFileSync(p,s.split(a).join(b));' \
|
||||
&& chmod 0755 /usr/local/bin/run-with-env /usr/local/bin/casaderoll-mcphub-entrypoint /usr/local/bin/github-mcp-server \
|
||||
&& mkdir -p /app/data /run/secrets/mcphub
|
||||
|
||||
ENV MCPHUB_SETTING_PATH=/app/data/ \
|
||||
REQUEST_TIMEOUT=120000 \
|
||||
NODE_ENV=production
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/casaderoll-mcphub-entrypoint"]
|
||||
CMD ["/usr/local/bin/entrypoint.sh", "pnpm", "start"]
|
||||
@@ -0,0 +1,50 @@
|
||||
# CasaDeRoll MCPHub
|
||||
|
||||
MCPHub ist die zentrale Laufzeit und Verwaltungsoberfläche für portable
|
||||
MCP-Server. Die einzelnen Server bleiben unter `/mcp/{server}` sichtbar, obwohl
|
||||
sie sich einen Docker-Container und ein Appdata-Backup teilen.
|
||||
|
||||
## Was hier hinein gehört
|
||||
|
||||
- ARR, Deemix, Navidrome und GitHub laufen als lokale stdio-Unterprozesse.
|
||||
- Home Assistant und MUA/Unraid sind vorhandene HTTP-MCP-Endpunkte und werden
|
||||
vom Hub direkt weitergereicht.
|
||||
- Der Web-Adapter kann hier laufen; SearXNG/TinySearch dürfen getrennte
|
||||
Backend-Dienste bleiben.
|
||||
- Athenas administrativer Operator ist hostgebunden. Vor dem Entfernen seines
|
||||
alten Athena-Containers wird er durch einen SSH-basierten Operator im Hub
|
||||
ersetzt.
|
||||
|
||||
## Dauerhafte Daten
|
||||
|
||||
`/mnt/nvme-storage/appdata/MCPHub` on Unraid contains:
|
||||
|
||||
- `mcp_settings.json` (users, server registrations and tool toggles)
|
||||
- `jwt-secret` (stable login sessions)
|
||||
- `secrets/*.env` (local credentials, mode `0600`)
|
||||
|
||||
Das Verzeichnis wird vom normalen Unraid-Appdata-Backup erfasst. Das Image
|
||||
enthält nur versionierten Code und keine Zugangsdaten.
|
||||
|
||||
Das Dashboard bleibt passwortgeschützt. MCP-Clients teilen sich einen
|
||||
generierten Bearer-Schlüssel in `client-token`. Dadurch ist kein OAuth-Ablauf
|
||||
pro Client nötig, ohne die MCP-Routen anonym zu öffnen. Port 8787 darf nicht ins
|
||||
öffentliche Internet weitergeleitet werden.
|
||||
|
||||
`configure-settings.py` erhält bestehende MCPHub-Benutzer und ersetzt
|
||||
Demo-Server durch die deklarative Produktionsliste. `verify-hub.py` führt
|
||||
Handshakes und Tool-Listen ohne Schreibzugriff aus. `probe-hub.py` führt genau
|
||||
eine ausdrücklich benannte, begrenzte Funktionsprobe aus.
|
||||
|
||||
## Migrationsregel
|
||||
|
||||
Jeweils nur einen Server verschieben, seinen Handshake und einen begrenzten
|
||||
read-only-Aufruf prüfen und erst danach Clients auf
|
||||
`http://UNRAID-IP:8787/mcp/{server}` umstellen. Der alte Athena-Container wird
|
||||
erst gestoppt, wenn Hermes und OpenWebUI nachweislich über MCPHub funktionieren.
|
||||
|
||||
Aktueller Stand: ARR, Deemix, Navidrome, GitHub, Home Assistant und MUA/Unraid
|
||||
sind auf MCPHub produktiv und wurden über Hermes sowie OpenWebUI geprüft. Die
|
||||
alten Athena-Container bleiben vorläufig als ausgeschaltetes beziehungsweise
|
||||
abschaltbares Rückfallnetz bestehen. Web-Adapter und Athena Operator sind die
|
||||
letzten beiden Migrationspunkte.
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
data_dir=${MCPHUB_SETTING_PATH:-/app/data/}
|
||||
case "$data_dir" in
|
||||
*/) state_dir=${data_dir%/} ;;
|
||||
*) state_dir=$(dirname "$data_dir") ;;
|
||||
esac
|
||||
mkdir -p "$state_dir"
|
||||
|
||||
# A stable signing key prevents every container recreation from invalidating
|
||||
# all logged-in browser sessions. It lives only in persistent appdata.
|
||||
jwt_file="$state_dir/jwt-secret"
|
||||
if [ ! -s "$jwt_file" ]; then
|
||||
umask 077
|
||||
python3 -c 'import secrets; print(secrets.token_urlsafe(64))' > "$jwt_file"
|
||||
fi
|
||||
JWT_SECRET=$(cat "$jwt_file")
|
||||
export JWT_SECRET
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replace demo MCPHub entries with CasaDeRoll's declarative server set.
|
||||
|
||||
The existing users, bearer keys, prompts and resources are preserved. Secret
|
||||
values are read locally and written only to the runtime settings file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import secrets
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
|
||||
def env_file(path: pathlib.Path) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return values
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key, value = key.strip(), value.strip()
|
||||
if key.startswith("export "):
|
||||
key = key[7:].strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||
value = value[1:-1]
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def required(values: dict[str, str], key: str, source: pathlib.Path) -> str:
|
||||
value = values.get(key, "").strip()
|
||||
if not value:
|
||||
raise SystemExit(f"{key} is missing in {source}")
|
||||
return value
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("settings", type=pathlib.Path)
|
||||
parser.add_argument("secrets", type=pathlib.Path)
|
||||
parser.add_argument("--web-backend", default="", help="TinySearch MCP URL; empty keeps web disabled")
|
||||
parser.add_argument("--searxng", default="", help="SearXNG base URL")
|
||||
args = parser.parse_args()
|
||||
|
||||
settings = json.loads(args.settings.read_text(encoding="utf-8")) if args.settings.exists() else {}
|
||||
ha_path = args.secrets / "homeassistant.env"
|
||||
mua_path = args.secrets / "mua.env"
|
||||
ha = env_file(ha_path)
|
||||
mua = env_file(mua_path)
|
||||
|
||||
servers: dict[str, object] = {
|
||||
"arr": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": ["/run/secrets/mcphub/arr.env", "--", "arr-mcp", "--transport", "stdio", "--auth-type", "none"],
|
||||
"enabled": True,
|
||||
},
|
||||
"deemix": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": ["/run/secrets/mcphub/deemix.env", "--", "python3", "/opt/casaderoll/mcps/deemix_mcp.py"],
|
||||
"env": {"MCP_TRANSPORT": "stdio"},
|
||||
"enabled": True,
|
||||
},
|
||||
"navidrome": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": ["/run/secrets/mcphub/navidrome.env", "--", "node", "/opt/casaderoll/navidrome/dist/index.js"],
|
||||
"env": {"MCP_TRANSPORT": "stdio", "MCP_HTTP_EXPOSE": "false", "WEBUI_ENABLED": "false"},
|
||||
"enabled": True,
|
||||
},
|
||||
"github": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": ["/run/secrets/mcphub/github.env", "--", "/usr/local/bin/github-mcp-server", "stdio", "--read-only", "--tools", "search_repositories,get_file_contents,search_code"],
|
||||
"enabled": True,
|
||||
},
|
||||
"homeassistant": {
|
||||
"type": "streamable-http",
|
||||
"url": required(ha, "HASS_URL", ha_path).rstrip("/") + "/api/hass_mcp",
|
||||
"headers": {"Authorization": "Bearer " + required(ha, "HASS_TOKEN", ha_path)},
|
||||
"owner": "admin",
|
||||
"enabled": True,
|
||||
},
|
||||
"unraid": {
|
||||
"type": "streamable-http",
|
||||
"url": required(mua, "MUA_MCP_URL", mua_path),
|
||||
"headers": {"Authorization": "Bearer " + required(mua, "MUA_MCP_BEARER_TOKEN", mua_path)},
|
||||
"owner": "admin",
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
if args.web_backend:
|
||||
servers["web"] = {
|
||||
"type": "stdio",
|
||||
"command": "python3",
|
||||
"args": ["/opt/casaderoll/mcps/web_search_mcp.py"],
|
||||
"env": {
|
||||
"TINYSEARCH_MCP_URL": args.web_backend,
|
||||
"SEARXNG_URL": args.searxng,
|
||||
},
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
settings["mcpServers"] = servers
|
||||
settings.setdefault("users", [])
|
||||
token_path = args.settings.parent / "client-token"
|
||||
keys = settings.setdefault("bearerKeys", [])
|
||||
client_key = next((item for item in keys if item.get("name") == "casaderoll-clients"), None)
|
||||
if client_key is None:
|
||||
token = secrets.token_urlsafe(48)
|
||||
client_key = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": "casaderoll-clients",
|
||||
"token": token,
|
||||
"enabled": True,
|
||||
"kind": "system",
|
||||
"accessType": "all",
|
||||
"allowedGroups": [],
|
||||
"allowedServers": [],
|
||||
}
|
||||
keys.append(client_key)
|
||||
else:
|
||||
token = str(client_key["token"])
|
||||
client_key["enabled"] = True
|
||||
client_key["accessType"] = "all"
|
||||
token_path.write_text(token + "\n", encoding="utf-8")
|
||||
os.chmod(token_path, 0o600)
|
||||
settings.setdefault("prompts", [])
|
||||
settings.setdefault("resources", [])
|
||||
system = settings.setdefault("systemConfig", {})
|
||||
system.setdefault("routing", {})["skipAuth"] = False
|
||||
|
||||
args.settings.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".mcp-settings-", dir=args.settings.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(settings, handle, indent=2, ensure_ascii=False)
|
||||
handle.write("\n")
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, args.settings)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
print("MCPHUB_SETTINGS_CONFIGURED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"arr": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": [
|
||||
"/run/secrets/mcphub/arr.env",
|
||||
"--",
|
||||
"arr-mcp",
|
||||
"--transport",
|
||||
"stdio",
|
||||
"--auth-type",
|
||||
"none"
|
||||
],
|
||||
"enabled": true
|
||||
},
|
||||
"deemix": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": [
|
||||
"/run/secrets/mcphub/deemix.env",
|
||||
"--",
|
||||
"python3",
|
||||
"/opt/casaderoll/mcps/deemix_mcp.py"
|
||||
],
|
||||
"env": {"MCP_TRANSPORT": "stdio"},
|
||||
"enabled": true
|
||||
},
|
||||
"navidrome": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": [
|
||||
"/run/secrets/mcphub/navidrome.env",
|
||||
"--",
|
||||
"node",
|
||||
"/opt/casaderoll/navidrome/dist/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MCP_TRANSPORT": "stdio",
|
||||
"MCP_HTTP_EXPOSE": "false",
|
||||
"WEBUI_ENABLED": "false"
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"github": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": [
|
||||
"/run/secrets/mcphub/github.env",
|
||||
"--",
|
||||
"/usr/local/bin/github-mcp-server",
|
||||
"stdio",
|
||||
"--read-only",
|
||||
"--tools",
|
||||
"search_repositories,get_file_contents,search_code"
|
||||
],
|
||||
"enabled": true
|
||||
},
|
||||
"homeassistant": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://ha.example.invalid/api/hass_mcp",
|
||||
"headers": {"Authorization": "Bearer REPLACE_FROM_LOCAL_SECRET"},
|
||||
"owner": "admin",
|
||||
"enabled": true
|
||||
},
|
||||
"unraid": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://UNRAID-IP:3002/mcp",
|
||||
"headers": {"Authorization": "Bearer REPLACE_FROM_LOCAL_SECRET"},
|
||||
"owner": "admin",
|
||||
"enabled": true
|
||||
},
|
||||
"web": {
|
||||
"type": "stdio",
|
||||
"command": "python3",
|
||||
"args": ["/opt/casaderoll/mcps/web_search_mcp.py"],
|
||||
"env": {
|
||||
"TINYSEARCH_MCP_URL": "http://ATHENA-VPN-IP:TINYSEARCH-PORT/mcp",
|
||||
"SEARXNG_URL": "http://ATHENA-VPN-IP:SEARXNG-PORT"
|
||||
},
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"users": [],
|
||||
"systemConfig": {
|
||||
"oauthServer": {"enabled": false},
|
||||
"routing": {"skipAuth": false}
|
||||
},
|
||||
"bearerKeys": [],
|
||||
"prompts": [],
|
||||
"resources": []
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Invoke one explicitly chosen MCPHub tool and print a bounded result."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import httpx
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
|
||||
async def probe(url: str, token: str, tool: str, arguments: dict[str, object]) -> None:
|
||||
async with httpx.AsyncClient(headers={"Authorization": f"Bearer {token}"}) as client:
|
||||
async with streamable_http_client(url, http_client=client) as (reader, writer, _):
|
||||
async with ClientSession(reader, writer) as session:
|
||||
await session.initialize()
|
||||
result = await session.call_tool(tool, arguments)
|
||||
text = "\n".join(getattr(item, "text", "") for item in result.content)
|
||||
print(text[:2000])
|
||||
if result.isError:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("url")
|
||||
parser.add_argument("tool")
|
||||
parser.add_argument("arguments_b64", help="Base64-encoded JSON object")
|
||||
parser.add_argument("--token-file", required=True, type=pathlib.Path)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(
|
||||
probe(
|
||||
args.url,
|
||||
args.token_file.read_text(encoding="utf-8").strip(),
|
||||
args.tool,
|
||||
json.loads(base64.b64decode(args.arguments_b64).decode("utf-8")),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run a command with one or more Docker-style env files.
|
||||
|
||||
Values are parsed literally rather than sourced by a shell. Tokens containing
|
||||
`$`, `#`, spaces or shell metacharacters therefore remain unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
|
||||
def load_env(path: pathlib.Path) -> None:
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"secret environment file is missing: {path}")
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("export "):
|
||||
line = line[7:].lstrip()
|
||||
if "=" not in line:
|
||||
raise SystemExit(f"invalid environment line in {path}: {raw!r}")
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
if not key or not key.replace("_", "A").isalnum() or key[0].isdigit():
|
||||
raise SystemExit(f"invalid environment variable in {path}: {key!r}")
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||
value = value[1:-1]
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 3:
|
||||
raise SystemExit("usage: run-with-env ENV_FILE [ENV_FILE ...] -- COMMAND [ARG ...]")
|
||||
try:
|
||||
separator = sys.argv.index("--")
|
||||
except ValueError as exc:
|
||||
raise SystemExit("missing -- before command") from exc
|
||||
env_files = [pathlib.Path(item) for item in sys.argv[1:separator]]
|
||||
command = sys.argv[separator + 1 :]
|
||||
if not env_files or not command:
|
||||
raise SystemExit("at least one env file and a command are required")
|
||||
for env_file in env_files:
|
||||
load_env(env_file)
|
||||
os.execvp(command[0], command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify MCPHub server endpoints without invoking write operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import httpx
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
|
||||
async def verify(base_url: str, servers: list[str], token: str) -> None:
|
||||
results: dict[str, object] = {}
|
||||
for server in servers:
|
||||
url = f"{base_url.rstrip('/')}/mcp/{server}"
|
||||
try:
|
||||
async with httpx.AsyncClient(headers={"Authorization": f"Bearer {token}"}) as client:
|
||||
transport = streamable_http_client(url, http_client=client)
|
||||
async with transport as (reader, writer, _):
|
||||
async with ClientSession(reader, writer) as session:
|
||||
await session.initialize()
|
||||
tools = await session.list_tools()
|
||||
results[server] = {
|
||||
"ok": True,
|
||||
"tool_count": len(tools.tools),
|
||||
"tools": [tool.name for tool in tools.tools],
|
||||
}
|
||||
except Exception as exc:
|
||||
results[server] = {"ok": False, "error": str(exc)[:300]}
|
||||
print(json.dumps(results, ensure_ascii=False, indent=2))
|
||||
if not all(item.get("ok") for item in results.values()):
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("base_url")
|
||||
parser.add_argument("--token-file", required=True, type=pathlib.Path)
|
||||
parser.add_argument("servers", nargs="+")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(verify(args.base_url, args.servers, args.token_file.read_text(encoding="utf-8").strip()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user