simplify MCPHub extensions and restore Hermes web tools
This commit is contained in:
@@ -20,10 +20,12 @@ Hermes Agent und MCPHub laufen auf Unraid und werden dort mit Appdata gesichert.
|
||||
- Home Assistant, ARR, Unraid, Navidrome, Deemix und GitHub laufen gemeinsam im
|
||||
MCPHub-Container auf Unraid, bleiben aber als getrennte MCP-Server unter
|
||||
`/mcp/NAME` sichtbar, abschaltbar und unabhängig für Clients freigebbar.
|
||||
- Hermes verwendet für allgemeine Recherche seine integrierten Webwerkzeuge;
|
||||
der frühere Athena-Webadapter wird nicht mehr gestartet.
|
||||
- `config/mcp-registry.json` ist die einzige Liste der MCPHub-Server und ihrer
|
||||
Client-Registrierungen. `platform/mcp/sync-clients.py` erzeugt Hermes daraus.
|
||||
- Hermes verwendet für allgemeine Recherche den eingebauten schlüssellosen
|
||||
Keenable-Provider für Suche und Seitenabruf; der frühere Athena-Webadapter
|
||||
wird nicht mehr gestartet.
|
||||
- Die produktive Liste der MCPHub-Server liegt in Unraid-Appdata unter
|
||||
`MCPHub/config/mcp-registry.json`; `config/mcp-registry.json` ist der
|
||||
Neuinstallations-Seed. `platform/mcp/sync-clients.py` erzeugt Hermes daraus.
|
||||
- Modelle und Athena-Backups liegen auf `/data`. Hermes liegt vollständig unter
|
||||
`/mnt/nvme-storage/appdata/Hermes-Agent`; MCPHub-Zustand, Client-Schlüssel und
|
||||
MCP-Zugänge liegen unter `/mnt/nvme-storage/appdata/MCPHub`.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"server": {
|
||||
"id": "himalaya",
|
||||
"hermes_id": "himalaya",
|
||||
"name": "Himalaya Mail",
|
||||
"description": "Apple-unabhängiger Mailzugriff über die Himalaya CLI. Lesen, suchen und Anhänge verwalten; schreibende Mailaktionen nur auf ausdrücklichen Auftrag.",
|
||||
"url": "http://192.168.1.2:8787/mcp/himalaya",
|
||||
"clients": ["hermes"],
|
||||
"timeout": 300,
|
||||
"deployment": {
|
||||
"version": "himalaya-mcp 2.1.2 / himalaya-cli 2.1.0",
|
||||
"source": "https://github.com/Data-Wise/himalaya-mcp",
|
||||
"required_env": ["HIMALAYA_CONFIG"],
|
||||
"required_files": ["himalaya-config.toml"]
|
||||
},
|
||||
"hub": {
|
||||
"type": "stdio",
|
||||
"secret_file": "himalaya.env",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": [
|
||||
"/run/secrets/mcphub/himalaya.env",
|
||||
"--",
|
||||
"node",
|
||||
"/app/data/extensions/himalaya/index.js"
|
||||
],
|
||||
"env": {
|
||||
"HIMALAYA_BINARY": "/app/data/extensions/himalaya/himalaya",
|
||||
"MCP_TRANSPORT": "stdio"
|
||||
},
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"source": "/app/data/work/himalaya/himalaya",
|
||||
"path": "himalaya",
|
||||
"sha256": "7bc31ca0ea596218d97f1b2637e14c6653b1ebf9741711ac0f8a675384d67472",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"source": "/app/data/work/himalaya/index.js",
|
||||
"path": "index.js",
|
||||
"sha256": "c8a94a46b33e3e683d38bdfbd5d84f4441e4668780facb5f3e90fc22e68ab683",
|
||||
"mode": "0644"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>MCPHub</Name>
|
||||
<Repository>casaderoll/mcphub:1.1.1</Repository>
|
||||
<Repository>casaderoll/mcphub:1.2.1</Repository>
|
||||
<Registry>https://hub.docker.com/r/samanhappy/mcphub</Registry>
|
||||
<Network>bridge</Network>
|
||||
<MyIP/>
|
||||
@@ -11,7 +11,7 @@
|
||||
<Support>https://github.com/samanhappy/mcphub/issues</Support>
|
||||
<Project>https://github.com/samanhappy/mcphub</Project>
|
||||
<ReadMe>https://github.com/samanhappy/mcphub#readme</ReadMe>
|
||||
<Overview>Zentrale MCP-Verwaltung mit Weboberfläche. Das lokale CasaDeRoll-Image basiert reproduzierbar auf MCPHub 1.0.32 und enthält die versionierten ARR-, Deemix-, Navidrome-, GitHub- und FritzBox-Laufzeiten. Home Assistant, MUA und der Athena Operator werden als vorhandene HTTP-MCPs eingebunden. Einzelne Server bleiben unter /mcp/NAME getrennt sichtbar und schaltbar. Hermes nutzt für allgemeine Webrecherche seine eingebauten Werkzeuge.
|
||||
<Overview>Zentrale MCP-Verwaltung mit Weboberfläche. Das lokale CasaDeRoll-Image basiert reproduzierbar auf MCPHub 1.0.32 und enthält die versionierten ARR-, Deemix-, Navidrome-, GitHub- und FritzBox-Laufzeiten. Zusätzliche portable MCPs liegen updatefest im gemounteten Appdata-Verzeichnis und benötigen keinen Image-Neubau. Home Assistant, MUA und der Athena Operator werden als vorhandene HTTP-MCPs eingebunden. Einzelne Server bleiben unter /mcp/NAME getrennt sichtbar und schaltbar. Hermes nutzt für allgemeine Webrecherche seine eingebauten Werkzeuge.
|
||||
|
||||
Weboberfläche: http://[IP]:[PORT:3000]/
|
||||
Benutzer beim ersten Start: admin
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SOURCE = pathlib.Path(__file__).parents[1] / "platform/mcphub/deploy-extension.py"
|
||||
SPEC = importlib.util.spec_from_file_location("deploy_extension", SOURCE)
|
||||
assert SPEC and SPEC.loader
|
||||
deploy = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(deploy)
|
||||
|
||||
|
||||
class DeployExtensionTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = pathlib.Path(self.temp.name)
|
||||
self.appdata = self.root / "appdata"
|
||||
self.work = self.appdata / "work/example"
|
||||
self.secrets = self.root / "secrets"
|
||||
self.registry = self.appdata / "config/mcp-registry.json"
|
||||
self.work.mkdir(parents=True)
|
||||
self.secrets.mkdir()
|
||||
self.registry.parent.mkdir(parents=True)
|
||||
self.registry.write_text('{"version":1,"servers":[]}\n')
|
||||
artifact = self.work / "index.js"
|
||||
artifact.write_text("console.log('ok')\n")
|
||||
digest = hashlib.sha256(artifact.read_bytes()).hexdigest()
|
||||
self.manifest = self.work / "manifest.json"
|
||||
self.manifest.write_text(json.dumps({
|
||||
"server": {
|
||||
"id": "example", "hermes_id": "example", "name": "Example",
|
||||
"description": "Example MCP", "url": "http://host/mcp/example",
|
||||
"clients": ["hermes"],
|
||||
"deployment": {
|
||||
"required_env": ["EXAMPLE_TOKEN"],
|
||||
"required_files": ["example-config.toml"],
|
||||
},
|
||||
"hub": {
|
||||
"type": "stdio", "secret_file": "example.env",
|
||||
"command": "node", "args": ["/app/data/extensions/example/index.js"],
|
||||
"enabled": True,
|
||||
},
|
||||
},
|
||||
"artifacts": [{
|
||||
"source": str(artifact), "path": "index.js",
|
||||
"sha256": digest, "mode": "0644",
|
||||
}],
|
||||
}))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp.cleanup()
|
||||
|
||||
def args(self, **extra: object) -> argparse.Namespace:
|
||||
values = {
|
||||
"appdata": self.appdata, "registry": self.registry,
|
||||
"secrets": self.secrets, "manifest": self.manifest, "id": "example",
|
||||
}
|
||||
values.update(extra)
|
||||
return argparse.Namespace(**values)
|
||||
|
||||
def registered(self) -> dict:
|
||||
return json.loads(self.registry.read_text())["servers"][0]
|
||||
|
||||
def test_missing_secret_forces_disabled_and_unpublished(self) -> None:
|
||||
deploy.stage(self.args())
|
||||
server = self.registered()
|
||||
self.assertFalse(server["hub"]["enabled"])
|
||||
self.assertEqual(server["clients"], [])
|
||||
self.assertTrue((self.appdata / "extensions/example/index.js").is_file())
|
||||
with self.assertRaises(SystemExit):
|
||||
deploy.set_enabled(self.args(), True)
|
||||
|
||||
def test_complete_secret_allows_activation(self) -> None:
|
||||
(self.secrets / "example.env").write_text("EXAMPLE_TOKEN=value\n")
|
||||
(self.secrets / "example-config.toml").write_text("account = 'example'\n")
|
||||
deploy.stage(self.args())
|
||||
deploy.set_enabled(self.args(), True)
|
||||
server = self.registered()
|
||||
self.assertTrue(server["hub"]["enabled"])
|
||||
self.assertEqual(server["clients"], ["hermes"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+2
-1
@@ -19,7 +19,8 @@ und überleben den Austausch der Debian-Systemplatte. Docker-Images werden aus
|
||||
dem Compose-Stack reproduziert und gehören nicht ins Backup.
|
||||
|
||||
Die portablen Fach-MCPs und Hermes gehören nicht mehr zum Athena-Systembackup.
|
||||
MCPHub liegt unter `/mnt/nvme-storage/appdata/MCPHub`, Hermes vollständig unter
|
||||
MCPHub liegt einschließlich externer Registry und portabler Erweiterungen unter
|
||||
`/mnt/nvme-storage/appdata/MCPHub`, Hermes vollständig unter
|
||||
`/mnt/nvme-storage/appdata/Hermes-Agent`. Beide Verzeichnisse werden vom
|
||||
bestehenden Unraid-Appdata-Backup gesichert. Für ein vollständiges
|
||||
Desaster-Recovery müssen daher sowohl Athenas `/data` als auch dieses
|
||||
|
||||
@@ -36,8 +36,11 @@ terminal:
|
||||
lifetime_seconds: 1800
|
||||
|
||||
web:
|
||||
search_backend: "searxng"
|
||||
extract_backend: "native"
|
||||
# Keenable is bundled with Hermes and provides both search and extraction
|
||||
# through its keyless tier. This remains available after Hermes moved to
|
||||
# Unraid, where the former Athena-local SearXNG no longer exists.
|
||||
search_backend: "keenable"
|
||||
extract_backend: "keenable"
|
||||
extract_char_limit: 15000
|
||||
keyless_fallback: true
|
||||
keyless_rescue: true
|
||||
@@ -161,7 +164,7 @@ skills:
|
||||
creation_nudge_interval: 20
|
||||
|
||||
plugins:
|
||||
enabled: ["web-searxng"]
|
||||
enabled: ["browser-browser-use", "web-keenable"]
|
||||
|
||||
timeouts:
|
||||
tools:
|
||||
|
||||
@@ -42,7 +42,6 @@ ROUTER_API_KEY=$router_key
|
||||
VOICE_TOOLS_OPENAI_KEY=$router_key
|
||||
MUA_MCP_URL=$mua_url
|
||||
MUA_MCP_BEARER_TOKEN=$mua_token
|
||||
SEARXNG_URL=http://searxng:8080
|
||||
API_SERVER_ENABLED=true
|
||||
API_SERVER_HOST=0.0.0.0
|
||||
API_SERVER_PORT=8642
|
||||
|
||||
@@ -34,6 +34,12 @@ create_profile() {
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set agent.reasoning_effort low
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set display.show_reasoning false
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set auxiliary.title_generation.enabled false
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set web.search_backend keenable
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set web.extract_backend keenable
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set web.keyless_fallback true
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set web.keyless_rescue true
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set plugins.enabled \
|
||||
'["browser-browser-use","web-keenable"]'
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config unset compression.threshold_tokens || true
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.threshold 0.95
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.target_ratio 0.15
|
||||
@@ -72,7 +78,9 @@ done < <(jq -r --argjson max "$(jq '.max_output_tokens' "$PROFILE_MATRIX")" \
|
||||
# The Unraid host deliberately has no system Python. Run the small declarative
|
||||
# client renderer in the already version-pinned MCPHub image instead of adding
|
||||
# host dependencies.
|
||||
sync_args=(--registry /stack/config/mcp-registry.json)
|
||||
mcphub_registry=${MCPHUB_REGISTRY:-/mnt/nvme-storage/appdata/MCPHub/config/mcp-registry.json}
|
||||
[[ -s $mcphub_registry ]] || die "MCPHub-Registry fehlt: $mcphub_registry"
|
||||
sync_args=(--registry /run/input/mcp-registry.json)
|
||||
mcphub_token=${MCPHUB_TOKEN_FILE:-/mnt/nvme-storage/appdata/MCPHub/client-token}
|
||||
token_mount=()
|
||||
if [[ -s $mcphub_token ]]; then
|
||||
@@ -85,8 +93,9 @@ done < <(find "$HERMES_DATA_DIR" -name config.yaml -type f -print)
|
||||
docker run --rm --entrypoint python \
|
||||
-v "$STACK_DIR:/stack:ro" \
|
||||
-v "$HERMES_DATA_DIR:/hermes:rw" \
|
||||
-v "$mcphub_registry:/run/input/mcp-registry.json:ro" \
|
||||
"${token_mount[@]}" \
|
||||
casaderoll/mcphub:1.1.0 \
|
||||
casaderoll/mcphub:1.2.1 \
|
||||
/stack/platform/mcp/sync-clients.py "${sync_args[@]}"
|
||||
|
||||
"$STACK_DIR/platform/hermes/install-skills.sh"
|
||||
|
||||
@@ -1,229 +1,107 @@
|
||||
---
|
||||
name: mcphub-deployer
|
||||
description: Install, update, disable, test, publish, or remove portable MCP servers in CasaDeRoll MCPHub on Unraid. Use for MCP catalog, GitHub, npm, PyPI, Go-binary, existing HTTP-MCP, client-registration, MCPHub repair, or moving an MCP out of Athena or Hermes.
|
||||
description: Install, update, disable, test, publish, or inspect portable MCP servers in CasaDeRoll MCPHub on Unraid. Use for MCP repositories, packages, binaries, HTTP MCPs, client registration, MCPHub repair, or moving an MCP out of Athena or Hermes.
|
||||
---
|
||||
|
||||
# MCPHub Deployer
|
||||
|
||||
Install portable MCPs in the existing `MCPHub` container on Unraid. Never
|
||||
create one Docker container per portable MCP and never install one inside
|
||||
Hermes. Hermes, OpenWebUI, Pi, and other agents are clients of MCPHub.
|
||||
Install portable MCPs in the existing `MCPHub`; never create a separate
|
||||
container and never install them inside Hermes.
|
||||
|
||||
## Fixed production map
|
||||
## Fixed map
|
||||
|
||||
Use these paths directly. Do not search the filesystem for alternatives.
|
||||
- Unraid: `192.168.1.2`; container: `MCPHub`; base URL: `http://192.168.1.2:8787`
|
||||
- Appdata: `/mnt/nvme-storage/appdata/MCPHub`
|
||||
- Work: `/mnt/nvme-storage/appdata/MCPHub/work/<id>`
|
||||
- Extensions: `/mnt/nvme-storage/appdata/MCPHub/extensions/<id>`
|
||||
- Registry: `/mnt/nvme-storage/appdata/MCPHub/config/mcp-registry.json`
|
||||
- Secret: `/mnt/nvme-storage/appdata/MCPHub/secrets/<id>.env` (0600)
|
||||
- Helper in container: `/opt/casaderoll/deploy-extension.py`
|
||||
- Route: `http://192.168.1.2:8787/mcp/<id>`
|
||||
|
||||
- Host: Unraid `192.168.1.2`
|
||||
- Container: `MCPHub`
|
||||
- UI/base URL: `http://192.168.1.2:8787`
|
||||
- Operational source/build tree: `/mnt/nvme-storage/appdata/MCPHub/build/repo`
|
||||
- Dockerfile: `/mnt/nvme-storage/appdata/MCPHub/build/repo/platform/mcphub/Dockerfile`
|
||||
- Single server and client registry: `/mnt/nvme-storage/appdata/MCPHub/build/repo/config/mcp-registry.json`
|
||||
- Registry renderer: `/mnt/nvme-storage/appdata/MCPHub/build/repo/platform/mcphub/configure-settings.py` (normally unchanged)
|
||||
- Versioned Unraid-template source: `/mnt/nvme-storage/appdata/MCPHub/build/repo/config/unraid-templates/my-MCPHub.xml`
|
||||
- Live DockerMan template: `/boot/config/plugins/dockerMan/templates-user/my-MCPHub.xml`
|
||||
- Persistent state: `/mnt/nvme-storage/appdata/MCPHub`
|
||||
- Secrets: `/mnt/nvme-storage/appdata/MCPHub/secrets/<server>.env`, mode `0600`
|
||||
- Client bearer token: `/mnt/nvme-storage/appdata/MCPHub/client-token`
|
||||
- Individual route: `http://192.168.1.2:8787/mcp/<server>`
|
||||
Do not search for other checkouts, registries, templates, or secret stores.
|
||||
Normal extensions do not modify Dockerfile, image tag, Unraid template, MCPHub
|
||||
source, or Hermes config manually.
|
||||
|
||||
The versioned template and the live DockerMan template are different files.
|
||||
Keep their image tag and required mounts aligned. If the versioned template is
|
||||
missing, copy the live template to that exact versioned path once; do not search
|
||||
for another template and do not infer a replacement from unrelated containers.
|
||||
## Hard credential boundary
|
||||
|
||||
The operational build tree is persistent and covered by the normal Unraid
|
||||
Appdata backup. The checkout below is legacy and MUST NOT be used or inspected
|
||||
for MCPHub work:
|
||||
Credentials are user input. Check only whether the dedicated secret file and
|
||||
required keys exist; never print values. Never inspect other containers,
|
||||
environments, mail servers, configs, histories, or passwords to find or infer
|
||||
credentials. If credentials are missing, complete credential-independent build
|
||||
work, stage the MCP disabled, report the exact secret path and missing key
|
||||
names, then stop. Never publish or authenticate it.
|
||||
|
||||
`/mnt/nvme-storage/Eigene Dateien/Michael/Entwicklung/AI-Profile-Router`
|
||||
## One-pass workflow
|
||||
|
||||
The normal Git repository remains the durable documentation/history. Publish
|
||||
the same focused files there when an authorized Git write path is available.
|
||||
Lack of Git access is a warning to report, not permission to search for other
|
||||
checkouts and not a reason to abandon an otherwise requested local install.
|
||||
1. Read this skill once. Inspect only MCPHub state, the fixed registry, the
|
||||
dedicated secret-file presence, and the target upstream release/source.
|
||||
2. Classify once: existing HTTP MCP, packaged stdio MCP, released binary,
|
||||
custom MCP, or host-bound HTTP proxy. Do not reconsider without a concrete
|
||||
failed build or handshake.
|
||||
3. Interpret intent: “prüfen/planen” changes nothing; “installieren/einbauen”
|
||||
continues; “deaktiviert” never activates; “read-only” omits mutating tools.
|
||||
4. Build only in `/tmp` or the fixed work directory. Pin versions and verify
|
||||
checksums. Put runtime files in the work directory, not in the repository.
|
||||
5. Write one compact manifest at `<work>/manifest.json`:
|
||||
|
||||
## Mandatory fast path
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"id": "example", "hermes_id": "example", "name": "Example",
|
||||
"description": "Short tool-selection description",
|
||||
"url": "http://192.168.1.2:8787/mcp/example",
|
||||
"clients": ["hermes"],
|
||||
"deployment": {"required_env": ["EXAMPLE_TOKEN"]},
|
||||
"hub": {
|
||||
"type": "stdio", "secret_file": "example.env",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": ["/run/secrets/mcphub/example.env", "--", "node", "/app/data/extensions/example/index.js"],
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"artifacts": [
|
||||
{"source": "/app/data/work/example/index.js", "path": "index.js", "sha256": "HEX", "mode": "0644"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For a normal installation, perform these phases once and in order. Do not
|
||||
restart discovery after a phase has completed.
|
||||
Use paths as seen inside MCPHub (`/app/data/...`) in the manifest.
|
||||
6. Stage with exactly:
|
||||
|
||||
### 1. Preflight — at most six checks
|
||||
```sh
|
||||
docker exec MCPHub python3 /opt/casaderoll/deploy-extension.py stage \
|
||||
--manifest /app/data/work/<id>/manifest.json
|
||||
```
|
||||
|
||||
Check only:
|
||||
The helper copies verified files, updates the registry atomically, and always
|
||||
stages disabled. It technically blocks activation when the dedicated secret
|
||||
or a required key is missing.
|
||||
7. Recreate only `MCPHub` once so it reconciles the external registry. Do not
|
||||
restart Hermes, Athena, Router, Qwen, WireGuard, Unraid, or other services.
|
||||
8. If credentials are ready, activate with the helper, recreate only MCPHub,
|
||||
then verify: health; all old routes; new handshake; `list_tools` schemas;
|
||||
one bounded read-only call; no test writes or residue. Otherwise stop while
|
||||
disabled.
|
||||
9. Run the existing client-sync script only after successful activation. Do
|
||||
not edit client YAML by hand. New routes are not advertised automatically.
|
||||
10. Report version, state, tool count, secret path (never values), tests,
|
||||
client sync, durable source status, and rollback.
|
||||
|
||||
1. `MCPHub` container state, image tag, mounts, and network.
|
||||
2. The fixed Dockerfile, registry, renderer, and both template paths above.
|
||||
3. Existing MCPHub server names to avoid duplication.
|
||||
4. Target service reachability or the upstream release.
|
||||
5. Required secret-file presence; never print its values.
|
||||
6. Current Git availability, if any.
|
||||
## Limits
|
||||
|
||||
Never run `find` to rediscover a listed path. Never read unrelated Compose
|
||||
stacks, repositories, documentation trees, container logs, container
|
||||
environments, application configs, home directories, or secret stores.
|
||||
- At most six preflight reads and two attempts per hypothesis.
|
||||
- At most one corrected build.
|
||||
- Never dump full registries, repository trees, logs, or configs; use bounded
|
||||
queries and compact JSON.
|
||||
- Never use one giant shell call to write several files.
|
||||
- Never claim success without observed handshake and probe results.
|
||||
- On failure leave the previous MCPHub running and the new extension disabled.
|
||||
- Ask before destructive actions or credential rotation not explicitly asked.
|
||||
|
||||
### Credential boundary — mandatory stop
|
||||
## Resume
|
||||
|
||||
Credentials and account configuration are user-supplied inputs, not discovery
|
||||
targets. This rule overrides the desire to complete a deployment or live test.
|
||||
|
||||
- Never inspect another container, service, environment, mount, config file,
|
||||
mailbox, shell history, password manager, or secret directory to obtain or
|
||||
infer credentials.
|
||||
- Never reuse credentials found in an existing mail server or another app
|
||||
unless the user explicitly names that exact source and authorizes reuse.
|
||||
- Check only whether the dedicated fixed secret file for this server exists;
|
||||
do not read its values during preflight.
|
||||
- If the MCP needs credentials or account settings and the dedicated file is
|
||||
absent or incomplete, finish all credential-independent build work, install
|
||||
the server disabled, and stop before live authentication. Ask the user for
|
||||
the missing fields and state the exact secret-file path.
|
||||
- Do not substitute inspection of an existing service for that question.
|
||||
- A missing credential may reduce verification to build plus MCP handshake; it
|
||||
is never permission to investigate the user's infrastructure.
|
||||
|
||||
### 2. Classify once
|
||||
|
||||
Choose exactly one integration:
|
||||
|
||||
- Existing HTTP MCP: declare its URL and authentication; do not copy it.
|
||||
- Packaged stdio MCP: pin and install the exact npm/Python package in MCPHub.
|
||||
- Released binary: pin version and checksum; download and verify it during the
|
||||
Docker image build. Do not commit a downloaded binary.
|
||||
- Custom MCP: keep source in the build tree and copy it into the image.
|
||||
- Host-bound MCP: leave it on its required host and proxy its authenticated
|
||||
HTTP endpoint through MCPHub.
|
||||
|
||||
Do not reconsider this classification unless a real build or handshake result
|
||||
contradicts it.
|
||||
|
||||
### 3. Interpret the user's intent
|
||||
|
||||
- “Prüfe/plane/zeige den Ablauf”: inspect and return a short plan; change
|
||||
nothing.
|
||||
- “Installiere/baue ein/los/Abfahrt”: continue through deployment and tests.
|
||||
- “Zunächst deaktiviert”: install the runtime and declaration with
|
||||
`enabled: false`; do not add it to client registries yet.
|
||||
- “Nur lesen”: disable or omit mutating tools before client publication.
|
||||
|
||||
Ask only for information that cannot be derived safely: credentials, a
|
||||
material license decision, or an ambiguous destructive permission.
|
||||
|
||||
For mail-related MCPs, repository inspection may determine which field names
|
||||
are required, but the IMAP/SMTP host, user, password/token, sender identity, and
|
||||
TLS choices must come from the user or the dedicated secret file. The presence
|
||||
of a Docker mail server does not answer those questions.
|
||||
|
||||
### 4. Implement the smallest change
|
||||
|
||||
Modify only the necessary fixed production files. Rules:
|
||||
|
||||
- Pin image, package, release, and checksum versions.
|
||||
- Put credentials only in the matching secret file with mode `0600`.
|
||||
- Never print, log, commit, summarize, or return a secret.
|
||||
- Use `/usr/local/bin/run-with-env` for secret-backed stdio servers.
|
||||
- Declare servers only in `config/mcp-registry.json`; do not hard-code a server
|
||||
in `configure-settings.py` and do not edit `mcp_settings.json` manually.
|
||||
- Preserve existing users, bearer keys, prompts, resources, enabled states,
|
||||
and per-tool toggles.
|
||||
- Build a new image tag. Never overwrite the tag currently running.
|
||||
- Update both the versioned template source and the live DockerMan template to
|
||||
the exact new tag. Do not rebuild `template.xml` inside an upstream image.
|
||||
|
||||
For servers exposing many tools, install disabled first. After a successful
|
||||
local test, enable only the required tool groups in MCPHub. Do not publish an
|
||||
unfiltered large server to clients.
|
||||
|
||||
### 5. Deploy without collateral changes
|
||||
|
||||
Build from `/mnt/nvme-storage/appdata/MCPHub/build/repo`, then recreate only
|
||||
`MCPHub` through Unraid DockerMan so it stays a managed Unraid container.
|
||||
Preserve all Appdata and mounts.
|
||||
|
||||
Never restart Athena, Router, Qwen, Hermes, OpenWebUI, WireGuard, Unraid, or
|
||||
unrelated containers for an MCPHub deployment.
|
||||
|
||||
Do not use unrestricted host shell access during repository analysis,
|
||||
classification, or credential preflight. Use it only after the exact file
|
||||
changes, new image tag, verification steps, and rollback tag are known. Its
|
||||
scope is then limited to those declared paths and the `MCPHub` container.
|
||||
|
||||
### 6. Prove the result
|
||||
|
||||
Verify, in this order:
|
||||
|
||||
1. New container uses the intended image and remains healthy.
|
||||
2. Existing MCP routes still handshake.
|
||||
3. New server starts when enabled.
|
||||
4. MCP handshake and `list_tools` succeed with valid schemas.
|
||||
5. One bounded read-only function returns plausible live data.
|
||||
6. No test download, queue item, write, or second backend remains.
|
||||
7. If installed disabled, return it to disabled after the temporary test.
|
||||
|
||||
If credentials are unavailable, steps 3 and 5 may be recorded as blocked.
|
||||
Never weaken the credential boundary merely to make the live probe pass.
|
||||
|
||||
A running container alone is not success. Never claim install, test,
|
||||
registration, Git push, or backup without observing its result.
|
||||
|
||||
### 7. Publish to clients only after filtering
|
||||
|
||||
Add `http://192.168.1.2:8787/mcp/<server>` to
|
||||
`config/mcp-registry.json` only after the server and selected tools have passed
|
||||
verification. Generate intended Hermes/OpenWebUI registrations from that one
|
||||
registry. Reload only the affected client gateway if required.
|
||||
|
||||
New MCPHub servers are not automatically advertised by the model router.
|
||||
|
||||
### 8. Finish compactly
|
||||
|
||||
Report exactly:
|
||||
|
||||
- installed version and image tag;
|
||||
- enabled/disabled state and exposed tool count;
|
||||
- secret-file path without values;
|
||||
- handshake and read-only probe result;
|
||||
- client registrations changed or intentionally omitted;
|
||||
- Git status/push result;
|
||||
- rollback image tag.
|
||||
|
||||
Do not narrate repeated planning or internal reconsideration.
|
||||
|
||||
## Context-compaction checkpoint
|
||||
|
||||
Before a long build or whenever context use approaches compression, write a
|
||||
small checkpoint to:
|
||||
|
||||
`/mnt/nvme-storage/appdata/MCPHub/work/<server>.json`
|
||||
|
||||
Store only: requested outcome, integration type, completed phases, modified
|
||||
paths, old/new image tags, pending action, verification results, and rollback.
|
||||
Never store credentials. After compression, reread this SKILL.md directly from
|
||||
|
||||
`/opt/data/skills/platform/mcphub-deployer/SKILL.md`
|
||||
|
||||
inside the Hermes container plus that checkpoint, then continue at the pending
|
||||
phase. Do not rely on a deduplicated/pruned earlier skill result and never
|
||||
rediscover completed phases.
|
||||
|
||||
Delete the checkpoint after a successful final report. Keep it on failure so a
|
||||
new session can resume safely.
|
||||
|
||||
## Hard limits and stop rules
|
||||
|
||||
- Maximum two attempts for the same command, endpoint, or hypothesis.
|
||||
- Maximum one corrected build after the first failed build.
|
||||
- Never repeat “I now understand the mechanism” and continue researching.
|
||||
- If upstream command, transport, license, or credentials remain unknown after
|
||||
two focused checks, stop and state that exact blocker.
|
||||
- If deployment fails, keep the previous container/image running and report the
|
||||
failing phase. Do not improvise a second container or unversioned binary.
|
||||
- Ask before destructive queue actions, downloads, service mutations, or
|
||||
credential rotation that the user did not explicitly authorize.
|
||||
|
||||
## Rollback
|
||||
|
||||
Restore the previous image tag and declarative server entry, recreate only
|
||||
`MCPHub`, and repeat existing-route handshakes plus one read-only probe. Never
|
||||
delete Appdata or shared secret files during rollback.
|
||||
Before compression write `<work>/checkpoint.json` containing only phase,
|
||||
version, completed checks, pending action, modified paths, and rollback. After
|
||||
compression reload this skill and that checkpoint before continuing. Delete
|
||||
the checkpoint only after success.
|
||||
|
||||
@@ -35,6 +35,7 @@ COPY platform/mcp/patches/mcp_sonarr.py /usr/local/lib/python3.13/site-packages/
|
||||
COPY platform/mcp/patches/mcp_radarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_radarr.py
|
||||
COPY config/mcp-registry.json /opt/casaderoll/config/mcp-registry.json
|
||||
COPY platform/mcphub/configure-settings.py /opt/casaderoll/configure-settings.py
|
||||
COPY platform/mcphub/deploy-extension.py /opt/casaderoll/deploy-extension.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
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ sie sich einen Docker-Container und ein Appdata-Backup teilen.
|
||||
- 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.
|
||||
- Allgemeine Webrecherche bleibt ein eingebautes Hermes-Werkzeug. Der alte
|
||||
- Allgemeine Webrecherche bleibt ein eingebautes Hermes-Werkzeug. Hermes nutzt
|
||||
den schlüssellosen Keenable-Provider für Suche und Seitenabruf. Der alte
|
||||
Athena-Webadapter sowie SearXNG/TinySearch gehören nicht zum MCPHub-Image.
|
||||
- Athenas administrativer Operator ist hostgebunden und bleibt auf Athena.
|
||||
MCPHub reicht den vorhandenen, nur über WireGuard erreichbaren HTTP-Endpunkt
|
||||
@@ -22,6 +23,9 @@ sie sich einen Docker-Container und ein Appdata-Backup teilen.
|
||||
`/mnt/nvme-storage/appdata/MCPHub` on Unraid contains:
|
||||
|
||||
- `mcp_settings.json` (users, server registrations and tool toggles)
|
||||
- `config/mcp-registry.json` (produktive deklarative Serverliste)
|
||||
- `extensions/<id>/` (geprüfte portable MCP-Laufzeiten)
|
||||
- `work/<id>/` (Manifest, Build- und Resume-Zwischenstand)
|
||||
- `jwt-secret` (stable login sessions)
|
||||
- `secrets/*.env` (local credentials, mode `0600`)
|
||||
|
||||
@@ -52,11 +56,29 @@ 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
|
||||
`configure-settings.py` erhält bestehende MCPHub-Benutzer und rendert die
|
||||
externe Produktionsliste. Das Image liefert nur den Seed für einen leeren
|
||||
Neuaufbau. `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.
|
||||
|
||||
## Portable MCPs installieren
|
||||
|
||||
Neue portable MCPs erfordern keinen Image-Neubau und keine Änderung am
|
||||
Unraid-Template. Laufzeitdateien werden zunächst unter `work/<id>` gebaut und
|
||||
per Manifest mit dem geprüften Helfer übernommen:
|
||||
|
||||
```bash
|
||||
docker exec MCPHub python3 /opt/casaderoll/deploy-extension.py stage \
|
||||
--manifest /app/data/work/<id>/manifest.json
|
||||
```
|
||||
|
||||
Der Helfer prüft Checksummen, kopiert atomar nach `extensions/<id>` und setzt
|
||||
den Server immer zuerst auf deaktiviert. Fehlt die dedizierte Secret-Datei oder
|
||||
ein Pflichtfeld, verweigert er die Aktivierung technisch und veröffentlicht
|
||||
den Server an keinen Client. Erst nach Handshake und begrenzter read-only Probe
|
||||
wird aktiviert und die Client-Konfiguration aus derselben Registry erzeugt.
|
||||
|
||||
## Migrationsregel
|
||||
|
||||
Jeweils nur einen Server verschieben, seinen Handshake und einen begrenzten
|
||||
|
||||
@@ -23,8 +23,15 @@ export JWT_SECRET
|
||||
# This makes image upgrades reproducible instead of relying on manual edits in
|
||||
# MCPHub's database/UI.
|
||||
settings_file="$state_dir/mcp_settings.json"
|
||||
registry_dir="$state_dir/config"
|
||||
registry_file="$registry_dir/mcp-registry.json"
|
||||
mkdir -p "$registry_dir" "$state_dir/extensions" "$state_dir/work"
|
||||
if [ ! -s "$registry_file" ]; then
|
||||
cp /opt/casaderoll/config/mcp-registry.json "$registry_file"
|
||||
chmod 0600 "$registry_file"
|
||||
fi
|
||||
python3 /opt/casaderoll/configure-settings.py \
|
||||
"$settings_file" /run/secrets/mcphub \
|
||||
--registry /opt/casaderoll/config/mcp-registry.json
|
||||
--registry "$registry_file"
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -74,7 +74,9 @@ def registry_servers(registry: pathlib.Path, secrets_dir: pathlib.Path,
|
||||
if isinstance(rendered, dict) and isinstance(rendered.get("url"), str):
|
||||
rendered["url"] = re.sub(r"(?<!:)//+", "/", rendered["url"])
|
||||
previous = existing.get(server_id)
|
||||
if isinstance(previous, dict) and "enabled" in previous:
|
||||
deployment = item.get("deployment") or {}
|
||||
if (not deployment.get("managed_enabled")
|
||||
and isinstance(previous, dict) and "enabled" in previous):
|
||||
rendered["enabled"] = bool(previous["enabled"])
|
||||
result[server_id] = rendered
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stage portable MCP runtimes in persistent MCPHub Appdata safely.
|
||||
|
||||
The helper deliberately separates staging from activation. Missing credentials
|
||||
can never result in a published server, even if an agent submits a manifest
|
||||
that requests activation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
|
||||
ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
|
||||
|
||||
def load_json(path: pathlib.Path) -> dict:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise SystemExit(f"Expected JSON object: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def atomic_json(path: pathlib.Path, value: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(value, handle, indent=2, ensure_ascii=False)
|
||||
handle.write("\n")
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def env_keys(path: pathlib.Path) -> set[str]:
|
||||
keys: set[str] = set()
|
||||
if not path.is_file():
|
||||
return keys
|
||||
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
if value.strip().strip("\"'"):
|
||||
keys.add(key.removeprefix("export ").strip())
|
||||
return keys
|
||||
|
||||
|
||||
def credential_state(server: dict, secrets_dir: pathlib.Path) -> tuple[bool, str]:
|
||||
deployment = server.get("deployment") or {}
|
||||
required = [str(item) for item in deployment.get("required_env", [])]
|
||||
required_files = [str(item) for item in deployment.get("required_files", [])]
|
||||
secret_name = str((server.get("hub") or {}).get("secret_file") or "")
|
||||
if not required and not secret_name:
|
||||
return True, "not-required"
|
||||
if not secret_name:
|
||||
return False, "secret-file-not-declared"
|
||||
source = secrets_dir / secret_name
|
||||
present = env_keys(source)
|
||||
missing = [key for key in required if key not in present]
|
||||
if not source.is_file():
|
||||
return False, f"missing:{source}"
|
||||
if missing:
|
||||
return False, "missing-keys:" + ",".join(missing)
|
||||
missing_files = [name for name in required_files if not (secrets_dir / name).is_file()]
|
||||
if missing_files:
|
||||
return False, "missing-files:" + ",".join(missing_files)
|
||||
return True, "ready"
|
||||
|
||||
|
||||
def registry(path: pathlib.Path) -> dict:
|
||||
document = load_json(path)
|
||||
if document.get("version") != 1 or not isinstance(document.get("servers"), list):
|
||||
raise SystemExit("Unsupported MCP registry schema")
|
||||
return document
|
||||
|
||||
|
||||
def find_server(document: dict, server_id: str) -> dict | None:
|
||||
return next((item for item in document["servers"] if item.get("id") == server_id), None)
|
||||
|
||||
|
||||
def validate_server(server: dict) -> str:
|
||||
server_id = str(server.get("id") or "")
|
||||
if not ID_RE.fullmatch(server_id):
|
||||
raise SystemExit("Invalid server id")
|
||||
for key in ("name", "description", "url", "hub"):
|
||||
if not server.get(key):
|
||||
raise SystemExit(f"Server field is required: {key}")
|
||||
if not isinstance(server["hub"], dict) or not server["hub"].get("type"):
|
||||
raise SystemExit("hub.type is required")
|
||||
return server_id
|
||||
|
||||
|
||||
def stage(args: argparse.Namespace) -> None:
|
||||
manifest = load_json(args.manifest)
|
||||
server = manifest.get("server")
|
||||
if not isinstance(server, dict):
|
||||
raise SystemExit("manifest.server must be an object")
|
||||
server = json.loads(json.dumps(server))
|
||||
server_id = validate_server(server)
|
||||
extension_dir = args.appdata / "extensions" / server_id
|
||||
work_root = (args.appdata / "work").resolve()
|
||||
extension_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = pathlib.Path(tempfile.mkdtemp(prefix=f".{server_id}.", dir=extension_dir.parent))
|
||||
try:
|
||||
for artifact in manifest.get("artifacts", []):
|
||||
source = pathlib.Path(str(artifact["source"])).resolve()
|
||||
if work_root not in source.parents:
|
||||
raise SystemExit(f"Artifact must be under {work_root}")
|
||||
relative = pathlib.PurePosixPath(str(artifact["path"]))
|
||||
if relative.is_absolute() or ".." in relative.parts:
|
||||
raise SystemExit("Invalid artifact destination")
|
||||
expected = str(artifact["sha256"]).lower()
|
||||
actual = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
if actual != expected:
|
||||
raise SystemExit(f"Checksum mismatch for {relative}")
|
||||
target = temporary / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, target)
|
||||
target.chmod(int(str(artifact.get("mode", "0644")), 8))
|
||||
backup = extension_dir.with_name(extension_dir.name + ".previous")
|
||||
if backup.exists():
|
||||
shutil.rmtree(backup)
|
||||
if extension_dir.exists():
|
||||
extension_dir.rename(backup)
|
||||
temporary.rename(extension_dir)
|
||||
except BaseException:
|
||||
shutil.rmtree(temporary, ignore_errors=True)
|
||||
raise
|
||||
|
||||
desired = list(server.get("clients", []))
|
||||
deployment = server.setdefault("deployment", {})
|
||||
deployment["desired_clients"] = desired
|
||||
deployment["managed_enabled"] = True
|
||||
ready, reason = credential_state(server, args.secrets)
|
||||
server["hub"]["enabled"] = False
|
||||
server["clients"] = []
|
||||
document = registry(args.registry)
|
||||
document["servers"] = [item for item in document["servers"] if item.get("id") != server_id]
|
||||
document["servers"].append(server)
|
||||
atomic_json(args.registry, document)
|
||||
print(json.dumps({
|
||||
"status": "staged", "id": server_id, "enabled": False,
|
||||
"credentials_ready": ready, "credential_state": reason,
|
||||
"extension": str(extension_dir),
|
||||
}))
|
||||
|
||||
|
||||
def set_enabled(args: argparse.Namespace, enabled: bool) -> None:
|
||||
document = registry(args.registry)
|
||||
server = find_server(document, args.id)
|
||||
if server is None:
|
||||
raise SystemExit(f"Unknown server: {args.id}")
|
||||
ready, reason = credential_state(server, args.secrets)
|
||||
if enabled and not ready:
|
||||
raise SystemExit(f"Activation refused: {reason}")
|
||||
server["hub"]["enabled"] = enabled
|
||||
desired = list((server.get("deployment") or {}).get("desired_clients", []))
|
||||
server["clients"] = desired if enabled else []
|
||||
atomic_json(args.registry, document)
|
||||
print(json.dumps({"status": "enabled" if enabled else "disabled", "id": args.id}))
|
||||
|
||||
|
||||
def status(args: argparse.Namespace) -> None:
|
||||
document = registry(args.registry)
|
||||
server = find_server(document, args.id)
|
||||
if server is None:
|
||||
print(json.dumps({"id": args.id, "registered": False}))
|
||||
return
|
||||
ready, reason = credential_state(server, args.secrets)
|
||||
print(json.dumps({
|
||||
"id": args.id,
|
||||
"registered": True,
|
||||
"enabled": bool((server.get("hub") or {}).get("enabled")),
|
||||
"clients": server.get("clients", []),
|
||||
"credentials_ready": ready,
|
||||
"credential_state": reason,
|
||||
"extension_exists": (args.appdata / "extensions" / args.id).is_dir(),
|
||||
}))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--appdata", type=pathlib.Path, default=pathlib.Path("/app/data"))
|
||||
parser.add_argument("--registry", type=pathlib.Path, default=pathlib.Path("/app/data/config/mcp-registry.json"))
|
||||
parser.add_argument("--secrets", type=pathlib.Path, default=pathlib.Path("/run/secrets/mcphub"))
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
stage_cmd = sub.add_parser("stage")
|
||||
stage_cmd.add_argument("--manifest", type=pathlib.Path, required=True)
|
||||
for name in ("status", "activate", "disable"):
|
||||
command = sub.add_parser(name)
|
||||
command.add_argument("id")
|
||||
args = parser.parse_args()
|
||||
args.appdata.mkdir(parents=True, exist_ok=True)
|
||||
if args.command == "stage":
|
||||
stage(args)
|
||||
elif args.command == "status":
|
||||
status(args)
|
||||
elif args.command == "activate":
|
||||
set_enabled(args, True)
|
||||
else:
|
||||
set_enabled(args, False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user