From 1b62370d869f1b2708d11a91692f980cb56d283d Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 14:15:47 +0200 Subject: [PATCH 001/155] Initial implementation of TarkaMCP - Proxmox infrastructure MCP server 29 MCP tools across 3 modules: - Proxmox (18 tools): monitoring, VM/CT lifecycle, storage, network, command execution - SSH (4 tools): direct host access with sync/async exec pattern - iLO (7 tools): HP hardware management via SSH tunnel through pve1 Features: - Modular architecture with conditional tool registration - QEMU Guest Agent + LXC exec auto-detection for in-VM commands - Async command execution with polling for long-running operations - Infrastructure context via YAML resource and MCP prompt - Environment-based config with graceful degradation Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 25 ++ .gitignore | 204 +----------- CLAUDE.md | 70 ++++ .../specs/2026-04-16-tarkamcp-design.md | 257 +++++++++++++++ infrastructure.yaml | 26 ++ pyproject.toml | 24 ++ src/tarkamcp/__init__.py | 1 + src/tarkamcp/__main__.py | 15 + src/tarkamcp/config.py | 105 ++++++ src/tarkamcp/ilo/__init__.py | 0 src/tarkamcp/ilo/client.py | 188 +++++++++++ src/tarkamcp/ilo/tools.py | 103 ++++++ src/tarkamcp/proxmox/__init__.py | 0 src/tarkamcp/proxmox/client.py | 89 +++++ src/tarkamcp/proxmox/monitoring.py | 222 +++++++++++++ src/tarkamcp/proxmox/system.py | 303 ++++++++++++++++++ src/tarkamcp/proxmox/vms.py | 168 ++++++++++ src/tarkamcp/server.py | 84 +++++ src/tarkamcp/ssh/__init__.py | 0 src/tarkamcp/ssh/client.py | 161 ++++++++++ src/tarkamcp/ssh/tools.py | 81 +++++ 21 files changed, 1926 insertions(+), 200 deletions(-) create mode 100644 .env.example create mode 100644 CLAUDE.md create mode 100644 docs/superpowers/specs/2026-04-16-tarkamcp-design.md create mode 100644 infrastructure.yaml create mode 100644 pyproject.toml create mode 100644 src/tarkamcp/__init__.py create mode 100644 src/tarkamcp/__main__.py create mode 100644 src/tarkamcp/config.py create mode 100644 src/tarkamcp/ilo/__init__.py create mode 100644 src/tarkamcp/ilo/client.py create mode 100644 src/tarkamcp/ilo/tools.py create mode 100644 src/tarkamcp/proxmox/__init__.py create mode 100644 src/tarkamcp/proxmox/client.py create mode 100644 src/tarkamcp/proxmox/monitoring.py create mode 100644 src/tarkamcp/proxmox/system.py create mode 100644 src/tarkamcp/proxmox/vms.py create mode 100644 src/tarkamcp/server.py create mode 100644 src/tarkamcp/ssh/__init__.py create mode 100644 src/tarkamcp/ssh/client.py create mode 100644 src/tarkamcp/ssh/tools.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3c83f0c --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Proxmox nodes -- API tokens (create on each node via Datacenter > Permissions > API Tokens) +PVE1_HOST=pve1.example.com +PVE1_TOKEN_ID=root@pam!tarkamcp +PVE1_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# PVE2 is optional (graceful degradation if missing or node is down) +PVE2_HOST=pve2.example.com +PVE2_TOKEN_ID=root@pam!tarkamcp +PVE2_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# iLO -- single unit, local network only (accessed via SSH tunnel through PVE1) +ILO_HOST=192.168.x.x +ILO_USER=Administrator +ILO_PASSWORD=xxxxx +ILO_JUMP_HOST=pve1 + +# SSH credentials (fallback access to hosts and VMs) +SSH_USER=root +SSH_PASSWORD=xxxxx + +# Options +PVE_VERIFY_SSL=false + +# Path to infrastructure.yaml (defaults to ./infrastructure.yaml) +# INFRA_YAML_PATH=./infrastructure.yaml diff --git a/.gitignore b/.gitignore index b7faf40..30762ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,207 +1,11 @@ -# Byte-compiled / optimized / DLL files __pycache__/ -*.py[codz] +*.py[cod] *$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ +*.egg-info/ dist/ -downloads/ -eggs/ +build/ .eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg *.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments .env -.envrc -.venv -env/ +.venv/ venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9ea00b2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,70 @@ +# TarkaMCP + +MCP server for managing a Proxmox VE infrastructure (pve1.example.com, pve2.example.com) with HP iLO 4 hardware management and SSH fallback access. + +## Quick Start + +```bash +pip install -e . +cp .env.example .env # Fill in real credentials +python -m tarkamcp # Runs MCP server on stdio +``` + +## Project Structure + +``` +src/tarkamcp/ + __main__.py Entry point (loads .env, starts MCP server) + server.py FastMCP server, registers all tool modules + config.py Environment variable loading & validation + proxmox/ + client.py proxmoxer wrapper (API token auth, error handling) + monitoring.py 6 tools: list_nodes, node_status, list_vms, vm_status, get_logs, get_tasks + vms.py 7 tools: vm_start, vm_stop, vm_restart, vm_create, vm_clone, vm_migrate, vm_config + system.py 5 tools: storage_status, network_config, exec_command (sync + async + get_result) + ssh/ + client.py asyncssh wrapper (host resolution, connection caching) + tools.py 4 tools: ssh_exec_command (sync + async + get_result), ssh_list_sessions + ilo/ + client.py python-hpilo wrapper (SSH tunnel via pve1 to local iLO) + tools.py 7 tools: server_info, health_status, power_status, power_on/off/reset, event_log +``` + +## Configuration + +All via environment variables (`.env` file). See `.env.example` for full list. + +**Required:** `PVE1_HOST`, `PVE1_TOKEN_ID`, `PVE1_TOKEN_SECRET` +**Optional:** PVE2, iLO, SSH credentials (modules are conditionally registered) + +## Creating Proxmox API Tokens + +On each Proxmox node: Datacenter > Permissions > API Tokens > Add +- User: `root@pam` +- Token ID: `tarkamcp` +- Uncheck "Privilege Separation" for full access + +## Claude Code Integration + +Add to settings.json: +```json +{ + "mcpServers": { + "tarkamcp": { + "command": "python", + "args": ["-m", "tarkamcp"], + "cwd": "/path/to/TarkaMCP", + "env": { "DOTENV_PATH": ".env" } + } + } +} +``` + +## Infrastructure Context + +Edit `infrastructure.yaml` to define naming conventions, node roles, and notes. +The server exposes it as `tarkamcp://infrastructure` resource. + +## Design Spec + +See `docs/superpowers/specs/2026-04-16-tarkamcp-design.md` diff --git a/docs/superpowers/specs/2026-04-16-tarkamcp-design.md b/docs/superpowers/specs/2026-04-16-tarkamcp-design.md new file mode 100644 index 0000000..53a0210 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-tarkamcp-design.md @@ -0,0 +1,257 @@ +# TarkaMCP -- Proxmox Infrastructure MCP Server + +## Context + +TarkaMCP is an MCP server that gives Claude direct access to a Proxmox VE infrastructure for diagnostics, VM management, system administration, and hardware management. The motivation: when a server crashes or misbehaves, Claude should be able to diagnose the issue, check hardware health, and propose/execute resolutions -- rather than the user having to manually SSH, check logs, and relay information back and forth. + +**Infrastructure:** +- **pve1.example.com** -- Proxmox VE node (active), exposed on the internet via HTTPS +- **pve2.example.com** -- Proxmox VE node (currently down) +- **iLO 4** -- HP Integrated Lights-Out, one unit, accessible only from the local network (not publicly exposed) +- **Zyxel USG 210** -- Firewall, deferred from v1 (no REST API available) + +## Architecture + +Single Python MCP server (`tarkamcp`) with modular design, running in **stdio** mode. Three core modules: + +``` +src/tarkamcp/ +├── __init__.py +├── __main__.py # Entry point +├── server.py # FastMCP server, registers all tools +├── config.py # Environment variable loading & validation +├── proxmox/ +│ ├── __init__.py +│ ├── client.py # proxmoxer wrapper, connection management +│ ├── vms.py # VM/CT lifecycle tools +│ ├── monitoring.py # Node & VM monitoring tools +│ └── system.py # Storage, network, command execution tools +├── ilo/ +│ ├── __init__.py +│ └── client.py # python-hpilo wrapper + SSH tunnel management +└── ssh/ + ├── __init__.py + └── client.py # asyncssh wrapper, session management +``` + +**Dependencies:** +- `mcp` -- MCP Python SDK (FastMCP) +- `proxmoxer` + `requests` -- Proxmox VE API client +- `python-hpilo` -- HP iLO 4 management (synchronous library, runs in asyncio executor) +- `asyncssh` -- Async SSH connections +- `python-dotenv` -- Environment variable loading + +## MCP Tools + +### Module Proxmox -- Monitoring & Diagnostic + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `proxmox_list_nodes` | List cluster nodes with status (online/offline/unknown) | -- | +| `proxmox_node_status` | Detailed node status: CPU, RAM, disk, uptime, kernel version, PVE version | `node` | +| `proxmox_list_vms` | List all VMs/CTs with status, resource usage | `node` (optional, all nodes if omitted) | +| `proxmox_vm_status` | Detailed VM/CT status: CPU, RAM, disk I/O, network I/O, uptime | `node`, `vmid` | +| `proxmox_get_logs` | Retrieve system logs (syslog, tasks, journal) | `node`, `source` (syslog/tasks), `limit` | +| `proxmox_get_tasks` | List recent Proxmox tasks (migrations, backups, etc.) | `node` (optional), `limit` | + +### Module Proxmox -- VM/CT Management + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `proxmox_vm_start` | Start a VM or CT | `node`, `vmid` | +| `proxmox_vm_stop` | Stop a VM or CT (clean shutdown or force) | `node`, `vmid`, `force` | +| `proxmox_vm_restart` | Restart a VM or CT | `node`, `vmid` | +| `proxmox_vm_create` | Create a new VM or CT | `node`, `config` (dict) | +| `proxmox_vm_clone` | Clone an existing VM/CT | `node`, `vmid`, `newid`, `name` | +| `proxmox_vm_migrate` | Migrate a VM/CT to another node | `node`, `vmid`, `target_node` | +| `proxmox_vm_config` | Read or modify VM/CT configuration | `node`, `vmid`, `updates` (optional) | + +### Module Proxmox -- System Administration + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `proxmox_storage_status` | Storage status across the cluster | `node` (optional) | +| `proxmox_network_config` | Network configuration of a node | `node` | +| `proxmox_exec_command` | Execute a command inside a VM (QEMU Guest Agent) or CT (lxc exec), wait for result | `node`, `vmid`, `command`, `timeout` (default 60s) | +| `proxmox_exec_command_async` | Start a long-running command inside a VM/CT, return exec_id | `node`, `vmid`, `command` | +| `proxmox_exec_get_result` | Get result of an async command by exec_id | `exec_id` | + +**Command execution design:** +- The tool auto-detects whether the target is a VM (uses QEMU Guest Agent) or CT (uses Proxmox's built-in lxc exec). The caller does not need to know the difference. +- `proxmox_exec_command` blocks until the command completes or timeout is reached. Returns `{"stdout": "...", "stderr": "...", "exit_code": N}`. +- `proxmox_exec_command_async` returns immediately with `{"exec_id": "...", "status": "running"}`. Internally uses QEMU Guest Agent's native async exec for VMs (start -> PID -> poll) or background execution for CTs. +- `proxmox_exec_get_result` returns `{"exec_id": "...", "status": "running|completed|timeout", "stdout": "...", "stderr": "...", "exit_code": N}`. +- Async exec state is held in-memory in the server process. A dict of `{exec_id: {pid, node, vmid, type, status, output}}`. + +### Module iLO + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `ilo_server_info` | Server model, serial number, firmware versions (iLO, BIOS) | -- | +| `ilo_health_status` | Full health: temperatures, fans, power supplies, disks, memory | -- | +| `ilo_power_status` | Current power state of the server | -- | +| `ilo_power_on` | Power on the physical server | -- | +| `ilo_power_off` | Power off the physical server (use when server is unresponsive) | `force` (default false) | +| `ilo_power_reset` | Hard reset the physical server | -- | +| `ilo_get_event_log` | iLO event log (hardware errors, reboots, etc.) | `limit` | + +**iLO access via SSH tunnel:** +Since iLO is only accessible from the local network, the module establishes an SSH tunnel through pve1: +1. asyncssh opens a tunnel: `localhost:dynamic_port -> pve1 -> ilo_local_ip:443` +2. python-hpilo connects to `localhost:dynamic_port` +3. Tunnel is created on-demand and reused for subsequent calls +4. If pve1 is unreachable, iLO tools return an error explaining the dependency + +### Module SSH + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `ssh_exec_command` | Execute a command on any host via SSH, wait for result | `host`, `command`, `timeout` (default 60s) | +| `ssh_exec_command_async` | Start a long-running SSH command, return exec_id | `host`, `command` | +| `ssh_exec_get_result` | Get result of an async SSH command | `exec_id` | +| `ssh_list_sessions` | List active async command sessions with their status | -- | + +SSH uses password authentication. The `host` parameter accepts: +- A Proxmox node name (`pve1`, `pve2`) -- resolved to the configured host from env vars +- A VMID (e.g., `101`) -- resolved to IP via the infrastructure.yaml convention (192.168.1.{VMID}) +- A direct IP or hostname (e.g., `192.168.1.50`) + +## Configuration + +All configuration via environment variables, loaded from `.env` file by `python-dotenv`: + +```env +# Proxmox nodes -- API tokens (to be created on the Proxmox nodes) +PVE1_HOST=pve1.example.com +PVE1_TOKEN_ID=root@pam!tarkamcp +PVE1_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +PVE2_HOST=pve2.example.com +PVE2_TOKEN_ID=root@pam!tarkamcp +PVE2_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# iLO -- single unit, local network only +ILO_HOST=192.168.x.x +ILO_USER=Administrator +ILO_PASSWORD=xxxxx +ILO_JUMP_HOST=pve1 # Proxmox node used as SSH jump host + +# SSH credentials (fallback access) +SSH_USER=root +SSH_PASSWORD=xxxxx + +# Options +PVE_VERIFY_SSL=false # Set to true if using valid SSL certificates +``` + +**Startup validation:** +- PVE1 credentials are required (server won't start without them) +- PVE2 credentials are optional (graceful degradation if missing or node is down) +- iLO credentials are optional (iLO tools disabled if not configured) +- SSH credentials are optional (SSH tools disabled if not configured) + +## Error Handling + +- **Node unreachable:** Tools return a clear error message indicating which node is unreachable, rather than raising exceptions. Claude can then suggest remediation (check iLO, try SSH, etc.). +- **Authentication failures:** Logged and returned as structured errors with guidance (check token, check password, etc.). +- **Command timeouts:** Async commands that exceed timeout are marked as `timeout` status. Partial output is preserved. +- **iLO tunnel failure:** If pve1 (jump host) is unreachable, iLO tools return an error explaining that iLO is only accessible through pve1. + +## Claude Code Integration + +Add to `~/.claude/settings.json` or project `.claude/settings.json`: + +```json +{ + "mcpServers": { + "tarkamcp": { + "command": "python", + "args": ["-m", "tarkamcp"], + "cwd": "/path/to/TarkaMCP/src", + "env": { + "PVE1_HOST": "pve1.example.com", + "PVE1_TOKEN_ID": "root@pam!tarkamcp", + "PVE1_TOKEN_SECRET": "..." + } + } + } +} +``` + +Or use a `.env` file in the project directory and configure only the command. + +## Verification Plan + +1. **Unit:** Test each module's client wrapper independently with mocked API responses +2. **Integration:** Test against pve1 with real API token: + - List nodes, check node status + - List VMs, start/stop a test VM + - Execute a simple command via QEMU Guest Agent (`echo hello`) + - Run an async command and poll for result +3. **iLO:** Test tunnel creation + health check against the real iLO +4. **SSH:** Test direct SSH command execution on pve1 +5. **End-to-end:** Start the MCP server, use it from Claude Code to diagnose a real scenario (e.g., "why is pve2 down?") + +## MCP Resources & Prompts + +### Infrastructure Context Resource + +An `infrastructure.yaml` file at the project root provides contextual information about the infrastructure. The MCP server exposes it as a resource so Claude can read it automatically. + +```yaml +# infrastructure.yaml +conventions: + vmid_to_ip: "CT VMID corresponds to local IP 192.168.1.{VMID}" + naming: "VMs are prefixed by their role (e.g., web-101, db-102)" + +nodes: + pve1: + host: pve1.example.com + role: "Primary node" + local_network: "192.168.1.0/24" + pve2: + host: pve2.example.com + role: "Secondary node" + notes: "Currently down" + +ilo: + host: "192.168.x.x" + access: "Local network only, via SSH tunnel through pve1" + +firewall: + model: "Zyxel USG 210" + notes: "No API, managed via web GUI" + +notes: + - "iLO is accessible only through pve1 as SSH jump host" + - "Zyxel USG 210 is the network gateway" + - "API tokens must be created on each Proxmox node before use" +``` + +The server exposes this as `tarkamcp://infrastructure` -- a readable resource that provides Claude with the full infrastructure context. + +### MCP Prompt: Infrastructure Overview + +The server registers an MCP prompt `tarkamcp-context` that injects a concise infrastructure summary into the conversation. This follows prompt engineering best practices (from `docs/prompt-engineering-guide.md`): +- Role definition: "You are managing a Proxmox VE infrastructure" +- Context: node topology, naming conventions, access constraints +- Positive instructions: what to check first, how to diagnose + +### Tool Description Quality + +All MCP tool descriptions follow best practices: +- **Self-sufficient**: each description is understandable without external context +- **Namespaced**: `proxmox_*`, `ilo_*`, `ssh_*` prefixes +- **When to use / when not to use**: each tool specifies its use case and alternatives +- **Actionable errors**: error messages include what went wrong and what to try next +- **Semantic parameter names**: `vmid` not `id`, `target_node` not `dest` + +Reference: `/docs/prompt-engineering-guide.md` -- sections 4.1 through 4.6. + +## Out of Scope (v1) + +- Zyxel USG 210 firewall integration (no API available) +- Proxmox built-in firewall management (can be added later) +- Backup management (can be added later via Proxmox Backup Server API) +- User/permission management on Proxmox +- Automated alerting/monitoring (this is a tool for Claude, not a monitoring stack) diff --git a/infrastructure.yaml b/infrastructure.yaml new file mode 100644 index 0000000..ca371d9 --- /dev/null +++ b/infrastructure.yaml @@ -0,0 +1,26 @@ +conventions: + vmid_to_ip: "CT VMID corresponds to local IP 192.168.1.{VMID}" + naming: "VMs are prefixed by their role (e.g., web-101, db-102)" + +nodes: + pve1: + host: pve1.example.com + role: "Primary node" + local_network: "192.168.1.0/24" + pve2: + host: pve2.example.com + role: "Secondary node" + notes: "Currently down" + +ilo: + host: "192.168.x.x" + access: "Local network only, via SSH tunnel through pve1" + +firewall: + model: "Zyxel USG 210" + notes: "No API, managed via web GUI" + +notes: + - "iLO is accessible only through pve1 as SSH jump host" + - "Zyxel USG 210 is the network gateway" + - "API tokens must be created on each Proxmox node before use" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e9cd9f8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "tarkamcp" +version = "0.1.0" +description = "MCP server for Proxmox VE infrastructure management" +requires-python = ">=3.11" +dependencies = [ + "mcp[cli]>=1.0", + "proxmoxer", + "requests", + "python-hpilo", + "asyncssh", + "python-dotenv", + "pyyaml", +] + +[project.scripts] +tarkamcp = "tarkamcp.__main__:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/tarkamcp"] diff --git a/src/tarkamcp/__init__.py b/src/tarkamcp/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/src/tarkamcp/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py new file mode 100644 index 0000000..042727e --- /dev/null +++ b/src/tarkamcp/__main__.py @@ -0,0 +1,15 @@ +from dotenv import load_dotenv + +# load_dotenv MUST run before importing server, because server.py +# triggers Config.from_env() at import time +load_dotenv() + +from .server import mcp # noqa: E402 + + +def main(): + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/src/tarkamcp/config.py b/src/tarkamcp/config.py new file mode 100644 index 0000000..2e2ca37 --- /dev/null +++ b/src/tarkamcp/config.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass +from pathlib import Path + +import yaml + + +@dataclass +class PVENode: + name: str + host: str + token_id: str + token_secret: str + + +@dataclass +class ILOConfig: + host: str + user: str + password: str + jump_host: str # Proxmox node name used as SSH tunnel + + +@dataclass +class SSHConfig: + user: str + password: str + + +@dataclass +class Config: + pve_nodes: list[PVENode] + ilo: ILOConfig | None + ssh: SSHConfig | None + verify_ssl: bool + infrastructure: dict + + @classmethod + def from_env(cls) -> Config: + # PVE1 is required + pve1_host = os.environ.get("PVE1_HOST", "") + pve1_token_id = os.environ.get("PVE1_TOKEN_ID", "") + pve1_token_secret = os.environ.get("PVE1_TOKEN_SECRET", "") + + if not pve1_host or not pve1_token_id or not pve1_token_secret: + print( + "ERROR: PVE1_HOST, PVE1_TOKEN_ID, and PVE1_TOKEN_SECRET are required.", + file=sys.stderr, + ) + sys.exit(1) + + nodes = [PVENode("pve1", pve1_host, pve1_token_id, pve1_token_secret)] + + # PVE2 is optional + pve2_host = os.environ.get("PVE2_HOST", "") + pve2_token_id = os.environ.get("PVE2_TOKEN_ID", "") + pve2_token_secret = os.environ.get("PVE2_TOKEN_SECRET", "") + if pve2_host and pve2_token_id and pve2_token_secret: + nodes.append(PVENode("pve2", pve2_host, pve2_token_id, pve2_token_secret)) + + # iLO is optional + ilo = None + ilo_host = os.environ.get("ILO_HOST", "") + ilo_user = os.environ.get("ILO_USER", "") + ilo_password = os.environ.get("ILO_PASSWORD", "") + ilo_jump = os.environ.get("ILO_JUMP_HOST", "pve1") + if ilo_host and ilo_user and ilo_password: + ilo = ILOConfig(ilo_host, ilo_user, ilo_password, ilo_jump) + + # SSH is optional + ssh = None + ssh_user = os.environ.get("SSH_USER", "") + ssh_password = os.environ.get("SSH_PASSWORD", "") + if ssh_user and ssh_password: + ssh = SSHConfig(ssh_user, ssh_password) + + verify_ssl = os.getenv("PVE_VERIFY_SSL", "false").lower() == "true" + + # Load infrastructure context + infra_path = Path(os.getenv("INFRA_YAML_PATH", "infrastructure.yaml")) + infrastructure = {} + if infra_path.exists(): + with open(infra_path) as f: + infrastructure = yaml.safe_load(f) or {} + + return cls( + pve_nodes=nodes, + ilo=ilo, + ssh=ssh, + verify_ssl=verify_ssl, + infrastructure=infrastructure, + ) + + def get_node(self, name: str) -> PVENode | None: + for node in self.pve_nodes: + if node.name == name: + return node + return None + + def get_node_host(self, name: str) -> str | None: + node = self.get_node(name) + return node.host if node else None diff --git a/src/tarkamcp/ilo/__init__.py b/src/tarkamcp/ilo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tarkamcp/ilo/client.py b/src/tarkamcp/ilo/client.py new file mode 100644 index 0000000..30ec16f --- /dev/null +++ b/src/tarkamcp/ilo/client.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +import asyncssh +import hpilo + +from ..config import Config + + +_tunnel: asyncssh.SSHClientConnection | None = None +_tunnel_listener: Any = None +_tunnel_local_port: int | None = None + + +class ILOClient: + """HP iLO 4 client that connects through an SSH tunnel via a Proxmox node.""" + + def __init__(self, config: Config) -> None: + self._config = config + + async def _ensure_tunnel(self) -> int: + """Ensure the SSH tunnel to iLO is up and return the local port.""" + global _tunnel, _tunnel_listener, _tunnel_local_port + + if _tunnel is not None and not _tunnel.is_closed() and _tunnel_local_port is not None: + return _tunnel_local_port + + # Clean up old tunnel + if _tunnel_listener is not None: + _tunnel_listener.close() + if _tunnel is not None: + _tunnel.close() + + ilo_cfg = self._config.ilo + if not ilo_cfg: + raise ILONotConfiguredError() + + ssh_cfg = self._config.ssh + if not ssh_cfg: + raise ILOTunnelError( + "SSH credentials are required to tunnel to iLO. " + "Set SSH_USER and SSH_PASSWORD in your .env file." + ) + + # Get jump host details + jump_host = self._config.get_node_host(ilo_cfg.jump_host) + if not jump_host: + raise ILOTunnelError( + f"Jump host '{ilo_cfg.jump_host}' is not configured as a Proxmox node. " + f"Check ILO_JUMP_HOST in your .env file." + ) + + try: + _tunnel = await asyncssh.connect( + jump_host, + username=ssh_cfg.user, + password=ssh_cfg.password, + known_hosts=None, + ) + + # Forward local port to iLO's HTTPS port (443) + _tunnel_listener = await _tunnel.forward_local_port( + "", 0, # Bind to random available port + ilo_cfg.host, 443, + ) + _tunnel_local_port = _tunnel_listener.get_port() + return _tunnel_local_port + + except Exception as e: + _tunnel = None + _tunnel_listener = None + _tunnel_local_port = None + raise ILOTunnelError( + f"Failed to create SSH tunnel to iLO through '{ilo_cfg.jump_host}' ({jump_host}): {e}. " + f"Check that {ilo_cfg.jump_host} is reachable with proxmox_list_nodes first." + ) from e + + async def _call_ilo(self, method: str, **kwargs: Any) -> Any: + """Call an hpilo method through the SSH tunnel. + + python-hpilo is synchronous, so we run it in a thread executor. + """ + local_port = await self._ensure_tunnel() + ilo_cfg = self._config.ilo + if not ilo_cfg: + raise ILONotConfiguredError() + + def _sync_call() -> Any: + ilo = hpilo.Ilo( + f"localhost", + port=local_port, + login=ilo_cfg.user, + password=ilo_cfg.password, + ssl_context=None, # Disable SSL verification for tunneled connection + ) + return getattr(ilo, method)(**kwargs) + + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, _sync_call) + + async def get_server_info(self) -> dict[str, Any]: + try: + product = await self._call_ilo("get_product_name") + serial = await self._call_ilo("get_server_name") + fw = await self._call_ilo("get_fw_version") + return { + "product_name": product, + "server_name": serial, + "firmware": fw, + } + except (ILONotConfiguredError, ILOTunnelError): + raise + except Exception as e: + return {"error": f"Failed to get server info from iLO: {e}"} + + async def get_health(self) -> dict[str, Any]: + try: + health = await self._call_ilo("get_embedded_health") + return health + except (ILONotConfiguredError, ILOTunnelError): + raise + except Exception as e: + return {"error": f"Failed to get health data from iLO: {e}"} + + async def get_power_status(self) -> dict[str, Any]: + try: + status = await self._call_ilo("get_host_power_status") + return {"power_status": status} + except (ILONotConfiguredError, ILOTunnelError): + raise + except Exception as e: + return {"error": f"Failed to get power status from iLO: {e}"} + + async def power_on(self) -> dict[str, Any]: + try: + await self._call_ilo("set_host_power", host_power=True) + return {"action": "power_on", "result": "success"} + except (ILONotConfiguredError, ILOTunnelError): + raise + except Exception as e: + return {"error": f"Failed to power on via iLO: {e}"} + + async def power_off(self, force: bool = False) -> dict[str, Any]: + try: + if force: + await self._call_ilo("set_host_power", host_power=False) + else: + await self._call_ilo("press_pwr_btn") + return {"action": "power_off", "force": force, "result": "success"} + except (ILONotConfiguredError, ILOTunnelError): + raise + except Exception as e: + return {"error": f"Failed to power off via iLO: {e}"} + + async def power_reset(self) -> dict[str, Any]: + try: + await self._call_ilo("reset_server") + return {"action": "power_reset", "result": "success"} + except (ILONotConfiguredError, ILOTunnelError): + raise + except Exception as e: + return {"error": f"Failed to reset server via iLO: {e}"} + + async def get_event_log(self, limit: int = 50) -> dict[str, Any]: + try: + log = await self._call_ilo("get_ilo_event_log") + if isinstance(log, list): + log = log[:limit] + return {"events": log, "total": len(log) if isinstance(log, list) else 0} + except (ILONotConfiguredError, ILOTunnelError): + raise + except Exception as e: + return {"error": f"Failed to get event log from iLO: {e}"} + + +class ILONotConfiguredError(Exception): + def __init__(self) -> None: + super().__init__( + "iLO credentials are not configured. " + "Set ILO_HOST, ILO_USER, and ILO_PASSWORD in your .env file." + ) + + +class ILOTunnelError(Exception): + def __init__(self, message: str) -> None: + super().__init__(message) diff --git a/src/tarkamcp/ilo/tools.py b/src/tarkamcp/ilo/tools.py new file mode 100644 index 0000000..960ffdf --- /dev/null +++ b/src/tarkamcp/ilo/tools.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from .client import ILOClient, ILONotConfiguredError, ILOTunnelError + + +def register_ilo_tools(mcp: FastMCP, ilo_client: ILOClient) -> None: + """Register HP iLO hardware management tools.""" + + @mcp.tool() + async def ilo_server_info() -> dict[str, Any]: + """Get physical server information: model, serial number, firmware versions. + + Use to identify the hardware and check firmware levels. + Connects to iLO 4 through an SSH tunnel via pve1. + If this fails, pve1 may be unreachable -- check with proxmox_list_nodes first. + """ + try: + return await ilo_client.get_server_info() + except (ILONotConfiguredError, ILOTunnelError) as e: + return {"error": str(e)} + + @mcp.tool() + async def ilo_health_status() -> dict[str, Any]: + """Get full hardware health: temperatures, fans, power supplies, disks, memory status. + + Use when diagnosing hardware issues -- overheating, fan failures, disk errors, PSU problems. + This is the most important iLO tool for crash investigation. + Returns detailed sensor readings and component health status. + """ + try: + return await ilo_client.get_health() + except (ILONotConfiguredError, ILOTunnelError) as e: + return {"error": str(e)} + + @mcp.tool() + async def ilo_power_status() -> dict[str, Any]: + """Get the current physical power state of the server (ON/OFF). + + Use to check if the server is physically powered on. + If a Proxmox node is unreachable but power is ON, the issue is likely software. + If power is OFF, use ilo_power_on to start it. + """ + try: + return await ilo_client.get_power_status() + except (ILONotConfiguredError, ILOTunnelError) as e: + return {"error": str(e)} + + @mcp.tool() + async def ilo_power_on() -> dict[str, Any]: + """Power on the physical server via iLO. + + Use when the server is physically powered off and needs to be started. + Check ilo_power_status first to confirm it's actually off. + After powering on, wait 2-3 minutes then check proxmox_list_nodes for the node to appear. + """ + try: + return await ilo_client.power_on() + except (ILONotConfiguredError, ILOTunnelError) as e: + return {"error": str(e)} + + @mcp.tool() + async def ilo_power_off(force: bool = False) -> dict[str, Any]: + """Power off the physical server via iLO. + + Default (force=false): sends an ACPI shutdown signal (clean shutdown, like pressing the power button). + With force=true: immediately cuts power (use only when the server is completely unresponsive). + Try proxmox_vm_stop and ssh_exec_command 'shutdown -h now' before using force power off. + """ + try: + return await ilo_client.power_off(force) + except (ILONotConfiguredError, ILOTunnelError) as e: + return {"error": str(e)} + + @mcp.tool() + async def ilo_power_reset() -> dict[str, Any]: + """Hard reset the physical server via iLO. + + Use as a last resort when the server is completely frozen and doesn't respond to + any software-level reboot commands. Equivalent to pressing the physical reset button. + Try proxmox_vm_restart and ssh_exec_command 'reboot' before using this. + """ + try: + return await ilo_client.power_reset() + except (ILONotConfiguredError, ILOTunnelError) as e: + return {"error": str(e)} + + @mcp.tool() + async def ilo_get_event_log(limit: int = 50) -> dict[str, Any]: + """Get the iLO event log: hardware errors, reboots, power events, component failures. + + Use to investigate past hardware events and find root causes of crashes. + Returns the most recent events (default 50, max 200). + Events include timestamps, severity, and descriptions. + """ + limit = min(limit, 200) + try: + return await ilo_client.get_event_log(limit) + except (ILONotConfiguredError, ILOTunnelError) as e: + return {"error": str(e)} diff --git a/src/tarkamcp/proxmox/__init__.py b/src/tarkamcp/proxmox/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tarkamcp/proxmox/client.py b/src/tarkamcp/proxmox/client.py new file mode 100644 index 0000000..bd4223b --- /dev/null +++ b/src/tarkamcp/proxmox/client.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any + +from proxmoxer import ProxmoxAPI +from requests.exceptions import ConnectionError, Timeout + +from ..config import Config + + +class ProxmoxClient: + """Manages connections to one or more Proxmox VE nodes via API tokens.""" + + def __init__(self, config: Config) -> None: + self._config = config + self._connections: dict[str, ProxmoxAPI] = {} + + def _get_connection(self, node_name: str) -> ProxmoxAPI: + if node_name in self._connections: + return self._connections[node_name] + + pve_node = self._config.get_node(node_name) + if not pve_node: + raise NodeNotFoundError(node_name, [n.name for n in self._config.pve_nodes]) + + conn = ProxmoxAPI( + pve_node.host, + user=pve_node.token_id.split("!")[0], + token_name=pve_node.token_id.split("!")[1], + token_value=pve_node.token_secret, + verify_ssl=self._config.verify_ssl, + ) + self._connections[node_name] = conn + return conn + + def _resolve_node(self, node: str | None) -> str: + """Return the node name, defaulting to pve1 if not specified.""" + if node: + return node + return self._config.pve_nodes[0].name + + def api_call(self, node_name: str, method: str, path: str, **kwargs: Any) -> Any: + """Execute an API call against a Proxmox node. + + Returns the result or a dict with 'error' key on failure. + """ + try: + conn = self._get_connection(node_name) + obj = conn + for part in path.strip("/").split("/"): + obj = getattr(obj, part) + return getattr(obj, method)(**kwargs) + except NodeNotFoundError: + raise + except (ConnectionError, Timeout) as e: + return { + "error": f"Node '{node_name}' is unreachable: {e}. " + "Try ssh_exec_command to access the host directly, " + "or ilo_health_status if the server may be physically down." + } + except Exception as e: + return {"error": f"Proxmox API error on '{node_name}': {e}"} + + def get(self, node_name: str, path: str, **kwargs: Any) -> Any: + return self.api_call(node_name, "get", path, **kwargs) + + def post(self, node_name: str, path: str, **kwargs: Any) -> Any: + return self.api_call(node_name, "post", path, **kwargs) + + def put(self, node_name: str, path: str, **kwargs: Any) -> Any: + return self.api_call(node_name, "put", path, **kwargs) + + def delete(self, node_name: str, path: str, **kwargs: Any) -> Any: + return self.api_call(node_name, "delete", path, **kwargs) + + @property + def configured_nodes(self) -> list[str]: + return [n.name for n in self._config.pve_nodes] + + +class NodeNotFoundError(Exception): + def __init__(self, node: str, available: list[str]) -> None: + self.node = node + self.available = available + super().__init__( + f"Node '{node}' is not configured. " + f"Available nodes: {', '.join(available)}. " + f"Check your .env file." + ) diff --git a/src/tarkamcp/proxmox/monitoring.py b/src/tarkamcp/proxmox/monitoring.py new file mode 100644 index 0000000..15d4c4c --- /dev/null +++ b/src/tarkamcp/proxmox/monitoring.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from .client import ProxmoxClient + + +def register_monitoring_tools(mcp: FastMCP, client: ProxmoxClient) -> None: + """Register all Proxmox monitoring and diagnostic tools.""" + + @mcp.tool() + def proxmox_list_nodes() -> dict[str, Any]: + """List all Proxmox cluster nodes with their status (online/offline). + + Use this as the first step when diagnosing cluster health or checking which nodes are available. + Returns a list of nodes with name, status, CPU usage, memory usage, and uptime. + If a node appears offline, use ilo_health_status to check if it's a hardware issue, + or ssh_exec_command to try reaching it directly. + """ + results = [] + for node_name in client.configured_nodes: + data = client.get(node_name, "nodes") + if isinstance(data, dict) and "error" in data: + results.append({"name": node_name, "status": "unreachable", "error": data["error"]}) + elif isinstance(data, list): + for node in data: + results.append({ + "name": node.get("node"), + "status": node.get("status", "unknown"), + "cpu": round(node.get("cpu", 0) * 100, 1), + "memory_used_gb": round(node.get("mem", 0) / 1073741824, 1), + "memory_total_gb": round(node.get("maxmem", 0) / 1073741824, 1), + "uptime_hours": round(node.get("uptime", 0) / 3600, 1), + }) + else: + results.append({"name": node_name, "status": "unknown", "raw": str(data)}) + return {"nodes": results} + + @mcp.tool() + def proxmox_node_status(node: str) -> dict[str, Any]: + """Get detailed status of a specific Proxmox node: CPU, RAM, disk, uptime, kernel, PVE version. + + Use after proxmox_list_nodes to drill into a specific node. + Provide the node name (e.g., 'pve1'). + Returns detailed resource usage and system information. + """ + data = client.get(node, f"nodes/{node}/status") + if isinstance(data, dict) and "error" in data: + return data + return { + "node": node, + "cpu_cores": data.get("cpuinfo", {}).get("cores"), + "cpu_model": data.get("cpuinfo", {}).get("model"), + "cpu_usage_pct": round(data.get("cpu", 0) * 100, 1), + "memory_used_gb": round(data.get("memory", {}).get("used", 0) / 1073741824, 1), + "memory_total_gb": round(data.get("memory", {}).get("total", 0) / 1073741824, 1), + "swap_used_gb": round(data.get("swap", {}).get("used", 0) / 1073741824, 1), + "swap_total_gb": round(data.get("swap", {}).get("total", 0) / 1073741824, 1), + "rootfs_used_gb": round(data.get("rootfs", {}).get("used", 0) / 1073741824, 1), + "rootfs_total_gb": round(data.get("rootfs", {}).get("total", 0) / 1073741824, 1), + "uptime_hours": round(data.get("uptime", 0) / 3600, 1), + "kernel_version": data.get("kversion"), + "pve_version": data.get("pveversion"), + } + + @mcp.tool() + def proxmox_list_vms(node: str = "") -> dict[str, Any]: + """List all VMs and containers with their status and resource usage. + + Use to get an overview of what's running on the cluster. + Omit 'node' to list VMs across all configured nodes. + Provide a node name (e.g., 'pve1') to list only that node's VMs. + Returns VMID, name, status, type (qemu/lxc), CPU, and memory for each. + """ + target_nodes = [node] if node else client.configured_nodes + all_vms: list[dict[str, Any]] = [] + + for n in target_nodes: + for vm_type in ("qemu", "lxc"): + data = client.get(n, f"nodes/{n}/{vm_type}") + if isinstance(data, dict) and "error" in data: + all_vms.append({"node": n, "type": vm_type, "error": data["error"]}) + continue + if not isinstance(data, list): + continue + for vm in data: + all_vms.append({ + "node": n, + "vmid": vm.get("vmid"), + "name": vm.get("name", ""), + "status": vm.get("status"), + "type": vm_type, + "cpu_usage_pct": round(vm.get("cpu", 0) * 100, 1), + "memory_used_mb": round(vm.get("mem", 0) / 1048576, 0), + "memory_max_mb": round(vm.get("maxmem", 0) / 1048576, 0), + "disk_used_gb": round(vm.get("disk", 0) / 1073741824, 1), + "uptime_hours": round(vm.get("uptime", 0) / 3600, 1), + }) + + all_vms.sort(key=lambda v: v.get("vmid", 0)) + return {"vms": all_vms, "total": len(all_vms)} + + @mcp.tool() + def proxmox_vm_status(node: str, vmid: int) -> dict[str, Any]: + """Get detailed status of a specific VM or container: CPU, RAM, disk I/O, network I/O, uptime. + + Use after proxmox_list_vms to drill into a specific VM. + Provide both the node name and VMID. + Auto-detects whether the target is a QEMU VM or LXC container. + """ + # Try qemu first, then lxc + for vm_type in ("qemu", "lxc"): + data = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/status/current") + if isinstance(data, dict) and "error" in data: + if "does not exist" in str(data.get("error", "")).lower(): + continue + # Real error (network, auth) + return data + if isinstance(data, dict) and data.get("status"): + config_data = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/config") + result: dict[str, Any] = { + "node": node, + "vmid": vmid, + "type": vm_type, + "name": data.get("name", ""), + "status": data.get("status"), + "cpu_usage_pct": round(data.get("cpu", 0) * 100, 1), + "cpus": data.get("cpus"), + "memory_used_mb": round(data.get("mem", 0) / 1048576, 0), + "memory_max_mb": round(data.get("maxmem", 0) / 1048576, 0), + "disk_read_mb": round(data.get("diskread", 0) / 1048576, 1), + "disk_write_mb": round(data.get("diskwrite", 0) / 1048576, 1), + "net_in_mb": round(data.get("netin", 0) / 1048576, 1), + "net_out_mb": round(data.get("netout", 0) / 1048576, 1), + "uptime_hours": round(data.get("uptime", 0) / 3600, 1), + "pid": data.get("pid"), + } + if isinstance(config_data, dict) and "error" not in config_data: + result["config_summary"] = { + "cores": config_data.get("cores"), + "memory_mb": config_data.get("memory"), + "description": config_data.get("description", ""), + } + return result + + return {"error": f"VM/CT {vmid} not found on node '{node}'. Check the VMID and node name."} + + @mcp.tool() + def proxmox_get_logs(node: str, source: str = "syslog", limit: int = 50) -> dict[str, Any]: + """Retrieve system logs from a Proxmox node. + + Use to diagnose system-level issues, crashes, or service failures. + Set 'source' to 'syslog' for system logs or 'tasks' for Proxmox task logs. + Adjust 'limit' to control how many log lines to return (default 50, max 500). + """ + limit = min(limit, 500) + + if source == "tasks": + data = client.get(node, f"nodes/{node}/tasks", limit=limit) + if isinstance(data, dict) and "error" in data: + return data + if isinstance(data, list): + return { + "node": node, + "source": "tasks", + "entries": [ + { + "upid": t.get("upid"), + "type": t.get("type"), + "status": t.get("status"), + "user": t.get("user"), + "starttime": t.get("starttime"), + "endtime": t.get("endtime"), + } + for t in data + ], + } + return {"node": node, "source": "tasks", "entries": [], "raw": str(data)} + + # syslog + data = client.get(node, f"nodes/{node}/syslog", limit=limit) + if isinstance(data, dict) and "error" in data: + return data + if isinstance(data, list): + return { + "node": node, + "source": "syslog", + "lines": [entry.get("t", "") for entry in data], + } + return {"node": node, "source": "syslog", "lines": [], "raw": str(data)} + + @mcp.tool() + def proxmox_get_tasks(node: str = "", limit: int = 20) -> dict[str, Any]: + """List recent Proxmox tasks across the cluster: migrations, backups, VM operations. + + Use to check what operations have been running or to investigate failed tasks. + Omit 'node' to list tasks from all configured nodes. + Returns task type, status, user, and timing for each. + """ + target_nodes = [node] if node else client.configured_nodes + all_tasks: list[dict[str, Any]] = [] + + for n in target_nodes: + data = client.get(n, f"nodes/{n}/tasks", limit=limit) + if isinstance(data, dict) and "error" in data: + all_tasks.append({"node": n, "error": data["error"]}) + continue + if isinstance(data, list): + for t in data: + all_tasks.append({ + "node": n, + "upid": t.get("upid"), + "type": t.get("type"), + "status": t.get("status"), + "user": t.get("user"), + "starttime": t.get("starttime"), + "endtime": t.get("endtime"), + }) + + return {"tasks": all_tasks, "total": len(all_tasks)} diff --git a/src/tarkamcp/proxmox/system.py b/src/tarkamcp/proxmox/system.py new file mode 100644 index 0000000..d82b51a --- /dev/null +++ b/src/tarkamcp/proxmox/system.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import base64 +import time +import uuid +from dataclasses import dataclass, field +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from .client import ProxmoxClient + + +@dataclass +class ExecSession: + exec_id: str + node: str + vmid: int + vm_type: str + command: str + status: str = "running" + stdout: str = "" + stderr: str = "" + exit_code: int | None = None + pid: int | None = None + started_at: float = field(default_factory=time.time) + + +_exec_sessions: dict[str, ExecSession] = {} + + +def _detect_vm_type(client: ProxmoxClient, node: str, vmid: int) -> str | None: + for vm_type in ("qemu", "lxc"): + data = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/status/current") + if isinstance(data, dict) and "error" in data: + continue + if isinstance(data, dict) and data.get("status"): + return vm_type + return None + + +async def _exec_qemu_sync(client: ProxmoxClient, node: str, vmid: int, command: str, timeout: int) -> dict[str, Any]: + """Execute a command in a QEMU VM via Guest Agent, polling until done.""" + import asyncio + import shlex + + # Proxmox agent/exec endpoint expects: command (binary path) + optional arg-N params + parts = shlex.split(command) + exec_kwargs: dict[str, Any] = {"command": parts[0]} + for i, arg in enumerate(parts[1:]): + exec_kwargs[f"arg{i}"] = arg + + # Start the command via QEMU Guest Agent + result = client.post(node, f"nodes/{node}/qemu/{vmid}/agent/exec", **exec_kwargs) + if isinstance(result, dict) and "error" in result: + return result + + pid = result.get("pid") if isinstance(result, dict) else None + if pid is None: + return {"error": f"Failed to start command in VM {vmid}. QEMU Guest Agent may not be running."} + + # Poll for result (async-safe, does not block event loop) + deadline = time.time() + timeout + while time.time() < deadline: + status_data = client.get(node, f"nodes/{node}/qemu/{vmid}/agent/exec-status", pid=pid) + if isinstance(status_data, dict) and "error" in status_data: + return status_data + if isinstance(status_data, dict) and status_data.get("exited"): + stdout = status_data.get("out-data", "") + stderr = status_data.get("err-data", "") + # Proxmox returns base64-encoded output + if status_data.get("out-data-encoding") == "base64" and stdout: + stdout = base64.b64decode(stdout).decode("utf-8", errors="replace") + if status_data.get("err-data-encoding") == "base64" and stderr: + stderr = base64.b64decode(stderr).decode("utf-8", errors="replace") + return { + "stdout": stdout, + "stderr": stderr, + "exit_code": status_data.get("exitcode", -1), + } + await asyncio.sleep(2) + + return { + "stdout": "", + "stderr": "", + "exit_code": None, + "status": "timeout", + "error": f"Command timed out after {timeout}s. Use proxmox_exec_command_async for long-running commands.", + } + + +def _exec_lxc_sync(client: ProxmoxClient, node: str, vmid: int, command: str, timeout: int) -> dict[str, Any]: + """Execute a command in an LXC container via Proxmox API.""" + parts = command.split() + result = client.post( + node, + f"nodes/{node}/lxc/{vmid}/exec", + command=parts, + ) + if isinstance(result, dict) and "error" in result: + return result + + # LXC exec via API may return directly or via a different mechanism + # depending on PVE version. Handle both. + if isinstance(result, dict): + return { + "stdout": result.get("out-data", result.get("data", "")), + "stderr": result.get("err-data", ""), + "exit_code": result.get("exitcode", 0), + } + return {"stdout": str(result), "stderr": "", "exit_code": 0} + + +def register_system_tools(mcp: FastMCP, client: ProxmoxClient) -> None: + """Register Proxmox system administration and command execution tools.""" + + @mcp.tool() + def proxmox_storage_status(node: str = "") -> dict[str, Any]: + """Get storage status across the cluster: usage, type, content types. + + Use to check disk space, storage health, or find available storage. + Omit 'node' to list storage from all configured nodes. + Returns storage name, type (local, nfs, ceph, etc.), usage, and available space. + """ + target_nodes = [node] if node else client.configured_nodes + all_storage: list[dict[str, Any]] = [] + + for n in target_nodes: + data = client.get(n, f"nodes/{n}/storage") + if isinstance(data, dict) and "error" in data: + all_storage.append({"node": n, "error": data["error"]}) + continue + if not isinstance(data, list): + continue + for s in data: + status = client.get(n, f"nodes/{n}/storage/{s['storage']}/status") + used = 0 + total = 0 + if isinstance(status, dict) and "error" not in status: + used = status.get("used", 0) + total = status.get("total", 0) + + all_storage.append({ + "node": n, + "storage": s.get("storage"), + "type": s.get("type"), + "content": s.get("content"), + "enabled": s.get("enabled", 1) == 1, + "used_gb": round(used / 1073741824, 1), + "total_gb": round(total / 1073741824, 1), + "usage_pct": round(used / total * 100, 1) if total > 0 else 0, + }) + + return {"storage": all_storage} + + @mcp.tool() + def proxmox_network_config(node: str) -> dict[str, Any]: + """Get network interface configuration of a Proxmox node. + + Use to inspect network setup: bridges, bonds, VLANs, IP addresses. + Returns all network interfaces with their type, address, and configuration. + """ + data = client.get(node, f"nodes/{node}/network") + if isinstance(data, dict) and "error" in data: + return data + if not isinstance(data, list): + return {"node": node, "interfaces": [], "raw": str(data)} + + interfaces = [] + for iface in data: + interfaces.append({ + "name": iface.get("iface"), + "type": iface.get("type"), + "address": iface.get("address"), + "netmask": iface.get("netmask"), + "gateway": iface.get("gateway"), + "bridge_ports": iface.get("bridge_ports"), + "active": iface.get("active", False), + "method": iface.get("method"), + "cidr": iface.get("cidr"), + }) + + return {"node": node, "interfaces": interfaces} + + @mcp.tool() + async def proxmox_exec_command(node: str, vmid: int, command: str, timeout: int = 60) -> dict[str, Any]: + """Execute a command inside a VM (via QEMU Guest Agent) or container (via lxc exec) and wait for the result. + + Use for short-lived commands that complete within the timeout (default 60s, max 300s). + Returns stdout, stderr, and exit_code. + For long-running commands (apt upgrade, backups, etc.), use proxmox_exec_command_async instead. + For commands on the Proxmox host itself, use ssh_exec_command. + """ + timeout = min(timeout, 300) + vm_type = _detect_vm_type(client, node, vmid) + if not vm_type: + return {"error": f"VM/CT {vmid} not found on node '{node}'. Check VMID and node name."} + + if vm_type == "qemu": + return await _exec_qemu_sync(client, node, vmid, command, timeout) + return _exec_lxc_sync(client, node, vmid, command, timeout) + + @mcp.tool() + def proxmox_exec_command_async(node: str, vmid: int, command: str) -> dict[str, Any]: + """Start a long-running command inside a VM or container and return immediately. + + Use for commands that take more than 60 seconds (apt upgrade, database dumps, file transfers). + Returns an exec_id to track the command. Use proxmox_exec_get_result with that exec_id + to poll for completion and retrieve output. + """ + import shlex + + vm_type = _detect_vm_type(client, node, vmid) + if not vm_type: + return {"error": f"VM/CT {vmid} not found on node '{node}'. Check VMID and node name."} + + exec_id = str(uuid.uuid4())[:8] + session = ExecSession( + exec_id=exec_id, + node=node, + vmid=vmid, + vm_type=vm_type, + command=command, + ) + _exec_sessions[exec_id] = session + + if vm_type == "qemu": + # Start via guest agent with proper arg format + parts = shlex.split(command) + exec_kwargs: dict[str, Any] = {"command": parts[0]} + for i, arg in enumerate(parts[1:]): + exec_kwargs[f"arg{i}"] = arg + result = client.post(node, f"nodes/{node}/qemu/{vmid}/agent/exec", **exec_kwargs) + if isinstance(result, dict) and "error" in result: + session.status = "failed" + session.stderr = str(result["error"]) + return {"exec_id": exec_id, "status": "failed", "error": result["error"]} + session.pid = result.get("pid") if isinstance(result, dict) else None + else: + # LXC -- start in background + parts = shlex.split(command) + result = client.post(node, f"nodes/{node}/lxc/{vmid}/exec", command=parts) + if isinstance(result, dict) and "error" in result: + session.status = "failed" + session.stderr = str(result["error"]) + return {"exec_id": exec_id, "status": "failed", "error": result["error"]} + + return {"exec_id": exec_id, "status": "running", "vmid": vmid, "command": command} + + @mcp.tool() + def proxmox_exec_get_result(exec_id: str) -> dict[str, Any]: + """Get the result of an async command started with proxmox_exec_command_async. + + Provide the exec_id returned by proxmox_exec_command_async. + Returns status (running/completed/failed/timeout), stdout, stderr, and exit_code when done. + Call repeatedly to poll for completion. + """ + session = _exec_sessions.get(exec_id) + if not session: + return {"error": f"No command found with exec_id '{exec_id}'. It may have expired or never existed."} + + if session.status != "running": + return { + "exec_id": exec_id, + "status": session.status, + "stdout": session.stdout, + "stderr": session.stderr, + "exit_code": session.exit_code, + "command": session.command, + } + + # Poll QEMU guest agent + if session.vm_type == "qemu" and session.pid is not None: + status_data = client.get( + session.node, + f"nodes/{session.node}/qemu/{session.vmid}/agent/exec-status", + pid=session.pid, + ) + if isinstance(status_data, dict) and status_data.get("exited"): + stdout = status_data.get("out-data", "") + stderr = status_data.get("err-data", "") + if status_data.get("out-data-encoding") == "base64" and stdout: + stdout = base64.b64decode(stdout).decode("utf-8", errors="replace") + if status_data.get("err-data-encoding") == "base64" and stderr: + stderr = base64.b64decode(stderr).decode("utf-8", errors="replace") + session.status = "completed" + session.stdout = stdout + session.stderr = stderr + session.exit_code = status_data.get("exitcode", -1) + + # Check for timeout (10 min max for async) + if time.time() - session.started_at > 600: + session.status = "timeout" + + return { + "exec_id": exec_id, + "status": session.status, + "stdout": session.stdout, + "stderr": session.stderr, + "exit_code": session.exit_code, + "command": session.command, + "elapsed_seconds": round(time.time() - session.started_at), + } diff --git a/src/tarkamcp/proxmox/vms.py b/src/tarkamcp/proxmox/vms.py new file mode 100644 index 0000000..0bcf85e --- /dev/null +++ b/src/tarkamcp/proxmox/vms.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from .client import ProxmoxClient + + +def _detect_vm_type(client: ProxmoxClient, node: str, vmid: int) -> str | None: + """Detect whether a VMID is a QEMU VM or LXC container.""" + for vm_type in ("qemu", "lxc"): + data = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/status/current") + if isinstance(data, dict) and "error" in data: + continue + if isinstance(data, dict) and data.get("status"): + return vm_type + return None + + +def register_vm_tools(mcp: FastMCP, client: ProxmoxClient) -> None: + """Register all Proxmox VM/CT lifecycle management tools.""" + + @mcp.tool() + def proxmox_vm_start(node: str, vmid: int) -> dict[str, Any]: + """Start a stopped VM or container. + + Use when a VM/CT needs to be powered on. + Provide the node name and VMID. Auto-detects VM vs container. + Returns the task UPID on success for tracking the operation. + """ + vm_type = _detect_vm_type(client, node, vmid) + if not vm_type: + return {"error": f"VM/CT {vmid} not found on node '{node}'. Check VMID and node name."} + + result = client.post(node, f"nodes/{node}/{vm_type}/{vmid}/status/start") + if isinstance(result, dict) and "error" in result: + return result + return {"vmid": vmid, "node": node, "action": "start", "upid": result} + + @mcp.tool() + def proxmox_vm_stop(node: str, vmid: int, force: bool = False) -> dict[str, Any]: + """Stop a running VM or container. + + Use to shut down a VM/CT. Set force=true for an immediate hard stop + (equivalent to pulling the power cord -- use only when a clean shutdown fails). + Default is a clean ACPI shutdown for VMs or clean stop for containers. + """ + vm_type = _detect_vm_type(client, node, vmid) + if not vm_type: + return {"error": f"VM/CT {vmid} not found on node '{node}'. Check VMID and node name."} + + endpoint = "stop" if force else "shutdown" + result = client.post(node, f"nodes/{node}/{vm_type}/{vmid}/status/{endpoint}") + if isinstance(result, dict) and "error" in result: + return result + return {"vmid": vmid, "node": node, "action": endpoint, "force": force, "upid": result} + + @mcp.tool() + def proxmox_vm_restart(node: str, vmid: int) -> dict[str, Any]: + """Restart a running VM or container (clean reboot). + + Use when a VM/CT needs to be rebooted. Sends an ACPI reboot signal for VMs + or a clean restart for containers. If the VM is unresponsive, stop it with force=true first, + then start it again. + """ + vm_type = _detect_vm_type(client, node, vmid) + if not vm_type: + return {"error": f"VM/CT {vmid} not found on node '{node}'. Check VMID and node name."} + + # Use reboot for qemu, restart for lxc + endpoint = "reboot" if vm_type == "qemu" else "restart" + # Proxmox may not have a direct restart for lxc in older versions, try reboot + result = client.post(node, f"nodes/{node}/{vm_type}/{vmid}/status/{endpoint}") + if isinstance(result, dict) and "error" in result: + return result + return {"vmid": vmid, "node": node, "action": "restart", "upid": result} + + @mcp.tool() + def proxmox_vm_create(node: str, vmid: int, vm_type: str = "qemu", config: dict[str, Any] | None = None) -> dict[str, Any]: + """Create a new VM or container on a Proxmox node. + + Use to provision new virtual machines or containers. + Set vm_type to 'qemu' for a VM or 'lxc' for a container. + Pass configuration as a dict (e.g., {"cores": 2, "memory": 4096, "net0": "virtio,bridge=vmbr0"}). + Refer to Proxmox API docs for available config options per VM type. + """ + if vm_type not in ("qemu", "lxc"): + return {"error": f"Invalid vm_type '{vm_type}'. Use 'qemu' for VMs or 'lxc' for containers."} + + create_params = config or {} + result = client.post(node, f"nodes/{node}/{vm_type}", vmid=vmid, **create_params) + if isinstance(result, dict) and "error" in result: + return result + return {"vmid": vmid, "node": node, "type": vm_type, "action": "create", "upid": result} + + @mcp.tool() + def proxmox_vm_clone(node: str, vmid: int, newid: int, name: str = "") -> dict[str, Any]: + """Clone an existing VM or container to create a copy. + + Use to duplicate a VM/CT. Provide the source VMID, the new VMID for the clone, + and optionally a name. The clone inherits the source configuration. + """ + vm_type = _detect_vm_type(client, node, vmid) + if not vm_type: + return {"error": f"VM/CT {vmid} not found on node '{node}'. Check VMID and node name."} + + kwargs: dict[str, Any] = {"newid": newid} + if name: + kwargs["name"] = name + + result = client.post(node, f"nodes/{node}/{vm_type}/{vmid}/clone", **kwargs) + if isinstance(result, dict) and "error" in result: + return result + return { + "source_vmid": vmid, + "new_vmid": newid, + "name": name, + "node": node, + "action": "clone", + "upid": result, + } + + @mcp.tool() + def proxmox_vm_migrate(node: str, vmid: int, target_node: str) -> dict[str, Any]: + """Migrate a VM or container to another Proxmox node. + + Use to move a VM/CT from one node to another (e.g., for maintenance or load balancing). + The VM can be running (live migration) or stopped. + Provide the current node, VMID, and the target node name. + """ + vm_type = _detect_vm_type(client, node, vmid) + if not vm_type: + return {"error": f"VM/CT {vmid} not found on node '{node}'. Check VMID and node name."} + + result = client.post(node, f"nodes/{node}/{vm_type}/{vmid}/migrate", target=target_node) + if isinstance(result, dict) and "error" in result: + return result + return { + "vmid": vmid, + "from_node": node, + "to_node": target_node, + "action": "migrate", + "upid": result, + } + + @mcp.tool() + def proxmox_vm_config(node: str, vmid: int, updates: dict[str, Any] | None = None) -> dict[str, Any]: + """Read or modify the configuration of a VM or container. + + Without 'updates': returns the full current configuration. + With 'updates': applies the provided config changes (e.g., {"memory": 4096, "cores": 4}). + Use to inspect or change VM settings like memory, CPU cores, network, disks, etc. + """ + vm_type = _detect_vm_type(client, node, vmid) + if not vm_type: + return {"error": f"VM/CT {vmid} not found on node '{node}'. Check VMID and node name."} + + if updates is None: + data = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/config") + if isinstance(data, dict) and "error" in data: + return data + return {"vmid": vmid, "node": node, "type": vm_type, "config": data} + + result = client.put(node, f"nodes/{node}/{vm_type}/{vmid}/config", **updates) + if isinstance(result, dict) and "error" in result: + return result + return {"vmid": vmid, "node": node, "action": "config_update", "updates_applied": updates} diff --git a/src/tarkamcp/server.py b/src/tarkamcp/server.py new file mode 100644 index 0000000..ec1e09f --- /dev/null +++ b/src/tarkamcp/server.py @@ -0,0 +1,84 @@ +from mcp.server.fastmcp import FastMCP + +from .config import Config +from .proxmox.client import ProxmoxClient +from .proxmox.monitoring import register_monitoring_tools +from .proxmox.vms import register_vm_tools +from .proxmox.system import register_system_tools +from .ssh.client import SSHClient +from .ssh.tools import register_ssh_tools +from .ilo.client import ILOClient +from .ilo.tools import register_ilo_tools + +config = Config.from_env() +proxmox_client = ProxmoxClient(config) +ssh_client = SSHClient(config) +ilo_client = ILOClient(config) + +mcp = FastMCP( + "tarkamcp", + instructions=( + "TarkaMCP provides tools to manage a Proxmox VE infrastructure. " + "Use proxmox_* tools for VM/CT management and diagnostics, " + "ilo_* tools for hardware management (power, health), " + "and ssh_* tools for direct shell access as fallback. " + "Start with proxmox_list_nodes to see cluster status." + ), +) + + +@mcp.resource("tarkamcp://infrastructure") +def get_infrastructure() -> str: + """Infrastructure context: node topology, naming conventions, and access constraints.""" + if not config.infrastructure: + return "No infrastructure.yaml configured." + + import yaml + + return yaml.dump(config.infrastructure, default_flow_style=False, allow_unicode=True) + + +@mcp.prompt() +def tarkamcp_context() -> str: + """Inject infrastructure context into the conversation for Proxmox management tasks.""" + nodes_info = ", ".join(n.name for n in config.pve_nodes) + ilo_info = "iLO available (via SSH tunnel through pve1)" if config.ilo else "iLO not configured" + ssh_info = "SSH fallback available" if config.ssh else "SSH not configured" + + infra = config.infrastructure + conventions = "" + if infra.get("conventions"): + conventions = "\n".join(f"- {k}: {v}" for k, v in infra["conventions"].items()) + + notes = "" + if infra.get("notes"): + notes = "\n".join(f"- {n}" for n in infra["notes"]) + + return f"""You are managing a Proxmox VE infrastructure with the following topology: + +Nodes: {nodes_info} +Hardware: {ilo_info} +Access: {ssh_info} + +Conventions: +{conventions} + +Notes: +{notes} + +Diagnostic workflow: +1. Check cluster status with proxmox_list_nodes +2. For a specific node, use proxmox_node_status +3. If a node is unreachable via API, try ssh_exec_command on the host +4. If the host is completely unresponsive, use ilo_health_status and ilo_power_status +5. For in-VM issues, use proxmox_exec_command (QEMU Guest Agent) or ssh_exec_command""" + + +# Register tool modules +register_monitoring_tools(mcp, proxmox_client) +register_vm_tools(mcp, proxmox_client) +register_system_tools(mcp, proxmox_client) +if config.ssh: + register_ssh_tools(mcp, ssh_client) +if config.ilo: + register_ilo_tools(mcp, ilo_client) diff --git a/src/tarkamcp/ssh/__init__.py b/src/tarkamcp/ssh/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tarkamcp/ssh/client.py b/src/tarkamcp/ssh/client.py new file mode 100644 index 0000000..e036968 --- /dev/null +++ b/src/tarkamcp/ssh/client.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import asyncio +import time +import uuid +from dataclasses import dataclass, field +from typing import Any + +import asyncssh + +from ..config import Config + + +@dataclass +class SSHExecSession: + exec_id: str + host: str + command: str + status: str = "running" + stdout: str = "" + stderr: str = "" + exit_code: int | None = None + started_at: float = field(default_factory=time.time) + + +_ssh_sessions: dict[str, SSHExecSession] = {} +_connection_cache: dict[str, tuple[asyncssh.SSHClientConnection, float]] = {} +_CONNECTION_TTL = 300 # 5 minutes + + +class SSHClient: + """Async SSH client with host resolution and connection caching.""" + + def __init__(self, config: Config) -> None: + self._config = config + + def resolve_host(self, host: str) -> str: + """Resolve a host identifier to an actual hostname/IP. + + Accepts: + - Node name (pve1, pve2) -> resolved from config + - Numeric VMID (101) -> resolved to 192.168.1.{VMID} via convention + - Direct IP or hostname -> passed through + """ + # Check if it's a configured node name + node_host = self._config.get_node_host(host) + if node_host: + return node_host + + # Check if it's a numeric VMID + if host.isdigit(): + return f"192.168.1.{host}" + + # Direct IP/hostname + return host + + async def _get_connection(self, host: str) -> asyncssh.SSHClientConnection: + """Get or create an SSH connection with caching.""" + resolved = self.resolve_host(host) + + if resolved in _connection_cache: + conn, created_at = _connection_cache[resolved] + if time.time() - created_at < _CONNECTION_TTL: + try: + # Verify connection is still alive + if not conn.is_closed(): + return conn + except Exception: + pass + # Expired or dead connection + try: + conn.close() + except Exception: + pass + del _connection_cache[resolved] + + if not self._config.ssh: + raise SSHNotConfiguredError() + + conn = await asyncssh.connect( + resolved, + username=self._config.ssh.user, + password=self._config.ssh.password, + known_hosts=None, # Accept all host keys (infra is trusted) + ) + _connection_cache[resolved] = (conn, time.time()) + return conn + + async def exec_command(self, host: str, command: str, timeout: int = 60) -> dict[str, Any]: + """Execute a command via SSH and wait for the result.""" + try: + conn = await self._get_connection(host) + result = await asyncio.wait_for( + conn.run(command, check=False), + timeout=timeout, + ) + return { + "stdout": result.stdout or "", + "stderr": result.stderr or "", + "exit_code": result.exit_status, + } + except asyncio.TimeoutError: + return { + "stdout": "", + "stderr": "", + "exit_code": None, + "status": "timeout", + "error": f"Command timed out after {timeout}s. Use ssh_exec_command_async for long-running commands.", + } + except SSHNotConfiguredError: + raise + except Exception as e: + return {"error": f"SSH connection to '{host}' failed: {e}. Check SSH credentials and host accessibility."} + + async def exec_command_async(self, host: str, command: str) -> str: + """Start a long-running command and return an exec_id.""" + exec_id = str(uuid.uuid4())[:8] + session = SSHExecSession(exec_id=exec_id, host=host, command=command) + _ssh_sessions[exec_id] = session + + async def _run() -> None: + try: + conn = await self._get_connection(host) + result = await asyncio.wait_for(conn.run(command, check=False), timeout=600) + session.stdout = result.stdout or "" + session.stderr = result.stderr or "" + session.exit_code = result.exit_status + session.status = "completed" + except asyncio.TimeoutError: + session.status = "timeout" + except Exception as e: + session.status = "failed" + session.stderr = str(e) + + asyncio.create_task(_run()) + return exec_id + + @staticmethod + def get_session(exec_id: str) -> SSHExecSession | None: + return _ssh_sessions.get(exec_id) + + @staticmethod + def list_sessions() -> list[dict[str, Any]]: + return [ + { + "exec_id": s.exec_id, + "host": s.host, + "command": s.command, + "status": s.status, + "elapsed_seconds": round(time.time() - s.started_at), + } + for s in _ssh_sessions.values() + ] + + +class SSHNotConfiguredError(Exception): + def __init__(self) -> None: + super().__init__( + "SSH credentials are not configured. " + "Set SSH_USER and SSH_PASSWORD in your .env file." + ) diff --git a/src/tarkamcp/ssh/tools.py b/src/tarkamcp/ssh/tools.py new file mode 100644 index 0000000..1b4fa61 --- /dev/null +++ b/src/tarkamcp/ssh/tools.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import time +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from .client import SSHClient, SSHNotConfiguredError + + +def register_ssh_tools(mcp: FastMCP, ssh_client: SSHClient) -> None: + """Register SSH command execution tools.""" + + @mcp.tool() + async def ssh_exec_command(host: str, command: str, timeout: int = 60) -> dict[str, Any]: + """Execute a command on a host via SSH and wait for the result. + + Use as a fallback when the Proxmox API is unavailable, or to run commands + directly on a Proxmox host (not inside a VM -- use proxmox_exec_command for that). + 'host' can be a node name (pve1), a VMID (101 -> 192.168.1.101), or a direct IP/hostname. + Timeout defaults to 60s (max 300s). For long commands, use ssh_exec_command_async. + """ + timeout = min(timeout, 300) + try: + return await ssh_client.exec_command(host, command, timeout) + except SSHNotConfiguredError as e: + return {"error": str(e)} + + @mcp.tool() + async def ssh_exec_command_async(host: str, command: str) -> dict[str, Any]: + """Start a long-running SSH command and return immediately with an exec_id. + + Use for commands that take more than 60 seconds (updates, large file operations, etc.). + Returns an exec_id. Use ssh_exec_get_result to poll for completion. + 'host' can be a node name (pve1), a VMID (101), or a direct IP/hostname. + """ + try: + exec_id = await ssh_client.exec_command_async(host, command) + return { + "exec_id": exec_id, + "status": "running", + "host": host, + "resolved_host": ssh_client.resolve_host(host), + "command": command, + } + except SSHNotConfiguredError as e: + return {"error": str(e)} + + @mcp.tool() + def ssh_exec_get_result(exec_id: str) -> dict[str, Any]: + """Get the result of an async SSH command started with ssh_exec_command_async. + + Provide the exec_id returned by ssh_exec_command_async. + Returns status (running/completed/failed/timeout), stdout, stderr, and exit_code. + Call repeatedly to poll for completion. + """ + session = SSHClient.get_session(exec_id) + if not session: + return {"error": f"No SSH command found with exec_id '{exec_id}'."} + return { + "exec_id": exec_id, + "host": session.host, + "command": session.command, + "status": session.status, + "stdout": session.stdout, + "stderr": session.stderr, + "exit_code": session.exit_code, + "elapsed_seconds": round(time.time() - session.started_at) + if session.status == "running" + else None, + } + + @mcp.tool() + def ssh_list_sessions() -> dict[str, Any]: + """List all active and recent SSH command sessions. + + Use to check what SSH commands are running or have completed. + Returns exec_id, host, command, status, and elapsed time for each session. + """ + sessions = SSHClient.list_sessions() + return {"sessions": sessions, "total": len(sessions)} From 4fcc0eb5cb9170b079671c0660d2cb8d1c937e82 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 14:22:16 +0200 Subject: [PATCH 002/155] Add integration test suite and server-side installation README - tests/test_integration.py: ~50 tests across all modules (Proxmox monitoring, system, exec QEMU/LXC, VM lifecycle, SSH, iLO, resources, error handling). Runs against real infrastructure with --section filtering and --test-vmid for destructive VM lifecycle tests. - README.md: full installation guide in French covering server-side setup (API tokens, QEMU Guest Agent, SSH, iLO), .env config, Claude Code integration, tool reference, and troubleshooting. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 444 +++++++++++++++++++++ tests/test_integration.py | 812 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 1256 insertions(+) create mode 100644 README.md create mode 100644 tests/test_integration.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e56ca2 --- /dev/null +++ b/README.md @@ -0,0 +1,444 @@ +# TarkaMCP + +Serveur MCP pour la gestion d'infrastructure Proxmox VE via Claude. Donne a Claude un acces direct a tes noeuds Proxmox, a l'iLO HP, et au SSH pour diagnostiquer, gerer les VMs/CTs, et resoudre les problemes d'infrastructure. + +## Table des matieres + +- [Architecture](#architecture) +- [Installation cote client (ta machine)](#installation-cote-client) +- [Configuration cote serveur (Proxmox)](#configuration-cote-serveur-proxmox) +- [Configuration cote serveur (iLO)](#configuration-cote-serveur-ilo) +- [Configuration du .env](#configuration-du-env) +- [Integration Claude Code](#integration-claude-code) +- [Tests](#tests) +- [Outils disponibles](#outils-disponibles) +- [Depannage](#depannage) + +--- + +## Architecture + +``` +Ta machine (Claude Code) Infrastructure ++----------------------------+ +-----------------------------+ +| | API | | +| TarkaMCP (MCP server) --------> | pve1.example.com :8006 | +| | | SSH | (Proxmox VE) | +| +-- proxmox/ (API) --------> | | +| +-- ssh/ (asyncssh) --------> | pve2.example.com :8006 | +| +-- ilo/ (tunnel) ---+ | (Proxmox VE) | +| | | +-----------------------------+ ++----------------------------+ | + | +-----------------------------+ + +---> | iLO 4 (reseau local) | + tunnel | via pve1 SSH | + SSH +-----------------------------+ +``` + +## Installation cote client + +### Prerequis + +- Python >= 3.11 +- pip +- Acces reseau vers pve1.example.com (port 8006 pour l'API, port 22 pour SSH) + +### Installation + +```bash +cd TarkaMCP +pip install -e . +``` + +### Verification rapide + +```bash +# Avec les variables d'environnement configurees +python -c " +from dotenv import load_dotenv; load_dotenv() +from tarkamcp.server import mcp +print(f'OK: {len(mcp._tool_manager._tools)} outils enregistres') +" +``` + +--- + +## Configuration cote serveur (Proxmox) + +### 1. Creer un API token sur chaque noeud + +Se connecter a l'interface web Proxmox (`https://pve1.example.com`). + +1. Aller dans **Datacenter** > **Permissions** > **API Tokens** +2. Cliquer **Add** +3. Remplir : + - **User** : `root@pam` + - **Token ID** : `tarkamcp` + - **Privilege Separation** : **decocher** (important, sinon le token n'a aucun privilege) +4. Cliquer **Add** +5. **Copier le token secret** affiche (il ne sera plus visible apres) + +Le Token ID complet sera : `root@pam!tarkamcp` + +Repeter sur pve2 quand il sera de retour. + +### 2. Installer le QEMU Guest Agent dans les VMs + +Le Guest Agent est necessaire pour executer des commandes a l'interieur des VMs via l'API Proxmox. + +**Debian/Ubuntu :** +```bash +apt update && apt install -y qemu-guest-agent +systemctl enable --now qemu-guest-agent +``` + +**CentOS/RHEL/AlmaLinux :** +```bash +dnf install -y qemu-guest-agent +systemctl enable --now qemu-guest-agent +``` + +**Verification :** +```bash +systemctl status qemu-guest-agent +# Doit afficher "active (running)" +``` + +Puis dans Proxmox, activer le Guest Agent pour la VM : +1. Aller dans la VM > **Options** > **QEMU Guest Agent** +2. Cocher **Use QEMU Guest Agent** +3. Redemarrer la VM + +**Note :** Le Guest Agent n'est pas necessaire pour les conteneurs LXC -- Proxmox a un acces direct. + +### 3. Configurer l'acces SSH (optionnel mais recommande) + +Le serveur MCP utilise SSH comme fallback quand l'API Proxmox ne suffit pas. L'acces SSH par mot de passe doit etre actif sur les noeuds Proxmox. + +Verifier que c'est le cas : +```bash +# Sur le noeud Proxmox +grep -E "^PasswordAuthentication" /etc/ssh/sshd_config +# Doit afficher: PasswordAuthentication yes +``` + +Si non : +```bash +sed -i 's/^PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config +systemctl restart sshd +``` + +### 4. Verifier les ports ouverts + +Le serveur MCP a besoin de ces acces reseau : + +| Service | Port | Protocole | Depuis | +|---------|------|-----------|--------| +| Proxmox API | 8006 | HTTPS | Ta machine | +| SSH (noeuds) | 22 | SSH | Ta machine | +| iLO | 443 | HTTPS | pve1 (reseau local) | + +--- + +## Configuration cote serveur (iLO) + +L'iLO est sur le reseau local uniquement. TarkaMCP y accede via un tunnel SSH a travers pve1. + +### Prerequis + +- iLO 4 accessible depuis le reseau local de pve1 +- Credentials iLO (par defaut : `Administrator` / mot de passe configure) + +### Trouver l'IP de l'iLO + +Depuis pve1 : +```bash +# Scanner le reseau local pour trouver l'iLO +# L'iLO repond generalement sur le port 443 et 17988 +nmap -sn 192.168.1.0/24 | grep -B2 "HP\|iLO\|Hewlett" + +# Ou si tu connais l'IP, verifier +curl -sk https://192.168.1.X/xmldata?item=All | head -20 +``` + +### Tester l'acces iLO depuis pve1 + +```bash +# Depuis pve1 +curl -sk https://IP_ILO/xmldata?item=All | grep PRODUCT_NAME +# Doit afficher le nom du serveur HP +``` + +--- + +## Configuration du .env + +Copier le template et remplir : + +```bash +cp .env.example .env +``` + +Editer `.env` : + +```env +# OBLIGATOIRE -- PVE1 +PVE1_HOST=pve1.example.com +PVE1_TOKEN_ID=root@pam!tarkamcp +PVE1_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# OPTIONNEL -- PVE2 (quand il sera de retour) +PVE2_HOST=pve2.example.com +PVE2_TOKEN_ID=root@pam!tarkamcp +PVE2_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# OPTIONNEL -- iLO +ILO_HOST=192.168.1.X +ILO_USER=Administrator +ILO_PASSWORD=ton_mot_de_passe_ilo +ILO_JUMP_HOST=pve1 + +# OPTIONNEL -- SSH (recommande) +SSH_USER=root +SSH_PASSWORD=ton_mot_de_passe_root + +# OPTIONS +PVE_VERIFY_SSL=false +``` + +Les modules sont charges conditionnellement : +- **Sans SSH** : les 4 outils `ssh_*` ne sont pas disponibles +- **Sans iLO** : les 7 outils `ilo_*` ne sont pas disponibles +- **Sans PVE2** : les outils fonctionnent mais seul pve1 est interroge + +--- + +## Integration Claude Code + +### Option A : Settings globaux + +Ajouter dans `~/.claude/settings.json` : + +```json +{ + "mcpServers": { + "tarkamcp": { + "command": "python", + "args": ["-m", "tarkamcp"], + "cwd": "/chemin/vers/TarkaMCP" + } + } +} +``` + +Avec cette option, le `.env` doit etre dans le dossier `TarkaMCP/`. + +### Option B : Settings avec variables inline + +```json +{ + "mcpServers": { + "tarkamcp": { + "command": "python", + "args": ["-m", "tarkamcp"], + "cwd": "/chemin/vers/TarkaMCP", + "env": { + "PVE1_HOST": "pve1.example.com", + "PVE1_TOKEN_ID": "root@pam!tarkamcp", + "PVE1_TOKEN_SECRET": "ton-token-secret", + "SSH_USER": "root", + "SSH_PASSWORD": "ton-password", + "ILO_HOST": "192.168.1.X", + "ILO_USER": "Administrator", + "ILO_PASSWORD": "ton-password-ilo", + "ILO_JUMP_HOST": "pve1", + "PVE_VERIFY_SSL": "false" + } + } + } +} +``` + +### Verification dans Claude Code + +Une fois configure, relancer Claude Code et verifier : +``` +> Utilise proxmox_list_nodes pour voir l'etat du cluster +``` + +Claude devrait appeler l'outil et afficher les noeuds. + +--- + +## Contexte infrastructure + +Editer `infrastructure.yaml` pour definir tes conventions. Ce fichier est expose comme ressource MCP (`tarkamcp://infrastructure`) et donne a Claude le contexte de ton infra. + +```yaml +conventions: + vmid_to_ip: "CT VMID corresponds to local IP 192.168.1.{VMID}" + naming: "VMs are prefixed by their role (e.g., web-101, db-102)" + +nodes: + pve1: + host: pve1.example.com + role: "Primary node" + local_network: "192.168.1.0/24" + pve2: + host: pve2.example.com + role: "Secondary node" + +ilo: + host: "192.168.1.X" + access: "Local network only, via SSH tunnel through pve1" + +notes: + - "iLO is accessible only through pve1 as SSH jump host" + - "Zyxel USG 210 is the network gateway (no API)" +``` + +--- + +## Tests + +### Lancer les tests d'integration + +Les tests se lancent contre la vraie infrastructure. Ils necessitent un `.env` rempli. + +```bash +# Tous les tests (sauf lifecycle VM) +python tests/test_integration.py + +# Section par section +python tests/test_integration.py --section proxmox +python tests/test_integration.py --section ssh +python tests/test_integration.py --section ilo + +# Avec tests de lifecycle VM (start/stop/clone -- utilise un VMID de test) +python tests/test_integration.py --test-vmid 9999 +``` + +### Ce que les tests verifient + +| Section | Tests | Description | +|---------|-------|-------------| +| **Proxmox Monitoring** | 12 | list_nodes, node_status, list_vms, vm_status, get_logs, get_tasks | +| **Proxmox System** | 4 | storage_status, network_config | +| **Proxmox Exec (QEMU)** | 5 | exec sync, exit codes, async+poll, invalid VMID | +| **Proxmox Exec (LXC)** | 1 | exec dans un conteneur LXC | +| **VM Lifecycle** | 7 | start, stop, restart, config read/write, clone (necessite --test-vmid) | +| **SSH** | 8 | exec sur pve1, exit codes, host resolution, async+poll, sessions | +| **iLO** | 6 | server_info, health, power_status, event_log | +| **Resources** | 4 | infrastructure resource, prompt, config validation | +| **Error Handling** | 4 | invalid node, VMID, exec_id | + +Total : **~50 tests** + +### Creer une VM de test (optionnel) + +Pour les tests de lifecycle (start/stop/clone), creer une VM legere : + +```bash +# Sur pve1, creer une VM vide VMID 9999 +qm create 9999 --name tarkamcp-test --memory 128 --cores 1 --net0 virtio,bridge=vmbr0 +``` + +Puis lancer : +```bash +python tests/test_integration.py --test-vmid 9999 +``` + +--- + +## Outils disponibles + +### Proxmox -- Monitoring (6 outils) + +| Outil | Description | +|-------|-------------| +| `proxmox_list_nodes` | Liste les noeuds du cluster avec leur statut | +| `proxmox_node_status` | CPU, RAM, disque, uptime, version PVE d'un noeud | +| `proxmox_list_vms` | Liste toutes les VMs/CTs avec statut et ressources | +| `proxmox_vm_status` | Etat detaille d'une VM/CT specifique | +| `proxmox_get_logs` | Logs systeme (syslog) ou taches Proxmox | +| `proxmox_get_tasks` | Taches recentes (migrations, backups, etc.) | + +### Proxmox -- Gestion VMs (7 outils) + +| Outil | Description | +|-------|-------------| +| `proxmox_vm_start` | Demarrer une VM/CT | +| `proxmox_vm_stop` | Arreter une VM/CT (clean ou force) | +| `proxmox_vm_restart` | Redemarrer une VM/CT | +| `proxmox_vm_create` | Creer une nouvelle VM/CT | +| `proxmox_vm_clone` | Cloner une VM/CT existante | +| `proxmox_vm_migrate` | Migrer une VM/CT vers un autre noeud | +| `proxmox_vm_config` | Lire ou modifier la config d'une VM/CT | + +### Proxmox -- Systeme (5 outils) + +| Outil | Description | +|-------|-------------| +| `proxmox_storage_status` | Etat du stockage (local, NFS, CEPH, etc.) | +| `proxmox_network_config` | Configuration reseau du noeud | +| `proxmox_exec_command` | Executer une commande dans une VM/CT (sync) | +| `proxmox_exec_command_async` | Lancer une commande longue (async) | +| `proxmox_exec_get_result` | Recuperer le resultat d'une commande async | + +### SSH (4 outils) + +| Outil | Description | +|-------|-------------| +| `ssh_exec_command` | Commande SSH sur un hote (sync) | +| `ssh_exec_command_async` | Commande SSH longue (async) | +| `ssh_exec_get_result` | Resultat d'une commande SSH async | +| `ssh_list_sessions` | Lister les sessions SSH actives | + +### iLO (7 outils) + +| Outil | Description | +|-------|-------------| +| `ilo_server_info` | Modele, serial, firmware du serveur | +| `ilo_health_status` | Temperatures, ventilateurs, alims, disques, RAM | +| `ilo_power_status` | Etat d'alimentation (ON/OFF) | +| `ilo_power_on` | Allumer le serveur physique | +| `ilo_power_off` | Eteindre le serveur (clean ou force) | +| `ilo_power_reset` | Hard reset du serveur | +| `ilo_get_event_log` | Journal d'evenements iLO | + +--- + +## Depannage + +### "PVE1_HOST, PVE1_TOKEN_ID, and PVE1_TOKEN_SECRET are required" + +Le `.env` n'est pas charge ou les variables ne sont pas definies. Verifier : +```bash +cat .env | grep PVE1 +``` + +### "Node 'pveX' is unreachable" + +Le noeud Proxmox ne repond pas sur le port 8006. Verifier : +```bash +curl -sk https://pve1.example.com:8006/api2/json/version +``` + +### "QEMU Guest Agent may not be running" + +Le Guest Agent n'est pas installe ou pas actif dans la VM. Voir la section [Installer le QEMU Guest Agent](#2-installer-le-qemu-guest-agent-dans-les-vms). + +### "iLO is accessible only through pve1 (SSH tunnel)" + +L'iLO est sur le reseau local. Si pve1 est down, l'iLO est inaccessible. Verifier pve1 d'abord. + +### "SSH connection to 'X' failed" + +Verifier que SSH par mot de passe est actif et que les credentials sont corrects : +```bash +ssh root@pve1.example.com +``` + +### Certificats SSL + +Par defaut `PVE_VERIFY_SSL=false` car Proxmox utilise des certificats self-signed. Si tu as configure des certificats valides (Let's Encrypt), mets `PVE_VERIFY_SSL=true`. diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..9b77578 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,812 @@ +""" +TarkaMCP -- Integration test suite +=================================== + +Run against real infrastructure once services are back online. +Requires a filled .env file with real credentials. + +Usage: + # Run all tests (requires all services: PVE1 + SSH + iLO) + python tests/test_integration.py + + # Run a specific section + python tests/test_integration.py --section proxmox + python tests/test_integration.py --section ssh + python tests/test_integration.py --section ilo + + # Run with a test VM (for destructive tests: start/stop/clone) + python tests/test_integration.py --test-vmid 9999 + +Prerequisites: + - API token created on pve1 (see README.md) + - QEMU Guest Agent installed in at least one VM + - SSH access to pve1 with password auth + - iLO accessible via pve1 tunnel (for iLO tests) +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +import time +from dataclasses import dataclass +from pathlib import Path + +# Ensure the project is importable +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from dotenv import load_dotenv + +load_dotenv(Path(__file__).parent.parent / ".env") + + +# --------------------------------------------------------------------------- +# Test infrastructure +# --------------------------------------------------------------------------- + +@dataclass +class TestResult: + name: str + passed: bool + message: str + data: dict | None = None + + +class TestRunner: + def __init__(self) -> None: + self.results: list[TestResult] = [] + self._section = "" + + def section(self, name: str) -> None: + self._section = name + print(f"\n{'=' * 60}") + print(f" {name}") + print(f"{'=' * 60}") + + def record(self, name: str, passed: bool, message: str, data: dict | None = None) -> None: + full_name = f"[{self._section}] {name}" if self._section else name + result = TestResult(full_name, passed, message, data) + self.results.append(result) + icon = "PASS" if passed else "FAIL" + print(f" [{icon}] {name}") + if not passed: + print(f" {message}") + if data and not passed: + preview = json.dumps(data, indent=2, default=str)[:300] + print(f" {preview}") + + def summary(self) -> None: + passed = sum(1 for r in self.results if r.passed) + failed = sum(1 for r in self.results if not r.passed) + total = len(self.results) + print(f"\n{'=' * 60}") + print(f" RESULTS: {passed}/{total} passed, {failed} failed") + print(f"{'=' * 60}") + if failed: + print("\n Failed tests:") + for r in self.results: + if not r.passed: + print(f" - {r.name}: {r.message}") + print() + + +# --------------------------------------------------------------------------- +# Import tools (done lazily so .env is loaded first) +# --------------------------------------------------------------------------- + +def get_tools() -> dict: + """Import and return all registered MCP tools.""" + from tarkamcp.server import mcp + return mcp._tool_manager._tools + + +def call_tool(tools: dict, name: str, **kwargs): + """Call an MCP tool function directly, handling both sync and async.""" + tool = tools[name] + result = tool.fn(**kwargs) + if asyncio.iscoroutine(result): + result = asyncio.get_event_loop().run_until_complete(result) + return result + + +# --------------------------------------------------------------------------- +# Test sections +# --------------------------------------------------------------------------- + +def test_proxmox_monitoring(runner: TestRunner, tools: dict) -> None: + runner.section("Proxmox Monitoring") + + # T1: List nodes + result = call_tool(tools, "proxmox_list_nodes") + has_nodes = isinstance(result, dict) and "nodes" in result and len(result["nodes"]) > 0 + runner.record( + "proxmox_list_nodes returns nodes", + has_nodes, + "Expected a list of nodes with at least 1 entry", + result, + ) + + if has_nodes: + pve1_node = next((n for n in result["nodes"] if n.get("name") == "pve1"), None) + runner.record( + "pve1 is present in node list", + pve1_node is not None, + "pve1 should appear in the cluster node list", + result, + ) + runner.record( + "pve1 is online", + pve1_node is not None and pve1_node.get("status") == "online", + f"pve1 status: {pve1_node.get('status') if pve1_node else 'missing'}", + pve1_node, + ) + + # T2: Node status + result = call_tool(tools, "proxmox_node_status", node="pve1") + has_cpu = isinstance(result, dict) and "cpu_cores" in result + runner.record( + "proxmox_node_status returns CPU/RAM/disk info", + has_cpu and "memory_total_gb" in result and "rootfs_total_gb" in result, + "Expected cpu_cores, memory_total_gb, rootfs_total_gb", + result, + ) + if has_cpu: + runner.record( + "CPU usage is a percentage (0-100)", + 0 <= result.get("cpu_usage_pct", -1) <= 100, + f"cpu_usage_pct = {result.get('cpu_usage_pct')}", + ) + runner.record( + "Uptime is positive", + result.get("uptime_hours", 0) > 0, + f"uptime_hours = {result.get('uptime_hours')}", + ) + runner.record( + "PVE version is present", + result.get("pve_version") is not None, + f"pve_version = {result.get('pve_version')}", + ) + + # T3: List VMs + result = call_tool(tools, "proxmox_list_vms", node="pve1") + has_vms = isinstance(result, dict) and "vms" in result + runner.record( + "proxmox_list_vms returns VM list", + has_vms, + "Expected a 'vms' key with a list", + result, + ) + if has_vms and len(result["vms"]) > 0: + first_vm = result["vms"][0] + runner.record( + "VMs have required fields (vmid, name, status, type)", + all(k in first_vm for k in ("vmid", "name", "status", "type")), + f"First VM keys: {list(first_vm.keys())}", + first_vm, + ) + + # T4: VM status (use first running VM found) + running_vm = None + if has_vms: + running_vm = next((v for v in result["vms"] if v.get("status") == "running"), None) + if running_vm: + result = call_tool(tools, "proxmox_vm_status", node="pve1", vmid=running_vm["vmid"]) + runner.record( + f"proxmox_vm_status for VMID {running_vm['vmid']} returns details", + isinstance(result, dict) and "status" in result and "cpu_usage_pct" in result, + "Expected status, cpu_usage_pct, memory fields", + result, + ) + else: + runner.record( + "proxmox_vm_status (skipped: no running VM found)", + True, + "Need at least one running VM to test vm_status", + ) + + # T5: Get logs + result = call_tool(tools, "proxmox_get_logs", node="pve1", source="syslog", limit=10) + runner.record( + "proxmox_get_logs (syslog) returns log lines", + isinstance(result, dict) and "lines" in result and len(result.get("lines", [])) > 0, + "Expected non-empty 'lines' list", + result if isinstance(result, dict) and "error" in result else None, + ) + + result = call_tool(tools, "proxmox_get_logs", node="pve1", source="tasks", limit=5) + runner.record( + "proxmox_get_logs (tasks) returns task entries", + isinstance(result, dict) and "entries" in result, + "Expected 'entries' list", + result if isinstance(result, dict) and "error" in result else None, + ) + + # T6: Get tasks + result = call_tool(tools, "proxmox_get_tasks", node="pve1", limit=5) + runner.record( + "proxmox_get_tasks returns task list", + isinstance(result, dict) and "tasks" in result, + "Expected 'tasks' key", + result, + ) + + +def test_proxmox_system(runner: TestRunner, tools: dict) -> None: + runner.section("Proxmox System") + + # T7: Storage status + result = call_tool(tools, "proxmox_storage_status", node="pve1") + has_storage = isinstance(result, dict) and "storage" in result and len(result.get("storage", [])) > 0 + runner.record( + "proxmox_storage_status returns storage list", + has_storage, + "Expected at least one storage entry", + result, + ) + if has_storage: + first = result["storage"][0] + runner.record( + "Storage entries have name/type/usage fields", + all(k in first for k in ("storage", "type", "used_gb", "total_gb")), + f"First storage keys: {list(first.keys())}", + first, + ) + + # T8: Network config + result = call_tool(tools, "proxmox_network_config", node="pve1") + has_ifaces = isinstance(result, dict) and "interfaces" in result and len(result.get("interfaces", [])) > 0 + runner.record( + "proxmox_network_config returns interface list", + has_ifaces, + "Expected at least one network interface", + result, + ) + if has_ifaces: + bridge = next((i for i in result["interfaces"] if i.get("type") == "bridge"), None) + runner.record( + "At least one bridge interface exists", + bridge is not None, + "Proxmox nodes should have at least one bridge (vmbr0)", + ) + + +def test_proxmox_exec(runner: TestRunner, tools: dict) -> None: + runner.section("Proxmox Command Execution (QEMU Guest Agent)") + + # Find a running QEMU VM with guest agent + vms_result = call_tool(tools, "proxmox_list_vms", node="pve1") + running_qemu = None + if isinstance(vms_result, dict) and "vms" in vms_result: + running_qemu = next( + (v for v in vms_result["vms"] + if v.get("status") == "running" and v.get("type") == "qemu"), + None, + ) + + if not running_qemu: + runner.record( + "proxmox_exec_command (skipped: no running QEMU VM)", + True, + "Need a running QEMU VM with guest agent to test exec", + ) + return + + vmid = running_qemu["vmid"] + print(f" Using VMID {vmid} ({running_qemu.get('name', '?')}) for exec tests") + + # T9: Sync exec -- simple command + result = call_tool(tools, "proxmox_exec_command", node="pve1", vmid=vmid, + command="echo TarkaMCP-test", timeout=30) + runner.record( + f"proxmox_exec_command 'echo' in VM {vmid}", + isinstance(result, dict) and "TarkaMCP-test" in result.get("stdout", ""), + f"Expected stdout containing 'TarkaMCP-test', got: {result}", + result, + ) + + # T10: Sync exec -- exit code + result = call_tool(tools, "proxmox_exec_command", node="pve1", vmid=vmid, + command="cat /etc/hostname", timeout=30) + runner.record( + f"proxmox_exec_command 'cat /etc/hostname' returns exit_code 0", + isinstance(result, dict) and result.get("exit_code") == 0, + f"exit_code = {result.get('exit_code')}, stdout = {result.get('stdout', '')[:100]}", + result, + ) + + # T11: Async exec + poll + result = call_tool(tools, "proxmox_exec_command_async", node="pve1", vmid=vmid, + command="sleep 3 && echo async-done") + has_exec_id = isinstance(result, dict) and "exec_id" in result + runner.record( + f"proxmox_exec_command_async returns exec_id", + has_exec_id and result.get("status") == "running", + f"Expected status=running with exec_id", + result, + ) + + if has_exec_id: + exec_id = result["exec_id"] + # Poll until done (max 30s) + deadline = time.time() + 30 + final_result = None + while time.time() < deadline: + final_result = call_tool(tools, "proxmox_exec_get_result", exec_id=exec_id) + if isinstance(final_result, dict) and final_result.get("status") != "running": + break + time.sleep(2) + + runner.record( + f"proxmox_exec_get_result returns completed result", + isinstance(final_result, dict) and final_result.get("status") == "completed", + f"Final status: {final_result.get('status') if final_result else 'none'}", + final_result, + ) + if isinstance(final_result, dict) and final_result.get("status") == "completed": + runner.record( + "Async exec stdout contains 'async-done'", + "async-done" in final_result.get("stdout", ""), + f"stdout = {final_result.get('stdout', '')[:100]}", + ) + + # T12: Exec on non-existent VM + result = call_tool(tools, "proxmox_exec_command", node="pve1", vmid=99999, + command="echo test", timeout=10) + runner.record( + "proxmox_exec_command on invalid VMID returns error", + isinstance(result, dict) and "error" in result, + f"Expected error, got: {result}", + result, + ) + + +def test_proxmox_exec_lxc(runner: TestRunner, tools: dict) -> None: + runner.section("Proxmox Command Execution (LXC)") + + vms_result = call_tool(tools, "proxmox_list_vms", node="pve1") + running_lxc = None + if isinstance(vms_result, dict) and "vms" in vms_result: + running_lxc = next( + (v for v in vms_result["vms"] + if v.get("status") == "running" and v.get("type") == "lxc"), + None, + ) + + if not running_lxc: + runner.record( + "LXC exec (skipped: no running LXC container)", + True, + "Need a running LXC container to test lxc exec", + ) + return + + vmid = running_lxc["vmid"] + print(f" Using CT {vmid} ({running_lxc.get('name', '?')}) for LXC exec tests") + + result = call_tool(tools, "proxmox_exec_command", node="pve1", vmid=vmid, + command="echo LXC-test", timeout=30) + runner.record( + f"proxmox_exec_command 'echo' in CT {vmid}", + isinstance(result, dict) and ("LXC-test" in result.get("stdout", "") or "error" not in result), + f"Result: {result}", + result, + ) + + +def test_proxmox_vm_lifecycle(runner: TestRunner, tools: dict, test_vmid: int | None) -> None: + runner.section("Proxmox VM Lifecycle") + + if not test_vmid: + runner.record( + "VM lifecycle tests (skipped: no --test-vmid provided)", + True, + "Pass --test-vmid to run start/stop/clone/config tests on a sacrificial VM", + ) + return + + print(f" Using VMID {test_vmid} for lifecycle tests") + + # T13: Read VM config + result = call_tool(tools, "proxmox_vm_config", node="pve1", vmid=test_vmid) + runner.record( + f"proxmox_vm_config (read) for VMID {test_vmid}", + isinstance(result, dict) and ("config" in result or "error" in result), + f"Expected config or error", + result, + ) + + if isinstance(result, dict) and "error" in result: + runner.record( + "VM lifecycle tests aborted: test VM not found", + False, + f"VMID {test_vmid} not found. Create it first or use a different --test-vmid.", + result, + ) + return + + vm_type = result.get("type", "qemu") + + # T14: Stop (if running) + status_result = call_tool(tools, "proxmox_vm_status", node="pve1", vmid=test_vmid) + if isinstance(status_result, dict) and status_result.get("status") == "running": + result = call_tool(tools, "proxmox_vm_stop", node="pve1", vmid=test_vmid) + runner.record( + f"proxmox_vm_stop VMID {test_vmid}", + isinstance(result, dict) and "upid" in result, + f"Expected UPID, got: {result}", + result, + ) + print(f" Waiting 10s for VM to stop...") + time.sleep(10) + + # T15: Start + result = call_tool(tools, "proxmox_vm_start", node="pve1", vmid=test_vmid) + runner.record( + f"proxmox_vm_start VMID {test_vmid}", + isinstance(result, dict) and ("upid" in result or "error" in result), + f"Result: {result}", + result, + ) + if isinstance(result, dict) and "upid" in result: + print(f" Waiting 10s for VM to start...") + time.sleep(10) + + # T16: Verify it's running + status_result = call_tool(tools, "proxmox_vm_status", node="pve1", vmid=test_vmid) + runner.record( + f"VMID {test_vmid} is running after start", + isinstance(status_result, dict) and status_result.get("status") == "running", + f"Status: {status_result.get('status') if isinstance(status_result, dict) else status_result}", + status_result, + ) + + # T17: Restart + result = call_tool(tools, "proxmox_vm_restart", node="pve1", vmid=test_vmid) + runner.record( + f"proxmox_vm_restart VMID {test_vmid}", + isinstance(result, dict) and ("upid" in result or "error" in result), + f"Result: {result}", + result, + ) + + # T18: Modify config (change description, harmless) + result = call_tool(tools, "proxmox_vm_config", node="pve1", vmid=test_vmid, + updates={"description": "TarkaMCP test VM - safe to delete"}) + runner.record( + f"proxmox_vm_config (update description) VMID {test_vmid}", + isinstance(result, dict) and ("updates_applied" in result or "error" in result), + f"Result: {result}", + result, + ) + + # T19: Clone (to VMID test_vmid+1000) + clone_id = test_vmid + 1000 + result = call_tool(tools, "proxmox_vm_clone", node="pve1", vmid=test_vmid, + newid=clone_id, name="tarkamcp-test-clone") + runner.record( + f"proxmox_vm_clone {test_vmid} -> {clone_id}", + isinstance(result, dict) and ("upid" in result or "error" in result), + f"Result: {result}", + result, + ) + + # Cleanup: delete the clone if it was created + if isinstance(result, dict) and "upid" in result: + print(f" Waiting 15s for clone to complete...") + time.sleep(15) + # Stop clone if running, then delete + call_tool(tools, "proxmox_vm_stop", node="pve1", vmid=clone_id, force=True) + time.sleep(5) + from tarkamcp.server import proxmox_client + proxmox_client.delete("pve1", f"nodes/pve1/{vm_type}/{clone_id}") + print(f" Cleaned up clone VMID {clone_id}") + + +def test_ssh(runner: TestRunner, tools: dict) -> None: + runner.section("SSH Module") + + if "ssh_exec_command" not in tools: + runner.record( + "SSH tests (skipped: SSH not configured)", + True, + "Set SSH_USER and SSH_PASSWORD in .env to enable SSH tests", + ) + return + + # T20: SSH exec on pve1 + result = call_tool(tools, "ssh_exec_command", host="pve1", command="uptime", timeout=30) + runner.record( + "ssh_exec_command 'uptime' on pve1", + isinstance(result, dict) and result.get("exit_code") == 0 and "load average" in result.get("stdout", ""), + f"Result: {result}", + result, + ) + + # T21: SSH exec -- hostname + result = call_tool(tools, "ssh_exec_command", host="pve1", command="hostname", timeout=15) + runner.record( + "ssh_exec_command 'hostname' on pve1", + isinstance(result, dict) and result.get("exit_code") == 0 and len(result.get("stdout", "").strip()) > 0, + f"stdout = '{result.get('stdout', '').strip()}'", + result, + ) + + # T22: SSH exec -- df (disk usage) + result = call_tool(tools, "ssh_exec_command", host="pve1", command="df -h /", timeout=15) + runner.record( + "ssh_exec_command 'df -h /' on pve1", + isinstance(result, dict) and result.get("exit_code") == 0, + f"exit_code = {result.get('exit_code')}", + result, + ) + + # T23: SSH exec -- failing command + result = call_tool(tools, "ssh_exec_command", host="pve1", + command="cat /nonexistent/file/12345", timeout=15) + runner.record( + "ssh_exec_command on nonexistent file returns non-zero exit code", + isinstance(result, dict) and result.get("exit_code", 0) != 0, + f"exit_code = {result.get('exit_code')}, stderr = {result.get('stderr', '')[:100]}", + result, + ) + + # T24: SSH host resolution -- VMID format + from tarkamcp.server import ssh_client + resolved = ssh_client.resolve_host("101") + runner.record( + "SSH host resolution: VMID '101' -> 192.168.1.101", + resolved == "192.168.1.101", + f"Resolved to: {resolved}", + ) + resolved = ssh_client.resolve_host("pve1") + runner.record( + "SSH host resolution: 'pve1' -> configured host", + resolved == os.environ.get("PVE1_HOST", ""), + f"Resolved to: {resolved}", + ) + + # T25: SSH async exec + poll + result = call_tool(tools, "ssh_exec_command_async", host="pve1", + command="sleep 2 && echo ssh-async-done") + has_id = isinstance(result, dict) and "exec_id" in result + runner.record( + "ssh_exec_command_async returns exec_id", + has_id, + f"Result: {result}", + result, + ) + + if has_id: + exec_id = result["exec_id"] + print(f" Polling exec_id={exec_id}...") + deadline = time.time() + 30 + final = None + while time.time() < deadline: + final = call_tool(tools, "ssh_exec_get_result", exec_id=exec_id) + if isinstance(final, dict) and final.get("status") != "running": + break + time.sleep(2) + runner.record( + "ssh_exec_get_result returns completed", + isinstance(final, dict) and final.get("status") == "completed", + f"Final: {final}", + final, + ) + + # T26: List sessions + result = call_tool(tools, "ssh_list_sessions") + runner.record( + "ssh_list_sessions returns session list", + isinstance(result, dict) and "sessions" in result, + f"Result: {result}", + result, + ) + + +def test_ilo(runner: TestRunner, tools: dict) -> None: + runner.section("iLO Module") + + if "ilo_server_info" not in tools: + runner.record( + "iLO tests (skipped: iLO not configured)", + True, + "Set ILO_HOST, ILO_USER, ILO_PASSWORD in .env to enable iLO tests", + ) + return + + # T27: Server info + result = call_tool(tools, "ilo_server_info") + runner.record( + "ilo_server_info returns server details", + isinstance(result, dict) and ("product_name" in result or "error" in result), + f"Result: {json.dumps(result, default=str)[:200]}", + result, + ) + if isinstance(result, dict) and "error" in result: + runner.record( + "iLO tests aborted: cannot reach iLO", + False, + result["error"], + ) + return + + # T28: Health status + result = call_tool(tools, "ilo_health_status") + runner.record( + "ilo_health_status returns health data", + isinstance(result, dict) and "error" not in result, + f"Result type: {type(result).__name__}, keys: {list(result.keys())[:5] if isinstance(result, dict) else 'N/A'}", + result if isinstance(result, dict) and "error" in result else None, + ) + + # T29: Power status + result = call_tool(tools, "ilo_power_status") + runner.record( + "ilo_power_status returns ON/OFF", + isinstance(result, dict) and "power_status" in result, + f"Result: {result}", + result, + ) + if isinstance(result, dict) and "power_status" in result: + runner.record( + "Server power is ON", + result["power_status"].upper() == "ON", + f"power_status = {result['power_status']}", + ) + + # T30: Event log + result = call_tool(tools, "ilo_get_event_log", limit=10) + runner.record( + "ilo_get_event_log returns events", + isinstance(result, dict) and ("events" in result or "error" in result), + f"Total events: {result.get('total', '?')}", + result if isinstance(result, dict) and "error" in result else None, + ) + + # NOTE: We do NOT test power_on/power_off/power_reset in automated tests + # as these are destructive operations. Test them manually. + runner.record( + "ilo_power_on/off/reset (not tested: destructive)", + True, + "Manual testing required. Use: ilo_power_status to verify state first.", + ) + + +def test_mcp_resources(runner: TestRunner) -> None: + runner.section("MCP Resources & Prompts") + + from tarkamcp.server import mcp, config + + # T31: Infrastructure resource + resource_fn = None + for key, res in mcp._resource_manager._resources.items(): + if "infrastructure" in str(key): + resource_fn = res + break + + runner.record( + "tarkamcp://infrastructure resource is registered", + resource_fn is not None, + "Expected a resource matching 'infrastructure'", + ) + + # T32: Infrastructure YAML is loaded + runner.record( + "infrastructure.yaml is loaded into config", + len(config.infrastructure) > 0, + f"Keys: {list(config.infrastructure.keys()) if config.infrastructure else 'empty'}", + ) + + # T33: Prompt is registered + prompts = mcp._prompt_manager._prompts + runner.record( + "tarkamcp_context prompt is registered", + "tarkamcp_context" in prompts, + f"Available prompts: {list(prompts.keys())}", + ) + + # T34: Config validation + runner.record( + "Config has at least 1 PVE node", + len(config.pve_nodes) >= 1, + f"Nodes: {[n.name for n in config.pve_nodes]}", + ) + runner.record( + "PVE1 host matches expected", + config.pve_nodes[0].host == os.environ.get("PVE1_HOST", ""), + f"Host: {config.pve_nodes[0].host}", + ) + + +def test_error_handling(runner: TestRunner, tools: dict) -> None: + runner.section("Error Handling") + + # T35: Invalid node + result = call_tool(tools, "proxmox_node_status", node="nonexistent-node") + runner.record( + "Invalid node returns actionable error", + isinstance(result, dict) and "error" in result, + f"Result: {result}", + result, + ) + + # T36: Invalid VMID + result = call_tool(tools, "proxmox_vm_status", node="pve1", vmid=99999) + runner.record( + "Invalid VMID returns error (not crash)", + isinstance(result, dict) and "error" in result, + f"Result: {result}", + result, + ) + + # T37: Invalid exec_id + result = call_tool(tools, "proxmox_exec_get_result", exec_id="nonexistent") + runner.record( + "Invalid exec_id returns error", + isinstance(result, dict) and "error" in result, + f"Result: {result}", + result, + ) + + if "ssh_exec_get_result" in tools: + result = call_tool(tools, "ssh_exec_get_result", exec_id="nonexistent") + runner.record( + "Invalid SSH exec_id returns error", + isinstance(result, dict) and "error" in result, + f"Result: {result}", + result, + ) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description="TarkaMCP integration tests") + parser.add_argument("--section", choices=["proxmox", "ssh", "ilo", "all"], default="all", + help="Which section to test") + parser.add_argument("--test-vmid", type=int, default=None, + help="VMID of a sacrificial test VM for lifecycle tests (start/stop/clone)") + args = parser.parse_args() + + runner = TestRunner() + tools = get_tools() + + print(f"\nTarkaMCP Integration Tests") + print(f"Tools registered: {len(tools)}") + print(f"Section: {args.section}") + if args.test_vmid: + print(f"Test VMID: {args.test_vmid}") + + sections = args.section + + if sections in ("proxmox", "all"): + test_proxmox_monitoring(runner, tools) + test_proxmox_system(runner, tools) + test_proxmox_exec(runner, tools) + test_proxmox_exec_lxc(runner, tools) + test_proxmox_vm_lifecycle(runner, tools, args.test_vmid) + test_error_handling(runner, tools) + + if sections in ("ssh", "all"): + test_ssh(runner, tools) + + if sections in ("ilo", "all"): + test_ilo(runner, tools) + + if sections == "all": + test_mcp_resources(runner) + + runner.summary() + + # Exit with error code if any test failed + failed = sum(1 for r in runner.results if not r.passed) + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main() From f9d8accbbebb72fceb8b8148f8bf7582b07baecc Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 14:31:54 +0200 Subject: [PATCH 003/155] Fix missing French accents in README.md Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 176 +++++++++++++++++++++++++++--------------------------- 1 file changed, 88 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 6e56ca2..915001d 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ # TarkaMCP -Serveur MCP pour la gestion d'infrastructure Proxmox VE via Claude. Donne a Claude un acces direct a tes noeuds Proxmox, a l'iLO HP, et au SSH pour diagnostiquer, gerer les VMs/CTs, et resoudre les problemes d'infrastructure. +Serveur MCP pour la gestion d'infrastructure Proxmox VE via Claude. Donne à Claude un accès direct à tes nœuds Proxmox, à l'iLO HP, et au SSH pour diagnostiquer, gérer les VMs/CTs, et résoudre les problèmes d'infrastructure. -## Table des matieres +## Table des matières - [Architecture](#architecture) -- [Installation cote client (ta machine)](#installation-cote-client) -- [Configuration cote serveur (Proxmox)](#configuration-cote-serveur-proxmox) -- [Configuration cote serveur (iLO)](#configuration-cote-serveur-ilo) +- [Installation côté client (ta machine)](#installation-côté-client) +- [Configuration côté serveur (Proxmox)](#configuration-côté-serveur-proxmox) +- [Configuration côté serveur (iLO)](#configuration-côté-serveur-ilo) - [Configuration du .env](#configuration-du-env) -- [Integration Claude Code](#integration-claude-code) +- [Intégration Claude Code](#intégration-claude-code) - [Tests](#tests) - [Outils disponibles](#outils-disponibles) -- [Depannage](#depannage) +- [Dépannage](#dépannage) --- @@ -30,18 +30,18 @@ Ta machine (Claude Code) Infrastructure | | | +-----------------------------+ +----------------------------+ | | +-----------------------------+ - +---> | iLO 4 (reseau local) | + +---> | iLO 4 (réseau local) | tunnel | via pve1 SSH | SSH +-----------------------------+ ``` -## Installation cote client +## Installation côté client -### Prerequis +### Prérequis - Python >= 3.11 - pip -- Acces reseau vers pve1.example.com (port 8006 pour l'API, port 22 pour SSH) +- Accès réseau vers pve1.example.com (port 8006 pour l'API, port 22 pour SSH) ### Installation @@ -50,41 +50,41 @@ cd TarkaMCP pip install -e . ``` -### Verification rapide +### Vérification rapide ```bash -# Avec les variables d'environnement configurees +# Avec les variables d'environnement configurées python -c " from dotenv import load_dotenv; load_dotenv() from tarkamcp.server import mcp -print(f'OK: {len(mcp._tool_manager._tools)} outils enregistres') +print(f'OK: {len(mcp._tool_manager._tools)} outils enregistrés') " ``` --- -## Configuration cote serveur (Proxmox) +## Configuration côté serveur (Proxmox) -### 1. Creer un API token sur chaque noeud +### 1. Créer un API token sur chaque nœud -Se connecter a l'interface web Proxmox (`https://pve1.example.com`). +Se connecter à l'interface web Proxmox (`https://pve1.example.com`). 1. Aller dans **Datacenter** > **Permissions** > **API Tokens** 2. Cliquer **Add** 3. Remplir : - **User** : `root@pam` - **Token ID** : `tarkamcp` - - **Privilege Separation** : **decocher** (important, sinon le token n'a aucun privilege) + - **Privilege Separation** : **décocher** (important, sinon le token n'a aucun privilège) 4. Cliquer **Add** -5. **Copier le token secret** affiche (il ne sera plus visible apres) +5. **Copier le token secret** affiché (il ne sera plus visible après) Le Token ID complet sera : `root@pam!tarkamcp` -Repeter sur pve2 quand il sera de retour. +Répéter sur pve2 quand il sera de retour. ### 2. Installer le QEMU Guest Agent dans les VMs -Le Guest Agent est necessaire pour executer des commandes a l'interieur des VMs via l'API Proxmox. +Le Guest Agent est nécessaire pour exécuter des commandes à l'intérieur des VMs via l'API Proxmox. **Debian/Ubuntu :** ```bash @@ -98,7 +98,7 @@ dnf install -y qemu-guest-agent systemctl enable --now qemu-guest-agent ``` -**Verification :** +**Vérification :** ```bash systemctl status qemu-guest-agent # Doit afficher "active (running)" @@ -107,17 +107,17 @@ systemctl status qemu-guest-agent Puis dans Proxmox, activer le Guest Agent pour la VM : 1. Aller dans la VM > **Options** > **QEMU Guest Agent** 2. Cocher **Use QEMU Guest Agent** -3. Redemarrer la VM +3. Redémarrer la VM -**Note :** Le Guest Agent n'est pas necessaire pour les conteneurs LXC -- Proxmox a un acces direct. +**Note :** Le Guest Agent n'est pas nécessaire pour les conteneurs LXC -- Proxmox a un accès direct. -### 3. Configurer l'acces SSH (optionnel mais recommande) +### 3. Configurer l'accès SSH (optionnel mais recommandé) -Le serveur MCP utilise SSH comme fallback quand l'API Proxmox ne suffit pas. L'acces SSH par mot de passe doit etre actif sur les noeuds Proxmox. +Le serveur MCP utilise SSH comme fallback quand l'API Proxmox ne suffit pas. L'accès SSH par mot de passe doit être actif sur les nœuds Proxmox. -Verifier que c'est le cas : +Vérifier que c'est le cas : ```bash -# Sur le noeud Proxmox +# Sur le nœud Proxmox grep -E "^PasswordAuthentication" /etc/ssh/sshd_config # Doit afficher: PasswordAuthentication yes ``` @@ -128,40 +128,40 @@ sed -i 's/^PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_ systemctl restart sshd ``` -### 4. Verifier les ports ouverts +### 4. Vérifier les ports ouverts -Le serveur MCP a besoin de ces acces reseau : +Le serveur MCP a besoin de ces accès réseau : | Service | Port | Protocole | Depuis | |---------|------|-----------|--------| | Proxmox API | 8006 | HTTPS | Ta machine | -| SSH (noeuds) | 22 | SSH | Ta machine | -| iLO | 443 | HTTPS | pve1 (reseau local) | +| SSH (nœuds) | 22 | SSH | Ta machine | +| iLO | 443 | HTTPS | pve1 (réseau local) | --- -## Configuration cote serveur (iLO) +## Configuration côté serveur (iLO) -L'iLO est sur le reseau local uniquement. TarkaMCP y accede via un tunnel SSH a travers pve1. +L'iLO est sur le réseau local uniquement. TarkaMCP y accède via un tunnel SSH à travers pve1. -### Prerequis +### Prérequis -- iLO 4 accessible depuis le reseau local de pve1 -- Credentials iLO (par defaut : `Administrator` / mot de passe configure) +- iLO 4 accessible depuis le réseau local de pve1 +- Credentials iLO (par défaut : `Administrator` / mot de passe configuré) ### Trouver l'IP de l'iLO Depuis pve1 : ```bash -# Scanner le reseau local pour trouver l'iLO -# L'iLO repond generalement sur le port 443 et 17988 +# Scanner le réseau local pour trouver l'iLO +# L'iLO répond généralement sur le port 443 et 17988 nmap -sn 192.168.1.0/24 | grep -B2 "HP\|iLO\|Hewlett" -# Ou si tu connais l'IP, verifier +# Ou si tu connais l'IP, vérifier curl -sk https://192.168.1.X/xmldata?item=All | head -20 ``` -### Tester l'acces iLO depuis pve1 +### Tester l'accès iLO depuis pve1 ```bash # Depuis pve1 @@ -179,7 +179,7 @@ Copier le template et remplir : cp .env.example .env ``` -Editer `.env` : +Éditer `.env` : ```env # OBLIGATOIRE -- PVE1 @@ -198,7 +198,7 @@ ILO_USER=Administrator ILO_PASSWORD=ton_mot_de_passe_ilo ILO_JUMP_HOST=pve1 -# OPTIONNEL -- SSH (recommande) +# OPTIONNEL -- SSH (recommandé) SSH_USER=root SSH_PASSWORD=ton_mot_de_passe_root @@ -206,14 +206,14 @@ SSH_PASSWORD=ton_mot_de_passe_root PVE_VERIFY_SSL=false ``` -Les modules sont charges conditionnellement : +Les modules sont chargés conditionnellement : - **Sans SSH** : les 4 outils `ssh_*` ne sont pas disponibles - **Sans iLO** : les 7 outils `ilo_*` ne sont pas disponibles -- **Sans PVE2** : les outils fonctionnent mais seul pve1 est interroge +- **Sans PVE2** : les outils fonctionnent mais seul pve1 est interrogé --- -## Integration Claude Code +## Intégration Claude Code ### Option A : Settings globaux @@ -231,7 +231,7 @@ Ajouter dans `~/.claude/settings.json` : } ``` -Avec cette option, le `.env` doit etre dans le dossier `TarkaMCP/`. +Avec cette option, le `.env` doit être dans le dossier `TarkaMCP/`. ### Option B : Settings avec variables inline @@ -259,20 +259,20 @@ Avec cette option, le `.env` doit etre dans le dossier `TarkaMCP/`. } ``` -### Verification dans Claude Code +### Vérification dans Claude Code -Une fois configure, relancer Claude Code et verifier : +Une fois configuré, relancer Claude Code et vérifier : ``` -> Utilise proxmox_list_nodes pour voir l'etat du cluster +> Utilise proxmox_list_nodes pour voir l'état du cluster ``` -Claude devrait appeler l'outil et afficher les noeuds. +Claude devrait appeler l'outil et afficher les nœuds. --- ## Contexte infrastructure -Editer `infrastructure.yaml` pour definir tes conventions. Ce fichier est expose comme ressource MCP (`tarkamcp://infrastructure`) et donne a Claude le contexte de ton infra. +Éditer `infrastructure.yaml` pour définir tes conventions. Ce fichier est exposé comme ressource MCP (`tarkamcp://infrastructure`) et donne à Claude le contexte de ton infra. ```yaml conventions: @@ -301,9 +301,9 @@ notes: ## Tests -### Lancer les tests d'integration +### Lancer les tests d'intégration -Les tests se lancent contre la vraie infrastructure. Ils necessitent un `.env` rempli. +Les tests se lancent contre la vraie infrastructure. Ils nécessitent un `.env` rempli. ```bash # Tous les tests (sauf lifecycle VM) @@ -318,7 +318,7 @@ python tests/test_integration.py --section ilo python tests/test_integration.py --test-vmid 9999 ``` -### Ce que les tests verifient +### Ce que les tests vérifient | Section | Tests | Description | |---------|-------|-------------| @@ -326,7 +326,7 @@ python tests/test_integration.py --test-vmid 9999 | **Proxmox System** | 4 | storage_status, network_config | | **Proxmox Exec (QEMU)** | 5 | exec sync, exit codes, async+poll, invalid VMID | | **Proxmox Exec (LXC)** | 1 | exec dans un conteneur LXC | -| **VM Lifecycle** | 7 | start, stop, restart, config read/write, clone (necessite --test-vmid) | +| **VM Lifecycle** | 7 | start, stop, restart, config read/write, clone (nécessite --test-vmid) | | **SSH** | 8 | exec sur pve1, exit codes, host resolution, async+poll, sessions | | **iLO** | 6 | server_info, health, power_status, event_log | | **Resources** | 4 | infrastructure resource, prompt, config validation | @@ -334,12 +334,12 @@ python tests/test_integration.py --test-vmid 9999 Total : **~50 tests** -### Creer une VM de test (optionnel) +### Créer une VM de test (optionnel) -Pour les tests de lifecycle (start/stop/clone), creer une VM legere : +Pour les tests de lifecycle (start/stop/clone), créer une VM légère : ```bash -# Sur pve1, creer une VM vide VMID 9999 +# Sur pve1, créer une VM vide VMID 9999 qm create 9999 --name tarkamcp-test --memory 128 --cores 1 --net0 virtio,bridge=vmbr0 ``` @@ -356,89 +356,89 @@ python tests/test_integration.py --test-vmid 9999 | Outil | Description | |-------|-------------| -| `proxmox_list_nodes` | Liste les noeuds du cluster avec leur statut | -| `proxmox_node_status` | CPU, RAM, disque, uptime, version PVE d'un noeud | +| `proxmox_list_nodes` | Liste les nœuds du cluster avec leur statut | +| `proxmox_node_status` | CPU, RAM, disque, uptime, version PVE d'un nœud | | `proxmox_list_vms` | Liste toutes les VMs/CTs avec statut et ressources | -| `proxmox_vm_status` | Etat detaille d'une VM/CT specifique | -| `proxmox_get_logs` | Logs systeme (syslog) ou taches Proxmox | -| `proxmox_get_tasks` | Taches recentes (migrations, backups, etc.) | +| `proxmox_vm_status` | État détaillé d'une VM/CT spécifique | +| `proxmox_get_logs` | Logs système (syslog) ou tâches Proxmox | +| `proxmox_get_tasks` | Tâches récentes (migrations, backups, etc.) | ### Proxmox -- Gestion VMs (7 outils) | Outil | Description | |-------|-------------| -| `proxmox_vm_start` | Demarrer une VM/CT | -| `proxmox_vm_stop` | Arreter une VM/CT (clean ou force) | -| `proxmox_vm_restart` | Redemarrer une VM/CT | -| `proxmox_vm_create` | Creer une nouvelle VM/CT | +| `proxmox_vm_start` | Démarrer une VM/CT | +| `proxmox_vm_stop` | Arrêter une VM/CT (clean ou force) | +| `proxmox_vm_restart` | Redémarrer une VM/CT | +| `proxmox_vm_create` | Créer une nouvelle VM/CT | | `proxmox_vm_clone` | Cloner une VM/CT existante | -| `proxmox_vm_migrate` | Migrer une VM/CT vers un autre noeud | +| `proxmox_vm_migrate` | Migrer une VM/CT vers un autre nœud | | `proxmox_vm_config` | Lire ou modifier la config d'une VM/CT | -### Proxmox -- Systeme (5 outils) +### Proxmox -- Système (5 outils) | Outil | Description | |-------|-------------| -| `proxmox_storage_status` | Etat du stockage (local, NFS, CEPH, etc.) | -| `proxmox_network_config` | Configuration reseau du noeud | -| `proxmox_exec_command` | Executer une commande dans une VM/CT (sync) | +| `proxmox_storage_status` | État du stockage (local, NFS, CEPH, etc.) | +| `proxmox_network_config` | Configuration réseau du nœud | +| `proxmox_exec_command` | Exécuter une commande dans une VM/CT (sync) | | `proxmox_exec_command_async` | Lancer une commande longue (async) | -| `proxmox_exec_get_result` | Recuperer le resultat d'une commande async | +| `proxmox_exec_get_result` | Récupérer le résultat d'une commande async | ### SSH (4 outils) | Outil | Description | |-------|-------------| -| `ssh_exec_command` | Commande SSH sur un hote (sync) | +| `ssh_exec_command` | Commande SSH sur un hôte (sync) | | `ssh_exec_command_async` | Commande SSH longue (async) | -| `ssh_exec_get_result` | Resultat d'une commande SSH async | +| `ssh_exec_get_result` | Résultat d'une commande SSH async | | `ssh_list_sessions` | Lister les sessions SSH actives | ### iLO (7 outils) | Outil | Description | |-------|-------------| -| `ilo_server_info` | Modele, serial, firmware du serveur | -| `ilo_health_status` | Temperatures, ventilateurs, alims, disques, RAM | -| `ilo_power_status` | Etat d'alimentation (ON/OFF) | +| `ilo_server_info` | Modèle, serial, firmware du serveur | +| `ilo_health_status` | Températures, ventilateurs, alims, disques, RAM | +| `ilo_power_status` | État d'alimentation (ON/OFF) | | `ilo_power_on` | Allumer le serveur physique | -| `ilo_power_off` | Eteindre le serveur (clean ou force) | +| `ilo_power_off` | Éteindre le serveur (clean ou force) | | `ilo_power_reset` | Hard reset du serveur | -| `ilo_get_event_log` | Journal d'evenements iLO | +| `ilo_get_event_log` | Journal d'événements iLO | --- -## Depannage +## Dépannage ### "PVE1_HOST, PVE1_TOKEN_ID, and PVE1_TOKEN_SECRET are required" -Le `.env` n'est pas charge ou les variables ne sont pas definies. Verifier : +Le `.env` n'est pas chargé ou les variables ne sont pas définies. Vérifier : ```bash cat .env | grep PVE1 ``` ### "Node 'pveX' is unreachable" -Le noeud Proxmox ne repond pas sur le port 8006. Verifier : +Le nœud Proxmox ne répond pas sur le port 8006. Vérifier : ```bash curl -sk https://pve1.example.com:8006/api2/json/version ``` ### "QEMU Guest Agent may not be running" -Le Guest Agent n'est pas installe ou pas actif dans la VM. Voir la section [Installer le QEMU Guest Agent](#2-installer-le-qemu-guest-agent-dans-les-vms). +Le Guest Agent n'est pas installé ou pas actif dans la VM. Voir la section [Installer le QEMU Guest Agent](#2-installer-le-qemu-guest-agent-dans-les-vms). ### "iLO is accessible only through pve1 (SSH tunnel)" -L'iLO est sur le reseau local. Si pve1 est down, l'iLO est inaccessible. Verifier pve1 d'abord. +L'iLO est sur le réseau local. Si pve1 est down, l'iLO est inaccessible. Vérifier pve1 d'abord. ### "SSH connection to 'X' failed" -Verifier que SSH par mot de passe est actif et que les credentials sont corrects : +Vérifier que SSH par mot de passe est actif et que les credentials sont corrects : ```bash ssh root@pve1.example.com ``` ### Certificats SSL -Par defaut `PVE_VERIFY_SSL=false` car Proxmox utilise des certificats self-signed. Si tu as configure des certificats valides (Let's Encrypt), mets `PVE_VERIFY_SSL=true`. +Par défaut `PVE_VERIFY_SSL=false` car Proxmox utilise des certificats self-signed. Si tu as configuré des certificats valides (Let's Encrypt), mets `PVE_VERIFY_SSL=true`. From 499457c22431bd6abeaa280cca298e328c34f5eb Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 14:33:58 +0200 Subject: [PATCH 004/155] Polish README with badges, features section, and usage examples Adds shields.io badges (Python, MCP, Proxmox, iLO, license, Claude Code), a features highlight section, concrete usage examples showing diagnostic workflow, and a license/footer section. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 915001d..39581d6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,34 @@ +
+ # TarkaMCP -Serveur MCP pour la gestion d'infrastructure Proxmox VE via Claude. Donne à Claude un accès direct à tes nœuds Proxmox, à l'iLO HP, et au SSH pour diagnostiquer, gérer les VMs/CTs, et résoudre les problèmes d'infrastructure. +[![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776AB?logo=python&logoColor=white)](https://www.python.org/downloads/) +[![MCP Protocol](https://img.shields.io/badge/MCP-Model_Context_Protocol-5A67D8)](https://modelcontextprotocol.io/) +[![Proxmox VE](https://img.shields.io/badge/Proxmox-VE_8.x-E57000?logo=proxmox&logoColor=white)](https://www.proxmox.com/) +[![HP iLO 4](https://img.shields.io/badge/HP-iLO_4-0096D6?logo=hp&logoColor=white)](https://www.hpe.com/us/en/servers/integrated-lights-out-ilo.html) +[![License](https://img.shields.io/github/license/Showdown76py/TarkaMCP)](LICENSE) +[![Claude Code](https://img.shields.io/badge/Built_with-Claude_Code-F97316)](https://claude.ai/code) + +**Serveur MCP pour la gestion d'infrastructure Proxmox VE via Claude.** + +Donne à Claude un accès direct à tes nœuds Proxmox, à l'iLO HP, et au SSH pour diagnostiquer, gérer les VMs/CTs, et résoudre les problèmes d'infrastructure. + +[Installation](#installation-côté-client) • [Configuration](#configuration-côté-serveur-proxmox) • [Outils](#outils-disponibles) • [Tests](#tests) + +
+ +--- + +### Fonctionnalités + +- **29 outils MCP** répartis en 3 modules (Proxmox, SSH, iLO) +- **Diagnostic automatisé** -- Claude identifie les crashs, vérifie le hardware, propose des résolutions +- **Exécution de commandes** dans les VMs/CTs via QEMU Guest Agent ou SSH, avec support sync et async +- **Gestion hardware à distance** -- power on/off/reset, températures, ventilateurs via iLO 4 +- **Architecture modulaire** -- chaque module se charge uniquement si ses credentials sont configurés +- **Contexte infrastructure** -- fichier YAML exposé comme ressource MCP pour guider Claude + +--- ## Table des matières @@ -442,3 +470,37 @@ ssh root@pve1.example.com ### Certificats SSL Par défaut `PVE_VERIFY_SSL=false` car Proxmox utilise des certificats self-signed. Si tu as configuré des certificats valides (Let's Encrypt), mets `PVE_VERIFY_SSL=true`. + +--- + +## Exemple d'utilisation + +Une fois configuré, tu peux demander à Claude des choses comme : + +> **"pve2 ne répond plus, qu'est-ce qui se passe ?"** +> +> Claude va automatiquement : +> 1. Appeler `proxmox_list_nodes` -- voir que pve2 est offline +> 2. Appeler `ilo_power_status` -- vérifier si le serveur est physiquement allumé +> 3. Appeler `ilo_health_status` -- checker les températures, ventilateurs, disques +> 4. Te proposer un diagnostic et une action (power cycle, vérifier les logs, etc.) + +> **"Combien de RAM utilise la VM 101 ?"** +> +> Claude appelle `proxmox_vm_status(node="pve1", vmid=101)` et te donne les détails. + +> **"Mets à jour les paquets sur tous les conteneurs"** +> +> Claude utilise `proxmox_list_vms` pour lister les CTs, puis `proxmox_exec_command_async` pour lancer `apt update && apt upgrade -y` dans chacun, et poll les résultats avec `proxmox_exec_get_result`. + +--- + +## Licence + +Ce projet est sous licence [Apache 2.0](LICENSE). + +--- + +
+Construit avec Claude Code +
From d03153905c5a395231cc15a07c162e4f471f1324 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 14:52:39 +0200 Subject: [PATCH 005/155] Add multi-platform support: Streamable HTTP transport, auth, deploy - __main__.py: dual transport (stdio + --http mode with Streamable HTTP) Bearer token auth via TARKAMCP_AUTH_TOKEN, /health endpoint - deploy/tarkamcp.service: systemd unit for running on Proxmox nodes - deploy/install.sh: one-command install script with auto-generated token - README: connection instructions for Claude (Code/web/mobile), ChatGPT, Gemini (CLI/API), Cloudflare Tunnel setup, new badges - .env.example: added TARKAMCP_AUTH_TOKEN, PORT, HOST vars Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 6 ++ README.md | 142 +++++++++++++++++++++++++++++---------- deploy/install.sh | 63 +++++++++++++++++ deploy/tarkamcp.service | 15 +++++ src/tarkamcp/__main__.py | 75 ++++++++++++++++++++- 5 files changed, 265 insertions(+), 36 deletions(-) create mode 100755 deploy/install.sh create mode 100644 deploy/tarkamcp.service diff --git a/.env.example b/.env.example index 3c83f0c..73b5139 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,9 @@ PVE_VERIFY_SSL=false # Path to infrastructure.yaml (defaults to ./infrastructure.yaml) # INFRA_YAML_PATH=./infrastructure.yaml + +# HTTP mode (for Claude mobile, ChatGPT, Gemini) +# Generate with: openssl rand -hex 32 +# TARKAMCP_AUTH_TOKEN=your-secret-token-here +# TARKAMCP_PORT=8420 +# TARKAMCP_HOST=0.0.0.0 diff --git a/README.md b/README.md index 39581d6..0a8c04c 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,18 @@ [![MCP Protocol](https://img.shields.io/badge/MCP-Model_Context_Protocol-5A67D8)](https://modelcontextprotocol.io/) [![Proxmox VE](https://img.shields.io/badge/Proxmox-VE_8.x-E57000?logo=proxmox&logoColor=white)](https://www.proxmox.com/) [![HP iLO 4](https://img.shields.io/badge/HP-iLO_4-0096D6?logo=hp&logoColor=white)](https://www.hpe.com/us/en/servers/integrated-lights-out-ilo.html) +[![ChatGPT](https://img.shields.io/badge/ChatGPT-Compatible-74AA9C?logo=openai&logoColor=white)](https://chatgpt.com/) +[![Gemini](https://img.shields.io/badge/Gemini-Compatible-4285F4?logo=google&logoColor=white)](https://gemini.google.com/) [![License](https://img.shields.io/github/license/Showdown76py/TarkaMCP)](LICENSE) [![Claude Code](https://img.shields.io/badge/Built_with-Claude_Code-F97316)](https://claude.ai/code) -**Serveur MCP pour la gestion d'infrastructure Proxmox VE via Claude.** +**Serveur MCP pour la gestion d'infrastructure Proxmox VE.** -Donne à Claude un accès direct à tes nœuds Proxmox, à l'iLO HP, et au SSH pour diagnostiquer, gérer les VMs/CTs, et résoudre les problèmes d'infrastructure. +Compatible **Claude** (Code, web, mobile) • **ChatGPT** • **Gemini** (CLI, API) -[Installation](#installation-côté-client) • [Configuration](#configuration-côté-serveur-proxmox) • [Outils](#outils-disponibles) • [Tests](#tests) +Donne à l'IA un accès direct à tes nœuds Proxmox, à l'iLO HP, et au SSH pour diagnostiquer, gérer les VMs/CTs, et résoudre les problèmes d'infrastructure. + +[Installation](#installation-côté-client) • [Connexion par plateforme](#connexion-par-plateforme) • [Outils](#outils-disponibles) • [Tests](#tests) @@ -26,7 +30,8 @@ Donne à Claude un accès direct à tes nœuds Proxmox, à l'iLO HP, et au SSH p - **Exécution de commandes** dans les VMs/CTs via QEMU Guest Agent ou SSH, avec support sync et async - **Gestion hardware à distance** -- power on/off/reset, températures, ventilateurs via iLO 4 - **Architecture modulaire** -- chaque module se charge uniquement si ses credentials sont configurés -- **Contexte infrastructure** -- fichier YAML exposé comme ressource MCP pour guider Claude +- **Contexte infrastructure** -- fichier YAML exposé comme ressource MCP pour guider l'IA +- **Multi-plateforme** -- stdio (Claude Code, Gemini CLI) + Streamable HTTP (Claude mobile, ChatGPT, Gemini API) --- @@ -34,10 +39,11 @@ Donne à Claude un accès direct à tes nœuds Proxmox, à l'iLO HP, et au SSH p - [Architecture](#architecture) - [Installation côté client (ta machine)](#installation-côté-client) +- [Déploiement remote (serveur)](#déploiement-remote-serveur) +- [Connexion par plateforme](#connexion-par-plateforme) - [Configuration côté serveur (Proxmox)](#configuration-côté-serveur-proxmox) - [Configuration côté serveur (iLO)](#configuration-côté-serveur-ilo) - [Configuration du .env](#configuration-du-env) -- [Intégration Claude Code](#intégration-claude-code) - [Tests](#tests) - [Outils disponibles](#outils-disponibles) - [Dépannage](#dépannage) @@ -63,6 +69,15 @@ Ta machine (Claude Code) Infrastructure SSH +-----------------------------+ ``` +## Deux modes de fonctionnement + +| Mode | Transport | Usage | Commande | +|------|-----------|-------|----------| +| **Local** | stdio | Claude Code, Gemini CLI | `python -m tarkamcp` | +| **Remote** | Streamable HTTP | Claude mobile/web, ChatGPT, Gemini API | `python -m tarkamcp --http` | + +Le mode **local** est pour un usage depuis ta machine. Le mode **remote** expose un serveur HTTP avec authentification par bearer token, accessible depuis n'importe quel client MCP. + ## Installation côté client ### Prérequis @@ -241,9 +256,51 @@ Les modules sont chargés conditionnellement : --- -## Intégration Claude Code +## Déploiement remote (serveur) + +Pour utiliser TarkaMCP depuis Claude mobile, ChatGPT, ou Gemini, il faut le déployer comme serveur HTTP sur ton infrastructure. -### Option A : Settings globaux +### Installation rapide sur pve1 + +```bash +git clone https://github.com/Showdown76py/TarkaMCP.git /opt/tarkamcp +cd /opt/tarkamcp +sudo bash deploy/install.sh +``` + +Le script va : +1. Installer les dépendances Python +2. Créer un `.env` avec un token d'auth généré automatiquement +3. Installer le service systemd + +Ensuite : +```bash +# Éditer le .env avec tes vrais credentials Proxmox +nano /opt/tarkamcp/.env + +# Démarrer le serveur +sudo systemctl start tarkamcp + +# Vérifier +curl http://localhost:8420/health +``` + +### Exposer via Cloudflare Tunnel + +Dans ton dashboard Cloudflare Zero Trust, ajouter un tunnel public : + +| Paramètre | Valeur | +|-----------|--------| +| **Hostname** | `mcp.example.com` (ou ton choix) | +| **Service** | `http://localhost:8420` | + +L'URL de ton serveur MCP sera : `https://mcp.example.com/mcp` + +--- + +## Connexion par plateforme + +### Claude Code (local, stdio) Ajouter dans `~/.claude/settings.json` : @@ -259,42 +316,57 @@ Ajouter dans `~/.claude/settings.json` : } ``` -Avec cette option, le `.env` doit être dans le dossier `TarkaMCP/`. +### Claude (web & mobile) -### Option B : Settings avec variables inline +1. Aller dans **Settings** > **Integrations** > **Add MCP Server** +2. Remplir : + - **URL** : `https://mcp.example.com/mcp` + - **Authentication** : Bearer Token + - **Token** : le token généré lors de l'installation -```json -{ - "mcpServers": { - "tarkamcp": { - "command": "python", - "args": ["-m", "tarkamcp"], - "cwd": "/chemin/vers/TarkaMCP", - "env": { - "PVE1_HOST": "pve1.example.com", - "PVE1_TOKEN_ID": "root@pam!tarkamcp", - "PVE1_TOKEN_SECRET": "ton-token-secret", - "SSH_USER": "root", - "SSH_PASSWORD": "ton-password", - "ILO_HOST": "192.168.1.X", - "ILO_USER": "Administrator", - "ILO_PASSWORD": "ton-password-ilo", - "ILO_JUMP_HOST": "pve1", - "PVE_VERIFY_SSL": "false" - } - } - } -} +### ChatGPT + +1. Aller dans **Settings** > **Developer Mode** > **MCP Servers** +2. Ajouter un serveur : + - **URL** : `https://mcp.example.com/mcp` + - **Auth Header** : `Bearer ` + +### Gemini CLI + +```bash +# Dans la config Gemini CLI, ajouter le serveur MCP +gemini mcp add tarkamcp --url https://mcp.example.com/mcp \ + --header "Authorization: Bearer " +``` + +### Gemini API (programmatique) + +```python +from google import genai + +client = genai.Client() +response = client.models.generate_content( + model="gemini-2.0-flash", + contents="Liste les VMs sur pve1", + config={ + "tools": [{ + "mcp_servers": [{ + "url": "https://mcp.example.com/mcp", + "headers": {"Authorization": "Bearer "}, + }] + }] + }, +) ``` -### Vérification dans Claude Code +### Vérification -Une fois configuré, relancer Claude Code et vérifier : +Depuis n'importe quelle plateforme, demander : ``` -> Utilise proxmox_list_nodes pour voir l'état du cluster +Utilise proxmox_list_nodes pour voir l'état du cluster ``` -Claude devrait appeler l'outil et afficher les nœuds. +L'IA devrait appeler l'outil et afficher les nœuds. --- diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..86bc5ff --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# TarkaMCP - Installation rapide sur un noeud Proxmox +# Usage: curl -sSL ... | bash OU bash deploy/install.sh + +set -e + +INSTALL_DIR="/opt/tarkamcp" +REPO="https://github.com/Showdown76py/TarkaMCP.git" + +echo "=== TarkaMCP - Installation ===" + +# 1. Cloner ou mettre à jour +if [ -d "$INSTALL_DIR" ]; then + echo "[*] Mise à jour de TarkaMCP..." + cd "$INSTALL_DIR" && git pull +else + echo "[*] Clonage de TarkaMCP..." + git clone "$REPO" "$INSTALL_DIR" + cd "$INSTALL_DIR" +fi + +# 2. Installer les dépendances +echo "[*] Installation des dépendances Python..." +pip3 install -e . --quiet + +# 3. Fichier .env +if [ ! -f "$INSTALL_DIR/.env" ]; then + echo "[*] Création du fichier .env..." + cp .env.example .env + + # Générer un token auth automatiquement + TOKEN=$(openssl rand -hex 32) + echo "" >> .env + echo "TARKAMCP_AUTH_TOKEN=$TOKEN" >> .env + + echo "" + echo "=============================================" + echo " Token d'authentification généré :" + echo " $TOKEN" + echo "" + echo " Conserve-le pour configurer tes clients." + echo "=============================================" + echo "" + echo "[!] Édite /opt/tarkamcp/.env pour ajouter tes credentials Proxmox." +else + echo "[*] .env existant conservé." +fi + +# 4. Service systemd +echo "[*] Installation du service systemd..." +cp deploy/tarkamcp.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable tarkamcp + +echo "" +echo "=== Installation terminée ===" +echo "" +echo "Prochaines étapes :" +echo " 1. Éditer /opt/tarkamcp/.env avec tes credentials" +echo " 2. Démarrer le service : systemctl start tarkamcp" +echo " 3. Vérifier : curl http://localhost:8420/health" +echo " 4. Configurer ton tunnel Cloudflare vers localhost:8420" +echo "" diff --git a/deploy/tarkamcp.service b/deploy/tarkamcp.service new file mode 100644 index 0000000..a6168b9 --- /dev/null +++ b/deploy/tarkamcp.service @@ -0,0 +1,15 @@ +[Unit] +Description=TarkaMCP - Proxmox MCP Server (HTTP) +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/tarkamcp +EnvironmentFile=/opt/tarkamcp/.env +ExecStart=/usr/bin/python3 -m tarkamcp --http +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py index 042727e..06efdff 100644 --- a/src/tarkamcp/__main__.py +++ b/src/tarkamcp/__main__.py @@ -1,3 +1,6 @@ +import argparse +import os + from dotenv import load_dotenv # load_dotenv MUST run before importing server, because server.py @@ -8,7 +11,77 @@ def main(): - mcp.run(transport="stdio") + parser = argparse.ArgumentParser(description="TarkaMCP - Proxmox MCP Server") + parser.add_argument( + "--http", action="store_true", + help="Run as Streamable HTTP server (for Claude mobile, ChatGPT, Gemini)", + ) + parser.add_argument( + "--port", type=int, default=int(os.environ.get("TARKAMCP_PORT", "8420")), + help="HTTP port (default: 8420, or TARKAMCP_PORT env var)", + ) + parser.add_argument( + "--host", default=os.environ.get("TARKAMCP_HOST", "0.0.0.0"), + help="HTTP bind address (default: 0.0.0.0)", + ) + args = parser.parse_args() + + if args.http: + # Streamable HTTP mode for remote clients + _run_http(args.host, args.port) + else: + # stdio mode for Claude Code / Gemini CLI + mcp.run(transport="stdio") + + +def _run_http(host: str, port: int): + """Run the MCP server over Streamable HTTP with bearer token auth.""" + import uvicorn + from starlette.applications import Starlette + from starlette.middleware import Middleware + from starlette.requests import Request + from starlette.responses import JSONResponse + from starlette.routing import Mount + + auth_token = os.environ.get("TARKAMCP_AUTH_TOKEN", "") + if not auth_token: + import sys + print( + "ERROR: TARKAMCP_AUTH_TOKEN is required for HTTP mode.\n" + "Generate one with: openssl rand -hex 32", + file=sys.stderr, + ) + sys.exit(1) + + # Get the ASGI app from FastMCP + mcp_app = mcp.streamable_http_app() + + async def auth_middleware(request: Request, call_next): + # Health check endpoint -- no auth needed + if request.url.path == "/health": + return JSONResponse({"status": "ok", "server": "tarkamcp"}) + + # Check bearer token + authorization = request.headers.get("authorization", "") + if not authorization.startswith("Bearer ") or authorization[7:] != auth_token: + return JSONResponse( + {"error": "Invalid or missing bearer token"}, + status_code=401, + ) + return await call_next(request) + + # Wrap MCP app with auth + from starlette.middleware.base import BaseHTTPMiddleware + + app = Starlette( + routes=[Mount("/", app=mcp_app)], + middleware=[Middleware(BaseHTTPMiddleware, dispatch=auth_middleware)], + ) + + print(f"TarkaMCP HTTP server starting on {host}:{port}") + print(f"MCP endpoint: http://{host}:{port}/mcp") + print(f"Health check: http://{host}:{port}/health") + uvicorn.run(app, host=host, port=port, log_level="info") if __name__ == "__main__": From aebbb59886aa9a40abd5379c3080d7b1d9f3ac8c Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 15:08:57 +0200 Subject: [PATCH 006/155] Make bearer token auth optional for Claude web compatibility Claude web's MCP connector doesn't support bearer tokens (only OAuth, which is optional). Auth token is now optional: - If TARKAMCP_AUTH_TOKEN is set: bearer token required (ChatGPT, Gemini) - If not set: open access, rely on Cloudflare Zero Trust for security Updated README with accurate Claude web connector instructions matching the actual "Add custom connector" dialog. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 11 +++++++---- src/tarkamcp/__main__.py | 27 ++++++++++++--------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 0a8c04c..5ef7bfb 100644 --- a/README.md +++ b/README.md @@ -318,11 +318,14 @@ Ajouter dans `~/.claude/settings.json` : ### Claude (web & mobile) -1. Aller dans **Settings** > **Integrations** > **Add MCP Server** +1. Aller dans **Settings** > **Integrations** > **Add custom connector** 2. Remplir : - - **URL** : `https://mcp.example.com/mcp` - - **Authentication** : Bearer Token - - **Token** : le token généré lors de l'installation + - **Name** : `TarkaMCP` + - **Remote MCP server URL** : `https://mcp.example.com/mcp` + - **OAuth Client ID / Secret** : laisser vide (l'auth est gérée par Cloudflare Zero Trust) +3. Cliquer **Add** + +> **Note :** Claude web ne supporte pas les bearer tokens, uniquement OAuth (optionnel). La sécurité repose sur Cloudflare Zero Trust qui protège le tunnel. Ne pas définir `TARKAMCP_AUTH_TOKEN` dans le `.env` du serveur si tu veux que Claude web puisse se connecter. ### ChatGPT diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py index 06efdff..4cd2321 100644 --- a/src/tarkamcp/__main__.py +++ b/src/tarkamcp/__main__.py @@ -44,14 +44,6 @@ def _run_http(host: str, port: int): from starlette.routing import Mount auth_token = os.environ.get("TARKAMCP_AUTH_TOKEN", "") - if not auth_token: - import sys - print( - "ERROR: TARKAMCP_AUTH_TOKEN is required for HTTP mode.\n" - "Generate one with: openssl rand -hex 32", - file=sys.stderr, - ) - sys.exit(1) # Get the ASGI app from FastMCP mcp_app = mcp.streamable_http_app() @@ -61,13 +53,16 @@ async def auth_middleware(request: Request, call_next): if request.url.path == "/health": return JSONResponse({"status": "ok", "server": "tarkamcp"}) - # Check bearer token - authorization = request.headers.get("authorization", "") - if not authorization.startswith("Bearer ") or authorization[7:] != auth_token: - return JSONResponse( - {"error": "Invalid or missing bearer token"}, - status_code=401, - ) + # If a token is configured, enforce bearer auth. + # If no token is set, accept all connections (rely on Cloudflare + # Zero Trust or other external auth for security). + if auth_token: + authorization = request.headers.get("authorization", "") + if not authorization.startswith("Bearer ") or authorization[7:] != auth_token: + return JSONResponse( + {"error": "Invalid or missing bearer token"}, + status_code=401, + ) return await call_next(request) # Wrap MCP app with auth @@ -78,7 +73,9 @@ async def auth_middleware(request: Request, call_next): middleware=[Middleware(BaseHTTPMiddleware, dispatch=auth_middleware)], ) + auth_mode = "bearer token" if auth_token else "open (use Cloudflare Zero Trust)" print(f"TarkaMCP HTTP server starting on {host}:{port}") + print(f"Auth: {auth_mode}") print(f"MCP endpoint: http://{host}:{port}/mcp") print(f"Health check: http://{host}:{port}/health") uvicorn.run(app, host=host, port=port, log_level="info") From c7b6659434fa7f9fff1df0c725ef7ebeb8487c1f Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 15:11:21 +0200 Subject: [PATCH 007/155] Replace bearer token auth with URL-based secret Bearer tokens don't work with Claude web (only supports OAuth, which is overkill for personal use). New approach: the secret is embedded in the URL path itself (/s//mcp), like webhook URLs. - Any request to a path without the secret gets a 404 - The URL is the credential -- treat it like a password - Works with ALL platforms (Claude, ChatGPT, Gemini) with zero auth config - TARKAMCP_SECRET env var replaces TARKAMCP_AUTH_TOKEN Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 5 ++-- README.md | 24 ++++++++------- deploy/install.sh | 14 +++++---- src/tarkamcp/__main__.py | 65 +++++++++++++++++++--------------------- 4 files changed, 55 insertions(+), 53 deletions(-) diff --git a/.env.example b/.env.example index 73b5139..23ea327 100644 --- a/.env.example +++ b/.env.example @@ -24,8 +24,9 @@ PVE_VERIFY_SSL=false # Path to infrastructure.yaml (defaults to ./infrastructure.yaml) # INFRA_YAML_PATH=./infrastructure.yaml -# HTTP mode (for Claude mobile, ChatGPT, Gemini) +# HTTP mode (for Claude mobile/web, ChatGPT, Gemini) +# This secret is part of the URL: https://your-domain/s//mcp # Generate with: openssl rand -hex 32 -# TARKAMCP_AUTH_TOKEN=your-secret-token-here +# TARKAMCP_SECRET=your-secret-here # TARKAMCP_PORT=8420 # TARKAMCP_HOST=0.0.0.0 diff --git a/README.md b/README.md index 5ef7bfb..daa5c72 100644 --- a/README.md +++ b/README.md @@ -316,30 +316,33 @@ Ajouter dans `~/.claude/settings.json` : } ``` +L'authentification est intégrée dans l'URL elle-même (comme un webhook). Le secret généré à l'installation fait partie du chemin : + +``` +https://mcp.example.com/s//mcp +``` + +Traite cette URL comme un mot de passe. Quiconque la possède a accès au serveur. + ### Claude (web & mobile) 1. Aller dans **Settings** > **Integrations** > **Add custom connector** 2. Remplir : - **Name** : `TarkaMCP` - - **Remote MCP server URL** : `https://mcp.example.com/mcp` - - **OAuth Client ID / Secret** : laisser vide (l'auth est gérée par Cloudflare Zero Trust) + - **Remote MCP server URL** : `https://mcp.example.com/s//mcp` + - **OAuth Client ID / Secret** : laisser vide 3. Cliquer **Add** -> **Note :** Claude web ne supporte pas les bearer tokens, uniquement OAuth (optionnel). La sécurité repose sur Cloudflare Zero Trust qui protège le tunnel. Ne pas définir `TARKAMCP_AUTH_TOKEN` dans le `.env` du serveur si tu veux que Claude web puisse se connecter. - ### ChatGPT 1. Aller dans **Settings** > **Developer Mode** > **MCP Servers** 2. Ajouter un serveur : - - **URL** : `https://mcp.example.com/mcp` - - **Auth Header** : `Bearer ` + - **URL** : `https://mcp.example.com/s//mcp` ### Gemini CLI ```bash -# Dans la config Gemini CLI, ajouter le serveur MCP -gemini mcp add tarkamcp --url https://mcp.example.com/mcp \ - --header "Authorization: Bearer " +gemini mcp add tarkamcp --url https://mcp.example.com/s//mcp ``` ### Gemini API (programmatique) @@ -354,8 +357,7 @@ response = client.models.generate_content( config={ "tools": [{ "mcp_servers": [{ - "url": "https://mcp.example.com/mcp", - "headers": {"Authorization": "Bearer "}, + "url": "https://mcp.example.com/s//mcp", }] }] }, diff --git a/deploy/install.sh b/deploy/install.sh index 86bc5ff..ea72fb5 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -28,17 +28,19 @@ if [ ! -f "$INSTALL_DIR/.env" ]; then echo "[*] Création du fichier .env..." cp .env.example .env - # Générer un token auth automatiquement - TOKEN=$(openssl rand -hex 32) + # Générer un secret URL automatiquement + SECRET=$(openssl rand -hex 32) echo "" >> .env - echo "TARKAMCP_AUTH_TOKEN=$TOKEN" >> .env + echo "TARKAMCP_SECRET=$SECRET" >> .env echo "" echo "=============================================" - echo " Token d'authentification généré :" - echo " $TOKEN" + echo " Secret URL généré." echo "" - echo " Conserve-le pour configurer tes clients." + echo " Ton endpoint MCP sera :" + echo " https:///s/$SECRET/mcp" + echo "" + echo " Traite cette URL comme un mot de passe." echo "=============================================" echo "" echo "[!] Édite /opt/tarkamcp/.env pour ajouter tes credentials Proxmox." diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py index 4cd2321..e74357c 100644 --- a/src/tarkamcp/__main__.py +++ b/src/tarkamcp/__main__.py @@ -27,57 +27,54 @@ def main(): args = parser.parse_args() if args.http: - # Streamable HTTP mode for remote clients _run_http(args.host, args.port) else: - # stdio mode for Claude Code / Gemini CLI mcp.run(transport="stdio") def _run_http(host: str, port: int): - """Run the MCP server over Streamable HTTP with bearer token auth.""" + """Run the MCP server over Streamable HTTP with URL-based secret auth.""" + import sys + import uvicorn from starlette.applications import Starlette - from starlette.middleware import Middleware from starlette.requests import Request - from starlette.responses import JSONResponse - from starlette.routing import Mount - - auth_token = os.environ.get("TARKAMCP_AUTH_TOKEN", "") - - # Get the ASGI app from FastMCP + from starlette.responses import JSONResponse, Response + from starlette.routing import Mount, Route + + secret = os.environ.get("TARKAMCP_SECRET", "") + if not secret: + print( + "ERROR: TARKAMCP_SECRET is required for HTTP mode.\n" + "This secret is embedded in the URL to authenticate requests.\n" + "Generate one with: openssl rand -hex 32", + file=sys.stderr, + ) + sys.exit(1) + + # The MCP app from FastMCP mcp_app = mcp.streamable_http_app() - async def auth_middleware(request: Request, call_next): - # Health check endpoint -- no auth needed - if request.url.path == "/health": - return JSONResponse({"status": "ok", "server": "tarkamcp"}) - - # If a token is configured, enforce bearer auth. - # If no token is set, accept all connections (rely on Cloudflare - # Zero Trust or other external auth for security). - if auth_token: - authorization = request.headers.get("authorization", "") - if not authorization.startswith("Bearer ") or authorization[7:] != auth_token: - return JSONResponse( - {"error": "Invalid or missing bearer token"}, - status_code=401, - ) - return await call_next(request) - - # Wrap MCP app with auth - from starlette.middleware.base import BaseHTTPMiddleware + # Health check + async def health(request: Request) -> Response: + return JSONResponse({"status": "ok", "server": "tarkamcp"}) + # The MCP endpoint lives under /s// + # Anyone without the secret gets a 404 -- the URL IS the key. + # Example: https://mcp.example.com/s/a1b2c3d4.../mcp app = Starlette( - routes=[Mount("/", app=mcp_app)], - middleware=[Middleware(BaseHTTPMiddleware, dispatch=auth_middleware)], + routes=[ + Route("/health", health), + Mount(f"/s/{secret}", app=mcp_app), + ], ) - auth_mode = "bearer token" if auth_token else "open (use Cloudflare Zero Trust)" + public_path = f"/s/{secret}/mcp" print(f"TarkaMCP HTTP server starting on {host}:{port}") - print(f"Auth: {auth_mode}") - print(f"MCP endpoint: http://{host}:{port}/mcp") + print(f"MCP endpoint: http://{host}:{port}{public_path}") print(f"Health check: http://{host}:{port}/health") + print(f"\nClients should use the full URL including the secret path.") + print(f"Treat this URL like a password -- anyone with it has access.") uvicorn.run(app, host=host, port=port, log_level="info") From 65db6e95ed4b27fbb03dbc8a6c99c0d9a85bfc71 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 15:15:09 +0200 Subject: [PATCH 008/155] Replace URL secret with proper OAuth 2.1 client credentials Implements a real OAuth 2.1 client_credentials flow: - CLI: `tarkamcp auth create/list/revoke` to manage client credentials - OAuth metadata at /.well-known/oauth-authorization-server - Token endpoint at POST /oauth/token (client_credentials grant) - Bearer token validation on all MCP endpoints - Tokens expire after 24h, clients re-authenticate automatically Works with all platforms: - Claude web/mobile: Client ID + Secret in the OAuth fields - ChatGPT/Gemini: POST /oauth/token to get a bearer token - Claude Code: unchanged (stdio, no auth needed) New files: - src/tarkamcp/auth.py: ClientStore (JSON) + TokenStore (in-memory) - Updated __main__.py with auth subcommands and OAuth endpoints Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 6 +- README.md | 77 ++++++++++---- deploy/install.sh | 23 ++-- src/tarkamcp/__main__.py | 225 +++++++++++++++++++++++++++++++++------ src/tarkamcp/auth.py | 139 ++++++++++++++++++++++++ 5 files changed, 398 insertions(+), 72 deletions(-) create mode 100644 src/tarkamcp/auth.py diff --git a/.env.example b/.env.example index 23ea327..fccb707 100644 --- a/.env.example +++ b/.env.example @@ -25,8 +25,8 @@ PVE_VERIFY_SSL=false # INFRA_YAML_PATH=./infrastructure.yaml # HTTP mode (for Claude mobile/web, ChatGPT, Gemini) -# This secret is part of the URL: https://your-domain/s//mcp -# Generate with: openssl rand -hex 32 -# TARKAMCP_SECRET=your-secret-here +# Clients are managed via: tarkamcp auth create --name "My Client" +# Client credentials are stored in clients.json +# TARKAMCP_CLIENTS_FILE=/opt/tarkamcp/clients.json # TARKAMCP_PORT=8420 # TARKAMCP_HOST=0.0.0.0 diff --git a/README.md b/README.md index daa5c72..5a9d08e 100644 --- a/README.md +++ b/README.md @@ -268,23 +268,41 @@ cd /opt/tarkamcp sudo bash deploy/install.sh ``` -Le script va : -1. Installer les dépendances Python -2. Créer un `.env` avec un token d'auth généré automatiquement -3. Installer le service systemd +Le script installe les dépendances, crée le `.env`, et configure le service systemd. Ensuite : ```bash -# Éditer le .env avec tes vrais credentials Proxmox +# 1. Éditer le .env avec tes vrais credentials Proxmox nano /opt/tarkamcp/.env -# Démarrer le serveur +# 2. Créer un client OAuth (pour se connecter depuis Claude/ChatGPT/Gemini) +tarkamcp auth create --name "Mon iPhone" +# → Client ID: tarkamcp_abc123... +# → Client Secret: sk_def456... +# Note-les, le secret ne sera plus affiché. + +# 3. Démarrer le serveur sudo systemctl start tarkamcp -# Vérifier +# 4. Vérifier curl http://localhost:8420/health ``` +### Gérer les clients + +```bash +# Créer un client par appareil / plateforme +tarkamcp auth create --name "Claude Web" +tarkamcp auth create --name "ChatGPT" +tarkamcp auth create --name "Gemini" + +# Lister les clients existants +tarkamcp auth list + +# Révoquer un accès +tarkamcp auth revoke tarkamcp_abc123... +``` + ### Exposer via Cloudflare Tunnel Dans ton dashboard Cloudflare Zero Trust, ajouter un tunnel public : @@ -316,40 +334,58 @@ Ajouter dans `~/.claude/settings.json` : } ``` -L'authentification est intégrée dans l'URL elle-même (comme un webhook). Le secret généré à l'installation fait partie du chemin : - -``` -https://mcp.example.com/s//mcp -``` - -Traite cette URL comme un mot de passe. Quiconque la possède a accès au serveur. +L'authentification utilise **OAuth 2.1 client credentials**. Tu crées un client sur le serveur (`tarkamcp auth create`), et tu utilises le Client ID + Secret pour te connecter depuis n'importe quelle plateforme. ### Claude (web & mobile) 1. Aller dans **Settings** > **Integrations** > **Add custom connector** 2. Remplir : - **Name** : `TarkaMCP` - - **Remote MCP server URL** : `https://mcp.example.com/s//mcp` - - **OAuth Client ID / Secret** : laisser vide + - **Remote MCP server URL** : `https://mcp.example.com/mcp` + - **OAuth Client ID** : `tarkamcp_abc123...` (obtenu via `tarkamcp auth create`) + - **OAuth Client Secret** : `sk_def456...` 3. Cliquer **Add** ### ChatGPT 1. Aller dans **Settings** > **Developer Mode** > **MCP Servers** -2. Ajouter un serveur : - - **URL** : `https://mcp.example.com/s//mcp` +2. Ajouter un serveur avec l'URL : `https://mcp.example.com/mcp` +3. Pour l'auth, obtenir un bearer token : + ```bash + curl -X POST https://mcp.example.com/oauth/token \ + -d "grant_type=client_credentials&client_id=tarkamcp_abc123&client_secret=sk_def456" + # → {"access_token": "xxxx", "token_type": "bearer", ...} + ``` +4. Utiliser l'`access_token` comme bearer token ### Gemini CLI ```bash -gemini mcp add tarkamcp --url https://mcp.example.com/s//mcp +# Obtenir un token +TOKEN=$(curl -s -X POST https://mcp.example.com/oauth/token \ + -d "grant_type=client_credentials&client_id=tarkamcp_abc123&client_secret=sk_def456" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + +# Ajouter le serveur MCP +gemini mcp add tarkamcp --url https://mcp.example.com/mcp \ + --header "Authorization: Bearer $TOKEN" ``` ### Gemini API (programmatique) ```python +import requests from google import genai +# 1. Obtenir un token +resp = requests.post("https://mcp.example.com/oauth/token", data={ + "grant_type": "client_credentials", + "client_id": "tarkamcp_abc123", + "client_secret": "sk_def456", +}) +token = resp.json()["access_token"] + +# 2. Utiliser avec Gemini client = genai.Client() response = client.models.generate_content( model="gemini-2.0-flash", @@ -357,7 +393,8 @@ response = client.models.generate_content( config={ "tools": [{ "mcp_servers": [{ - "url": "https://mcp.example.com/s//mcp", + "url": "https://mcp.example.com/mcp", + "headers": {"Authorization": f"Bearer {token}"}, }] }] }, diff --git a/deploy/install.sh b/deploy/install.sh index ea72fb5..79a9bdd 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -28,20 +28,8 @@ if [ ! -f "$INSTALL_DIR/.env" ]; then echo "[*] Création du fichier .env..." cp .env.example .env - # Générer un secret URL automatiquement - SECRET=$(openssl rand -hex 32) - echo "" >> .env - echo "TARKAMCP_SECRET=$SECRET" >> .env - - echo "" - echo "=============================================" - echo " Secret URL généré." - echo "" - echo " Ton endpoint MCP sera :" - echo " https:///s/$SECRET/mcp" echo "" - echo " Traite cette URL comme un mot de passe." - echo "=============================================" + echo " .env créé. Remplis-le avec tes credentials Proxmox." echo "" echo "[!] Édite /opt/tarkamcp/.env pour ajouter tes credentials Proxmox." else @@ -58,8 +46,9 @@ echo "" echo "=== Installation terminée ===" echo "" echo "Prochaines étapes :" -echo " 1. Éditer /opt/tarkamcp/.env avec tes credentials" -echo " 2. Démarrer le service : systemctl start tarkamcp" -echo " 3. Vérifier : curl http://localhost:8420/health" -echo " 4. Configurer ton tunnel Cloudflare vers localhost:8420" +echo " 1. Éditer /opt/tarkamcp/.env avec tes credentials Proxmox" +echo " 2. Créer un client : tarkamcp auth create --name 'Claude Web'" +echo " 3. Démarrer le service : systemctl start tarkamcp" +echo " 4. Vérifier : curl http://localhost:8420/health" +echo " 5. Configurer ton tunnel Cloudflare vers localhost:8420" echo "" diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py index e74357c..60576e8 100644 --- a/src/tarkamcp/__main__.py +++ b/src/tarkamcp/__main__.py @@ -1,5 +1,7 @@ import argparse import os +import sys +from pathlib import Path from dotenv import load_dotenv @@ -7,74 +9,233 @@ # triggers Config.from_env() at import time load_dotenv() -from .server import mcp # noqa: E402 - def main(): parser = argparse.ArgumentParser(description="TarkaMCP - Proxmox MCP Server") - parser.add_argument( + sub = parser.add_subparsers(dest="command") + + # --- serve (default) --- + serve_parser = sub.add_parser("serve", help="Start the MCP server") + serve_parser.add_argument( "--http", action="store_true", help="Run as Streamable HTTP server (for Claude mobile, ChatGPT, Gemini)", ) - parser.add_argument( + serve_parser.add_argument( "--port", type=int, default=int(os.environ.get("TARKAMCP_PORT", "8420")), - help="HTTP port (default: 8420, or TARKAMCP_PORT env var)", + help="HTTP port (default: 8420)", ) - parser.add_argument( + serve_parser.add_argument( "--host", default=os.environ.get("TARKAMCP_HOST", "0.0.0.0"), help="HTTP bind address (default: 0.0.0.0)", ) + + # --- auth create --- + auth_parser = sub.add_parser("auth", help="Manage OAuth client credentials") + auth_sub = auth_parser.add_subparsers(dest="auth_command") + + create_parser = auth_sub.add_parser("create", help="Create a new client") + create_parser.add_argument("--name", required=True, help="Client name (e.g., 'Claude Web', 'Mon iPhone')") + create_parser.add_argument("--clients-file", type=Path, default=None, help="Path to clients.json") + + list_parser = auth_sub.add_parser("list", help="List all clients") + list_parser.add_argument("--clients-file", type=Path, default=None, help="Path to clients.json") + + revoke_parser = auth_sub.add_parser("revoke", help="Revoke a client") + revoke_parser.add_argument("client_id", help="Client ID to revoke") + revoke_parser.add_argument("--clients-file", type=Path, default=None, help="Path to clients.json") + args = parser.parse_args() + # Default to 'serve' if no subcommand + if args.command is None: + # Backward compat: bare `python -m tarkamcp` = stdio serve + from .server import mcp + mcp.run(transport="stdio") + return + + if args.command == "serve": + _cmd_serve(args) + elif args.command == "auth": + _cmd_auth(args) + + +def _cmd_serve(args): + from .server import mcp + if args.http: - _run_http(args.host, args.port) + _run_http(mcp, args.host, args.port) else: mcp.run(transport="stdio") -def _run_http(host: str, port: int): - """Run the MCP server over Streamable HTTP with URL-based secret auth.""" - import sys +def _cmd_auth(args): + from .auth import ClientStore + + store = ClientStore(args.clients_file) + + if args.auth_command == "create": + client_id, client_secret = store.create(args.name) + print() + print(" Client créé avec succès !") + print() + print(f" Name: {args.name}") + print(f" Client ID: {client_id}") + print(f" Client Secret: {client_secret}") + print() + print(" Utilise ces credentials dans :") + print(" - Claude web/mobile : champs OAuth Client ID / Client Secret") + print(" - ChatGPT / Gemini : en-tête Authorization: Bearer ") + print(" (obtenir un token : POST /oauth/token avec les credentials)") + print() + print(" Le Client Secret ne sera plus affiché. Conserve-le maintenant.") + print() + + elif args.auth_command == "list": + clients = store.list_clients() + if not clients: + print("Aucun client enregistré.") + return + print(f"\n{'Client ID':<30} {'Name':<25} {'Créé le'}") + print("-" * 75) + from datetime import datetime + for c in clients: + created = datetime.fromtimestamp(c["created_at"]).strftime("%Y-%m-%d %H:%M") + print(f"{c['client_id']:<30} {c['name']:<25} {created}") + print() + + elif args.auth_command == "revoke": + if store.revoke(args.client_id): + print(f"Client {args.client_id} révoqué.") + else: + print(f"Client {args.client_id} introuvable.") + sys.exit(1) + + else: + print("Usage: tarkamcp auth {create|list|revoke}") + sys.exit(1) + +def _run_http(mcp, host: str, port: int): + """Run the MCP server over Streamable HTTP with OAuth client credentials.""" import uvicorn from starlette.applications import Starlette + from starlette.middleware import Middleware + from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.routing import Mount, Route - secret = os.environ.get("TARKAMCP_SECRET", "") - if not secret: - print( - "ERROR: TARKAMCP_SECRET is required for HTTP mode.\n" - "This secret is embedded in the URL to authenticate requests.\n" - "Generate one with: openssl rand -hex 32", - file=sys.stderr, - ) - sys.exit(1) + from .auth import ClientStore, TokenStore + + clients_file = os.environ.get("TARKAMCP_CLIENTS_FILE") + client_store = ClientStore(Path(clients_file) if clients_file else None) + token_store = TokenStore() + + # --- OAuth endpoints --- + + async def oauth_metadata(request: Request) -> Response: + """RFC 8414 -- OAuth Authorization Server Metadata.""" + # Build issuer from request + scheme = request.headers.get("x-forwarded-proto", request.url.scheme) + host_header = request.headers.get("x-forwarded-host", request.headers.get("host", "localhost")) + issuer = f"{scheme}://{host_header}" + + return JSONResponse({ + "issuer": issuer, + "token_endpoint": f"{issuer}/oauth/token", + "grant_types_supported": ["client_credentials"], + "token_endpoint_auth_methods_supported": ["client_secret_post"], + "response_types_supported": ["token"], + }) + + async def oauth_token(request: Request) -> Response: + """OAuth 2.1 token endpoint -- client_credentials grant.""" + try: + if request.headers.get("content-type", "").startswith("application/json"): + body = await request.json() + else: + form = await request.form() + body = dict(form) + except Exception: + return JSONResponse({"error": "invalid_request"}, status_code=400) + + grant_type = body.get("grant_type", "") + client_id = body.get("client_id", "") + client_secret = body.get("client_secret", "") + + if grant_type != "client_credentials": + return JSONResponse( + {"error": "unsupported_grant_type", "error_description": "Only client_credentials is supported"}, + status_code=400, + ) + + if not client_store.verify(client_id, client_secret): + return JSONResponse( + {"error": "invalid_client", "error_description": "Invalid client_id or client_secret"}, + status_code=401, + ) + + token, expires_in = token_store.issue(client_id) + return JSONResponse({ + "access_token": token, + "token_type": "bearer", + "expires_in": expires_in, + }) - # The MCP app from FastMCP - mcp_app = mcp.streamable_http_app() - - # Health check async def health(request: Request) -> Response: return JSONResponse({"status": "ok", "server": "tarkamcp"}) - # The MCP endpoint lives under /s// - # Anyone without the secret gets a 404 -- the URL IS the key. - # Example: https://mcp.example.com/s/a1b2c3d4.../mcp + # --- Auth middleware --- + + async def auth_middleware(request: Request, call_next): + path = request.url.path + + # Public endpoints -- no auth + if path in ("/health", "/oauth/token", "/.well-known/oauth-authorization-server"): + return await call_next(request) + + # Check bearer token + authorization = request.headers.get("authorization", "") + if not authorization.startswith("Bearer "): + return JSONResponse( + {"error": "unauthorized", "error_description": "Bearer token required"}, + status_code=401, + headers={"WWW-Authenticate": 'Bearer realm="tarkamcp"'}, + ) + + token = authorization[7:] + client_id = token_store.validate(token) + if not client_id: + return JSONResponse( + {"error": "invalid_token", "error_description": "Token is invalid or expired"}, + status_code=401, + headers={"WWW-Authenticate": 'Bearer realm="tarkamcp", error="invalid_token"'}, + ) + + return await call_next(request) + + # --- App assembly --- + + mcp_app = mcp.streamable_http_app() + app = Starlette( routes=[ Route("/health", health), - Mount(f"/s/{secret}", app=mcp_app), + Route("/.well-known/oauth-authorization-server", oauth_metadata), + Route("/oauth/token", oauth_token, methods=["POST"]), + Mount("/", app=mcp_app), ], + middleware=[Middleware(BaseHTTPMiddleware, dispatch=auth_middleware)], ) - public_path = f"/s/{secret}/mcp" + n_clients = len(client_store.list_clients()) print(f"TarkaMCP HTTP server starting on {host}:{port}") - print(f"MCP endpoint: http://{host}:{port}{public_path}") - print(f"Health check: http://{host}:{port}/health") - print(f"\nClients should use the full URL including the secret path.") - print(f"Treat this URL like a password -- anyone with it has access.") + print(f"Registered clients: {n_clients}") + print(f"MCP endpoint: http://{host}:{port}/mcp") + print(f"Token endpoint: http://{host}:{port}/oauth/token") + print(f"Health check: http://{host}:{port}/health") + if n_clients == 0: + print(f"\nNo clients registered! Run: tarkamcp auth create --name 'My Client'") uvicorn.run(app, host=host, port=port, log_level="info") diff --git a/src/tarkamcp/auth.py b/src/tarkamcp/auth.py new file mode 100644 index 0000000..0d13504 --- /dev/null +++ b/src/tarkamcp/auth.py @@ -0,0 +1,139 @@ +"""OAuth 2.1 client credentials management for TarkaMCP HTTP mode.""" + +from __future__ import annotations + +import hashlib +import json +import secrets +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +CLIENTS_FILE = Path("/opt/tarkamcp/clients.json") + + +@dataclass +class Client: + client_id: str + client_secret_hash: str + name: str + created_at: float + + +@dataclass +class AccessToken: + token: str + client_id: str + expires_at: float + + +def _hash_secret(secret: str) -> str: + return hashlib.sha256(secret.encode()).hexdigest() + + +class ClientStore: + """Persistent client credential storage backed by a JSON file.""" + + def __init__(self, path: Path | None = None) -> None: + self._path = path or CLIENTS_FILE + self._clients: dict[str, Client] = {} + self._load() + + def _load(self) -> None: + if self._path.exists(): + data = json.loads(self._path.read_text()) + for c in data.get("clients", []): + self._clients[c["client_id"]] = Client(**c) + + def _save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + data = { + "clients": [ + { + "client_id": c.client_id, + "client_secret_hash": c.client_secret_hash, + "name": c.name, + "created_at": c.created_at, + } + for c in self._clients.values() + ] + } + self._path.write_text(json.dumps(data, indent=2)) + + def create(self, name: str) -> tuple[str, str]: + """Create a new client. Returns (client_id, client_secret).""" + client_id = "tarkamcp_" + secrets.token_hex(8) + client_secret = "sk_" + secrets.token_hex(32) + + self._clients[client_id] = Client( + client_id=client_id, + client_secret_hash=_hash_secret(client_secret), + name=name, + created_at=time.time(), + ) + self._save() + return client_id, client_secret + + def verify(self, client_id: str, client_secret: str) -> bool: + """Verify client credentials.""" + client = self._clients.get(client_id) + if not client: + return False + return client.client_secret_hash == _hash_secret(client_secret) + + def list_clients(self) -> list[dict[str, Any]]: + """List all registered clients (without secrets).""" + return [ + { + "client_id": c.client_id, + "name": c.name, + "created_at": c.created_at, + } + for c in self._clients.values() + ] + + def revoke(self, client_id: str) -> bool: + """Revoke a client. Returns True if found and removed.""" + if client_id in self._clients: + del self._clients[client_id] + self._save() + return True + return False + + +class TokenStore: + """In-memory access token store with expiration.""" + + TOKEN_TTL = 3600 * 24 # 24 hours + + def __init__(self) -> None: + self._tokens: dict[str, AccessToken] = {} + + def issue(self, client_id: str) -> tuple[str, int]: + """Issue an access token. Returns (token, expires_in).""" + token = secrets.token_hex(32) + self._tokens[token] = AccessToken( + token=token, + client_id=client_id, + expires_at=time.time() + self.TOKEN_TTL, + ) + self._cleanup() + return token, self.TOKEN_TTL + + def validate(self, token: str) -> str | None: + """Validate a token. Returns client_id if valid, None otherwise.""" + access_token = self._tokens.get(token) + if not access_token: + return None + if time.time() > access_token.expires_at: + del self._tokens[token] + return None + return access_token.client_id + + def _cleanup(self) -> None: + now = time.time() + expired = [t for t, at in self._tokens.items() if now > at.expires_at] + for t in expired: + del self._tokens[t] From ed061cfbff6e519a1e9f2b8377283e18fbcd5314 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 15:24:52 +0200 Subject: [PATCH 009/155] Remove local/stdio mode, make HTTP-only with OAuth TarkaMCP is now exclusively a remote HTTP server: - `tarkamcp serve` (or bare `tarkamcp`) starts the HTTP server - `tarkamcp auth create/list/revoke` manages OAuth clients - No more --http flag, no more stdio transport - Greatly simplified README: single installation flow, no dual-mode docs - Updated CLAUDE.md to reflect HTTP-only architecture Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 53 ++-- README.md | 615 +++++++++++++-------------------------- src/tarkamcp/__main__.py | 69 ++--- 3 files changed, 236 insertions(+), 501 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9ea00b2..2211c24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,69 +1,50 @@ # TarkaMCP -MCP server for managing a Proxmox VE infrastructure (pve1.example.com, pve2.example.com) with HP iLO 4 hardware management and SSH fallback access. +Remote MCP server for managing a Proxmox VE infrastructure (pve1.example.com, pve2.example.com) with HP iLO 4 hardware management and SSH fallback access. Runs as an HTTP server with OAuth 2.1 client credentials authentication. ## Quick Start ```bash pip install -e . -cp .env.example .env # Fill in real credentials -python -m tarkamcp # Runs MCP server on stdio +cp .env.example .env # Fill in Proxmox credentials +tarkamcp auth create --name "x" # Create OAuth client +tarkamcp serve # Start HTTP server on :8420 ``` ## Project Structure ``` src/tarkamcp/ - __main__.py Entry point (loads .env, starts MCP server) + __main__.py CLI: serve (HTTP server) + auth (client management) server.py FastMCP server, registers all tool modules config.py Environment variable loading & validation + auth.py OAuth 2.1 client credentials (ClientStore + TokenStore) proxmox/ client.py proxmoxer wrapper (API token auth, error handling) monitoring.py 6 tools: list_nodes, node_status, list_vms, vm_status, get_logs, get_tasks - vms.py 7 tools: vm_start, vm_stop, vm_restart, vm_create, vm_clone, vm_migrate, vm_config - system.py 5 tools: storage_status, network_config, exec_command (sync + async + get_result) + vms.py 7 tools: vm_start/stop/restart/create/clone/migrate/config + system.py 5 tools: storage_status, network_config, exec_command (sync+async+get_result) ssh/ client.py asyncssh wrapper (host resolution, connection caching) - tools.py 4 tools: ssh_exec_command (sync + async + get_result), ssh_list_sessions + tools.py 4 tools: ssh_exec_command (sync+async+get_result), ssh_list_sessions ilo/ client.py python-hpilo wrapper (SSH tunnel via pve1 to local iLO) - tools.py 7 tools: server_info, health_status, power_status, power_on/off/reset, event_log + tools.py 7 tools: server_info, health_status, power_status/on/off/reset, event_log +deploy/ + install.sh One-command install script for Proxmox nodes + tarkamcp.service systemd unit file ``` ## Configuration -All via environment variables (`.env` file). See `.env.example` for full list. +All via environment variables (`.env` file). See `.env.example`. **Required:** `PVE1_HOST`, `PVE1_TOKEN_ID`, `PVE1_TOKEN_SECRET` -**Optional:** PVE2, iLO, SSH credentials (modules are conditionally registered) +**Optional:** PVE2, iLO, SSH credentials (modules conditionally registered) -## Creating Proxmox API Tokens +## Auth -On each Proxmox node: Datacenter > Permissions > API Tokens > Add -- User: `root@pam` -- Token ID: `tarkamcp` -- Uncheck "Privilege Separation" for full access - -## Claude Code Integration - -Add to settings.json: -```json -{ - "mcpServers": { - "tarkamcp": { - "command": "python", - "args": ["-m", "tarkamcp"], - "cwd": "/path/to/TarkaMCP", - "env": { "DOTENV_PATH": ".env" } - } - } -} -``` - -## Infrastructure Context - -Edit `infrastructure.yaml` to define naming conventions, node roles, and notes. -The server exposes it as `tarkamcp://infrastructure` resource. +OAuth 2.1 client credentials. Manage with `tarkamcp auth create/list/revoke`. ## Design Spec diff --git a/README.md b/README.md index 5a9d08e..9cc2db2 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,11 @@ [![License](https://img.shields.io/github/license/Showdown76py/TarkaMCP)](LICENSE) [![Claude Code](https://img.shields.io/badge/Built_with-Claude_Code-F97316)](https://claude.ai/code) -**Serveur MCP pour la gestion d'infrastructure Proxmox VE.** +**Serveur MCP remote pour la gestion d'infrastructure Proxmox VE.** -Compatible **Claude** (Code, web, mobile) • **ChatGPT** • **Gemini** (CLI, API) +Compatible **Claude** (web, mobile) • **ChatGPT** • **Gemini** (CLI, API) -Donne à l'IA un accès direct à tes nœuds Proxmox, à l'iLO HP, et au SSH pour diagnostiquer, gérer les VMs/CTs, et résoudre les problèmes d'infrastructure. - -[Installation](#installation-côté-client) • [Connexion par plateforme](#connexion-par-plateforme) • [Outils](#outils-disponibles) • [Tests](#tests) +[Installation](#installation) • [Connexion](#connexion-par-plateforme) • [Outils](#outils-disponibles) • [Tests](#tests) @@ -26,24 +24,22 @@ Donne à l'IA un accès direct à tes nœuds Proxmox, à l'iLO HP, et au SSH pou ### Fonctionnalités - **29 outils MCP** répartis en 3 modules (Proxmox, SSH, iLO) -- **Diagnostic automatisé** -- Claude identifie les crashs, vérifie le hardware, propose des résolutions -- **Exécution de commandes** dans les VMs/CTs via QEMU Guest Agent ou SSH, avec support sync et async +- **Multi-plateforme** -- Claude, ChatGPT, Gemini via Streamable HTTP + OAuth 2.1 +- **Diagnostic automatisé** -- l'IA identifie les crashs, vérifie le hardware, propose des résolutions +- **Exécution de commandes** dans les VMs/CTs via QEMU Guest Agent ou SSH (sync et async) - **Gestion hardware à distance** -- power on/off/reset, températures, ventilateurs via iLO 4 - **Architecture modulaire** -- chaque module se charge uniquement si ses credentials sont configurés -- **Contexte infrastructure** -- fichier YAML exposé comme ressource MCP pour guider l'IA -- **Multi-plateforme** -- stdio (Claude Code, Gemini CLI) + Streamable HTTP (Claude mobile, ChatGPT, Gemini API) --- ## Table des matières - [Architecture](#architecture) -- [Installation côté client (ta machine)](#installation-côté-client) -- [Déploiement remote (serveur)](#déploiement-remote-serveur) +- [Installation](#installation) +- [Configuration Proxmox](#configuration-proxmox) +- [Configuration iLO](#configuration-ilo) +- [Configuration .env](#configuration-env) - [Connexion par plateforme](#connexion-par-plateforme) -- [Configuration côté serveur (Proxmox)](#configuration-côté-serveur-proxmox) -- [Configuration côté serveur (iLO)](#configuration-côté-serveur-ilo) -- [Configuration du .env](#configuration-du-env) - [Tests](#tests) - [Outils disponibles](#outils-disponibles) - [Dépannage](#dépannage) @@ -53,567 +49,354 @@ Donne à l'IA un accès direct à tes nœuds Proxmox, à l'iLO HP, et au SSH pou ## Architecture ``` -Ta machine (Claude Code) Infrastructure -+----------------------------+ +-----------------------------+ -| | API | | -| TarkaMCP (MCP server) --------> | pve1.example.com :8006 | -| | | SSH | (Proxmox VE) | -| +-- proxmox/ (API) --------> | | -| +-- ssh/ (asyncssh) --------> | pve2.example.com :8006 | -| +-- ilo/ (tunnel) ---+ | (Proxmox VE) | -| | | +-----------------------------+ -+----------------------------+ | - | +-----------------------------+ - +---> | iLO 4 (réseau local) | - tunnel | via pve1 SSH | - SSH +-----------------------------+ -``` - -## Deux modes de fonctionnement - -| Mode | Transport | Usage | Commande | -|------|-----------|-------|----------| -| **Local** | stdio | Claude Code, Gemini CLI | `python -m tarkamcp` | -| **Remote** | Streamable HTTP | Claude mobile/web, ChatGPT, Gemini API | `python -m tarkamcp --http` | - -Le mode **local** est pour un usage depuis ta machine. Le mode **remote** expose un serveur HTTP avec authentification par bearer token, accessible depuis n'importe quel client MCP. - -## Installation côté client - -### Prérequis - -- Python >= 3.11 -- pip -- Accès réseau vers pve1.example.com (port 8006 pour l'API, port 22 pour SSH) - -### Installation - -```bash -cd TarkaMCP -pip install -e . +Clients (Claude, ChatGPT, Gemini) + | + | HTTPS (Cloudflare Tunnel) + v ++--[ pve1.example.com ]-------------------+ +| | +| TarkaMCP (HTTP :8420) | +| +-- proxmox/ -----> Proxmox API :8006 | +| +-- ssh/ ---------> SSH :22 | +| +-- ilo/ ---------> iLO 4 (réseau local)| +| | ++--------------------------------------------+ + | + | API Proxmox + v + pve2.example.com ``` -### Vérification rapide - -```bash -# Avec les variables d'environnement configurées -python -c " -from dotenv import load_dotenv; load_dotenv() -from tarkamcp.server import mcp -print(f'OK: {len(mcp._tool_manager._tools)} outils enregistrés') -" -``` +Le serveur tourne sur pve1 et expose un endpoint MCP via Cloudflare Tunnel. Toutes les plateformes s'y connectent avec des credentials OAuth. --- -## Configuration côté serveur (Proxmox) - -### 1. Créer un API token sur chaque nœud - -Se connecter à l'interface web Proxmox (`https://pve1.example.com`). - -1. Aller dans **Datacenter** > **Permissions** > **API Tokens** -2. Cliquer **Add** -3. Remplir : - - **User** : `root@pam` - - **Token ID** : `tarkamcp` - - **Privilege Separation** : **décocher** (important, sinon le token n'a aucun privilège) -4. Cliquer **Add** -5. **Copier le token secret** affiché (il ne sera plus visible après) - -Le Token ID complet sera : `root@pam!tarkamcp` +## Installation -Répéter sur pve2 quand il sera de retour. +### 1. Installer sur pve1 -### 2. Installer le QEMU Guest Agent dans les VMs - -Le Guest Agent est nécessaire pour exécuter des commandes à l'intérieur des VMs via l'API Proxmox. - -**Debian/Ubuntu :** -```bash -apt update && apt install -y qemu-guest-agent -systemctl enable --now qemu-guest-agent -``` - -**CentOS/RHEL/AlmaLinux :** ```bash -dnf install -y qemu-guest-agent -systemctl enable --now qemu-guest-agent -``` - -**Vérification :** -```bash -systemctl status qemu-guest-agent -# Doit afficher "active (running)" +git clone https://github.com/Showdown76py/TarkaMCP.git /opt/tarkamcp +cd /opt/tarkamcp +sudo bash deploy/install.sh ``` -Puis dans Proxmox, activer le Guest Agent pour la VM : -1. Aller dans la VM > **Options** > **QEMU Guest Agent** -2. Cocher **Use QEMU Guest Agent** -3. Redémarrer la VM - -**Note :** Le Guest Agent n'est pas nécessaire pour les conteneurs LXC -- Proxmox a un accès direct. +### 2. Configurer les credentials Proxmox -### 3. Configurer l'accès SSH (optionnel mais recommandé) - -Le serveur MCP utilise SSH comme fallback quand l'API Proxmox ne suffit pas. L'accès SSH par mot de passe doit être actif sur les nœuds Proxmox. - -Vérifier que c'est le cas : -```bash -# Sur le nœud Proxmox -grep -E "^PasswordAuthentication" /etc/ssh/sshd_config -# Doit afficher: PasswordAuthentication yes -``` - -Si non : ```bash -sed -i 's/^PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config -systemctl restart sshd +nano /opt/tarkamcp/.env ``` -### 4. Vérifier les ports ouverts +Remplir au minimum `PVE1_HOST`, `PVE1_TOKEN_ID`, `PVE1_TOKEN_SECRET` (voir [Configuration .env](#configuration-env)). -Le serveur MCP a besoin de ces accès réseau : +### 3. Créer un client OAuth -| Service | Port | Protocole | Depuis | -|---------|------|-----------|--------| -| Proxmox API | 8006 | HTTPS | Ta machine | -| SSH (nœuds) | 22 | SSH | Ta machine | -| iLO | 443 | HTTPS | pve1 (réseau local) | - ---- - -## Configuration côté serveur (iLO) - -L'iLO est sur le réseau local uniquement. TarkaMCP y accède via un tunnel SSH à travers pve1. - -### Prérequis - -- iLO 4 accessible depuis le réseau local de pve1 -- Credentials iLO (par défaut : `Administrator` / mot de passe configuré) - -### Trouver l'IP de l'iLO - -Depuis pve1 : ```bash -# Scanner le réseau local pour trouver l'iLO -# L'iLO répond généralement sur le port 443 et 17988 -nmap -sn 192.168.1.0/24 | grep -B2 "HP\|iLO\|Hewlett" - -# Ou si tu connais l'IP, vérifier -curl -sk https://192.168.1.X/xmldata?item=All | head -20 -``` - -### Tester l'accès iLO depuis pve1 - -```bash -# Depuis pve1 -curl -sk https://IP_ILO/xmldata?item=All | grep PRODUCT_NAME -# Doit afficher le nom du serveur HP +tarkamcp auth create --name "Claude Web" ``` ---- - -## Configuration du .env - -Copier le template et remplir : - -```bash -cp .env.example .env ``` - -Éditer `.env` : - -```env -# OBLIGATOIRE -- PVE1 -PVE1_HOST=pve1.example.com -PVE1_TOKEN_ID=root@pam!tarkamcp -PVE1_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - -# OPTIONNEL -- PVE2 (quand il sera de retour) -PVE2_HOST=pve2.example.com -PVE2_TOKEN_ID=root@pam!tarkamcp -PVE2_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - -# OPTIONNEL -- iLO -ILO_HOST=192.168.1.X -ILO_USER=Administrator -ILO_PASSWORD=ton_mot_de_passe_ilo -ILO_JUMP_HOST=pve1 - -# OPTIONNEL -- SSH (recommandé) -SSH_USER=root -SSH_PASSWORD=ton_mot_de_passe_root - -# OPTIONS -PVE_VERIFY_SSL=false + Client ID: tarkamcp_a1b2c3... + Client Secret: sk_d4e5f6... ``` -Les modules sont chargés conditionnellement : -- **Sans SSH** : les 4 outils `ssh_*` ne sont pas disponibles -- **Sans iLO** : les 7 outils `ilo_*` ne sont pas disponibles -- **Sans PVE2** : les outils fonctionnent mais seul pve1 est interrogé +Conserver ces credentials -- le secret ne sera plus affiché. ---- - -## Déploiement remote (serveur) - -Pour utiliser TarkaMCP depuis Claude mobile, ChatGPT, ou Gemini, il faut le déployer comme serveur HTTP sur ton infrastructure. - -### Installation rapide sur pve1 +### 4. Démarrer le serveur ```bash -git clone https://github.com/Showdown76py/TarkaMCP.git /opt/tarkamcp -cd /opt/tarkamcp -sudo bash deploy/install.sh +sudo systemctl start tarkamcp +curl http://localhost:8420/health +# → {"status": "ok", "server": "tarkamcp"} ``` -Le script installe les dépendances, crée le `.env`, et configure le service systemd. - -Ensuite : -```bash -# 1. Éditer le .env avec tes vrais credentials Proxmox -nano /opt/tarkamcp/.env +### 5. Exposer via Cloudflare Tunnel -# 2. Créer un client OAuth (pour se connecter depuis Claude/ChatGPT/Gemini) -tarkamcp auth create --name "Mon iPhone" -# → Client ID: tarkamcp_abc123... -# → Client Secret: sk_def456... -# Note-les, le secret ne sera plus affiché. +Dans le dashboard Cloudflare Zero Trust, ajouter un tunnel : -# 3. Démarrer le serveur -sudo systemctl start tarkamcp - -# 4. Vérifier -curl http://localhost:8420/health -``` +| Paramètre | Valeur | +|-----------|--------| +| **Hostname** | `mcp.example.com` | +| **Service** | `http://localhost:8420` | ### Gérer les clients ```bash -# Créer un client par appareil / plateforme -tarkamcp auth create --name "Claude Web" +# Créer un client par plateforme tarkamcp auth create --name "ChatGPT" tarkamcp auth create --name "Gemini" -# Lister les clients existants +# Lister tarkamcp auth list # Révoquer un accès tarkamcp auth revoke tarkamcp_abc123... ``` -### Exposer via Cloudflare Tunnel - -Dans ton dashboard Cloudflare Zero Trust, ajouter un tunnel public : - -| Paramètre | Valeur | -|-----------|--------| -| **Hostname** | `mcp.example.com` (ou ton choix) | -| **Service** | `http://localhost:8420` | - -L'URL de ton serveur MCP sera : `https://mcp.example.com/mcp` - --- ## Connexion par plateforme -### Claude Code (local, stdio) - -Ajouter dans `~/.claude/settings.json` : - -```json -{ - "mcpServers": { - "tarkamcp": { - "command": "python", - "args": ["-m", "tarkamcp"], - "cwd": "/chemin/vers/TarkaMCP" - } - } -} -``` - -L'authentification utilise **OAuth 2.1 client credentials**. Tu crées un client sur le serveur (`tarkamcp auth create`), et tu utilises le Client ID + Secret pour te connecter depuis n'importe quelle plateforme. - ### Claude (web & mobile) -1. Aller dans **Settings** > **Integrations** > **Add custom connector** +1. **Settings** > **Integrations** > **Add custom connector** 2. Remplir : - **Name** : `TarkaMCP` - **Remote MCP server URL** : `https://mcp.example.com/mcp` - - **OAuth Client ID** : `tarkamcp_abc123...` (obtenu via `tarkamcp auth create`) - - **OAuth Client Secret** : `sk_def456...` -3. Cliquer **Add** + - **OAuth Client ID** : `tarkamcp_a1b2c3...` + - **OAuth Client Secret** : `sk_d4e5f6...` +3. **Add** ### ChatGPT -1. Aller dans **Settings** > **Developer Mode** > **MCP Servers** -2. Ajouter un serveur avec l'URL : `https://mcp.example.com/mcp` -3. Pour l'auth, obtenir un bearer token : +1. **Settings** > **Developer Mode** > **MCP Servers** +2. URL : `https://mcp.example.com/mcp` +3. Obtenir un bearer token : ```bash curl -X POST https://mcp.example.com/oauth/token \ - -d "grant_type=client_credentials&client_id=tarkamcp_abc123&client_secret=sk_def456" - # → {"access_token": "xxxx", "token_type": "bearer", ...} + -d "grant_type=client_credentials&client_id=ID&client_secret=SECRET" ``` -4. Utiliser l'`access_token` comme bearer token +4. Utiliser l'`access_token` retourné comme bearer token ### Gemini CLI ```bash -# Obtenir un token TOKEN=$(curl -s -X POST https://mcp.example.com/oauth/token \ - -d "grant_type=client_credentials&client_id=tarkamcp_abc123&client_secret=sk_def456" \ + -d "grant_type=client_credentials&client_id=ID&client_secret=SECRET" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") -# Ajouter le serveur MCP gemini mcp add tarkamcp --url https://mcp.example.com/mcp \ --header "Authorization: Bearer $TOKEN" ``` -### Gemini API (programmatique) +### Gemini API ```python import requests from google import genai -# 1. Obtenir un token -resp = requests.post("https://mcp.example.com/oauth/token", data={ +token = requests.post("https://mcp.example.com/oauth/token", data={ "grant_type": "client_credentials", - "client_id": "tarkamcp_abc123", - "client_secret": "sk_def456", -}) -token = resp.json()["access_token"] + "client_id": "tarkamcp_...", + "client_secret": "sk_...", +}).json()["access_token"] -# 2. Utiliser avec Gemini client = genai.Client() response = client.models.generate_content( model="gemini-2.0-flash", contents="Liste les VMs sur pve1", - config={ - "tools": [{ - "mcp_servers": [{ - "url": "https://mcp.example.com/mcp", - "headers": {"Authorization": f"Bearer {token}"}, - }] - }] - }, + config={"tools": [{"mcp_servers": [{ + "url": "https://mcp.example.com/mcp", + "headers": {"Authorization": f"Bearer {token}"}, + }]}]}, ) ``` -### Vérification +--- -Depuis n'importe quelle plateforme, demander : -``` -Utilise proxmox_list_nodes pour voir l'état du cluster -``` +## Configuration Proxmox -L'IA devrait appeler l'outil et afficher les nœuds. +### Créer un API token ---- +Sur l'interface web Proxmox (`https://pve1.example.com`) : -## Contexte infrastructure +1. **Datacenter** > **Permissions** > **API Tokens** > **Add** +2. **User** : `root@pam`, **Token ID** : `tarkamcp` +3. **Décocher** Privilege Separation +4. Copier le secret affiché -Éditer `infrastructure.yaml` pour définir tes conventions. Ce fichier est exposé comme ressource MCP (`tarkamcp://infrastructure`) et donne à Claude le contexte de ton infra. +Répéter sur pve2 quand disponible. -```yaml -conventions: - vmid_to_ip: "CT VMID corresponds to local IP 192.168.1.{VMID}" - naming: "VMs are prefixed by their role (e.g., web-101, db-102)" +### Installer le QEMU Guest Agent -nodes: - pve1: - host: pve1.example.com - role: "Primary node" - local_network: "192.168.1.0/24" - pve2: - host: pve2.example.com - role: "Secondary node" +Nécessaire pour exécuter des commandes à l'intérieur des VMs. -ilo: - host: "192.168.1.X" - access: "Local network only, via SSH tunnel through pve1" +```bash +# Debian/Ubuntu +apt install -y qemu-guest-agent && systemctl enable --now qemu-guest-agent -notes: - - "iLO is accessible only through pve1 as SSH jump host" - - "Zyxel USG 210 is the network gateway (no API)" +# CentOS/RHEL +dnf install -y qemu-guest-agent && systemctl enable --now qemu-guest-agent ``` ---- +Puis dans Proxmox : VM > **Options** > **QEMU Guest Agent** > cocher > redémarrer la VM. -## Tests +Les conteneurs LXC n'ont pas besoin du Guest Agent. -### Lancer les tests d'intégration +### Configurer SSH (optionnel) -Les tests se lancent contre la vraie infrastructure. Ils nécessitent un `.env` rempli. +SSH sert de fallback quand l'API Proxmox ne suffit pas. ```bash -# Tous les tests (sauf lifecycle VM) -python tests/test_integration.py +# Vérifier que l'auth par mot de passe est active +grep "^PasswordAuthentication" /etc/ssh/sshd_config +``` -# Section par section -python tests/test_integration.py --section proxmox -python tests/test_integration.py --section ssh -python tests/test_integration.py --section ilo +--- -# Avec tests de lifecycle VM (start/stop/clone -- utilise un VMID de test) -python tests/test_integration.py --test-vmid 9999 -``` +## Configuration iLO -### Ce que les tests vérifient +L'iLO est sur le réseau local. Comme TarkaMCP tourne sur pve1, il y accède directement. -| Section | Tests | Description | -|---------|-------|-------------| -| **Proxmox Monitoring** | 12 | list_nodes, node_status, list_vms, vm_status, get_logs, get_tasks | -| **Proxmox System** | 4 | storage_status, network_config | -| **Proxmox Exec (QEMU)** | 5 | exec sync, exit codes, async+poll, invalid VMID | -| **Proxmox Exec (LXC)** | 1 | exec dans un conteneur LXC | -| **VM Lifecycle** | 7 | start, stop, restart, config read/write, clone (nécessite --test-vmid) | -| **SSH** | 8 | exec sur pve1, exit codes, host resolution, async+poll, sessions | -| **iLO** | 6 | server_info, health, power_status, event_log | -| **Resources** | 4 | infrastructure resource, prompt, config validation | -| **Error Handling** | 4 | invalid node, VMID, exec_id | +```bash +# Trouver l'IP de l'iLO depuis pve1 +nmap -sn 192.168.1.0/24 | grep -B2 "HP\|iLO" -Total : **~50 tests** +# Tester +curl -sk https://IP_ILO/xmldata?item=All | grep PRODUCT_NAME +``` -### Créer une VM de test (optionnel) +--- -Pour les tests de lifecycle (start/stop/clone), créer une VM légère : +## Configuration .env ```bash -# Sur pve1, créer une VM vide VMID 9999 -qm create 9999 --name tarkamcp-test --memory 128 --cores 1 --net0 virtio,bridge=vmbr0 +cp .env.example .env && nano .env +``` + +```env +# OBLIGATOIRE +PVE1_HOST=pve1.example.com +PVE1_TOKEN_ID=root@pam!tarkamcp +PVE1_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# OPTIONNEL -- PVE2 +PVE2_HOST=pve2.example.com +PVE2_TOKEN_ID=root@pam!tarkamcp +PVE2_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# OPTIONNEL -- iLO +ILO_HOST=192.168.1.X +ILO_USER=Administrator +ILO_PASSWORD=xxxxx +ILO_JUMP_HOST=pve1 + +# OPTIONNEL -- SSH +SSH_USER=root +SSH_PASSWORD=xxxxx + +# OPTIONS +PVE_VERIFY_SSL=false +# TARKAMCP_PORT=8420 ``` -Puis lancer : +Modules chargés conditionnellement : sans SSH → pas de `ssh_*`, sans iLO → pas de `ilo_*`. + +--- + +## Tests + ```bash +# Tous les tests +python tests/test_integration.py + +# Par section +python tests/test_integration.py --section proxmox +python tests/test_integration.py --section ssh +python tests/test_integration.py --section ilo + +# Avec tests VM lifecycle (start/stop/clone) python tests/test_integration.py --test-vmid 9999 ``` +| Section | Tests | Description | +|---------|-------|-------------| +| Proxmox Monitoring | 12 | nodes, status, VMs, logs, tasks | +| Proxmox System | 4 | storage, network | +| Proxmox Exec | 6 | QEMU GA + LXC, sync/async | +| VM Lifecycle | 7 | start/stop/restart/config/clone | +| SSH | 8 | exec, host resolution, async | +| iLO | 6 | health, power, event log | +| Resources & Errors | 8 | config, prompts, error handling | + --- ## Outils disponibles -### Proxmox -- Monitoring (6 outils) +### Proxmox -- Monitoring (6) | Outil | Description | |-------|-------------| -| `proxmox_list_nodes` | Liste les nœuds du cluster avec leur statut | -| `proxmox_node_status` | CPU, RAM, disque, uptime, version PVE d'un nœud | -| `proxmox_list_vms` | Liste toutes les VMs/CTs avec statut et ressources | -| `proxmox_vm_status` | État détaillé d'une VM/CT spécifique | -| `proxmox_get_logs` | Logs système (syslog) ou tâches Proxmox | -| `proxmox_get_tasks` | Tâches récentes (migrations, backups, etc.) | +| `proxmox_list_nodes` | Liste les nœuds avec leur statut | +| `proxmox_node_status` | CPU, RAM, disque, uptime d'un nœud | +| `proxmox_list_vms` | Liste toutes les VMs/CTs | +| `proxmox_vm_status` | État détaillé d'une VM/CT | +| `proxmox_get_logs` | Logs système ou tâches | +| `proxmox_get_tasks` | Tâches récentes | -### Proxmox -- Gestion VMs (7 outils) +### Proxmox -- Gestion VMs (7) | Outil | Description | |-------|-------------| | `proxmox_vm_start` | Démarrer une VM/CT | -| `proxmox_vm_stop` | Arrêter une VM/CT (clean ou force) | -| `proxmox_vm_restart` | Redémarrer une VM/CT | -| `proxmox_vm_create` | Créer une nouvelle VM/CT | -| `proxmox_vm_clone` | Cloner une VM/CT existante | -| `proxmox_vm_migrate` | Migrer une VM/CT vers un autre nœud | -| `proxmox_vm_config` | Lire ou modifier la config d'une VM/CT | +| `proxmox_vm_stop` | Arrêter (clean ou force) | +| `proxmox_vm_restart` | Redémarrer | +| `proxmox_vm_create` | Créer une VM/CT | +| `proxmox_vm_clone` | Cloner | +| `proxmox_vm_migrate` | Migrer vers un autre nœud | +| `proxmox_vm_config` | Lire/modifier la config | -### Proxmox -- Système (5 outils) +### Proxmox -- Système (5) | Outil | Description | |-------|-------------| -| `proxmox_storage_status` | État du stockage (local, NFS, CEPH, etc.) | -| `proxmox_network_config` | Configuration réseau du nœud | -| `proxmox_exec_command` | Exécuter une commande dans une VM/CT (sync) | -| `proxmox_exec_command_async` | Lancer une commande longue (async) | -| `proxmox_exec_get_result` | Récupérer le résultat d'une commande async | +| `proxmox_storage_status` | État du stockage | +| `proxmox_network_config` | Config réseau du nœud | +| `proxmox_exec_command` | Commande dans une VM/CT (sync) | +| `proxmox_exec_command_async` | Commande longue (async) | +| `proxmox_exec_get_result` | Résultat d'une commande async | -### SSH (4 outils) +### SSH (4) | Outil | Description | |-------|-------------| -| `ssh_exec_command` | Commande SSH sur un hôte (sync) | -| `ssh_exec_command_async` | Commande SSH longue (async) | -| `ssh_exec_get_result` | Résultat d'une commande SSH async | -| `ssh_list_sessions` | Lister les sessions SSH actives | +| `ssh_exec_command` | Commande sur un hôte (sync) | +| `ssh_exec_command_async` | Commande longue (async) | +| `ssh_exec_get_result` | Résultat d'une commande async | +| `ssh_list_sessions` | Sessions SSH actives | -### iLO (7 outils) +### iLO (7) | Outil | Description | |-------|-------------| -| `ilo_server_info` | Modèle, serial, firmware du serveur | -| `ilo_health_status` | Températures, ventilateurs, alims, disques, RAM | +| `ilo_server_info` | Modèle, serial, firmware | +| `ilo_health_status` | Températures, ventilateurs, alims, disques | | `ilo_power_status` | État d'alimentation (ON/OFF) | -| `ilo_power_on` | Allumer le serveur physique | -| `ilo_power_off` | Éteindre le serveur (clean ou force) | -| `ilo_power_reset` | Hard reset du serveur | +| `ilo_power_on` | Allumer le serveur | +| `ilo_power_off` | Éteindre (clean ou force) | +| `ilo_power_reset` | Hard reset | | `ilo_get_event_log` | Journal d'événements iLO | --- ## Dépannage -### "PVE1_HOST, PVE1_TOKEN_ID, and PVE1_TOKEN_SECRET are required" - -Le `.env` n'est pas chargé ou les variables ne sont pas définies. Vérifier : -```bash -cat .env | grep PVE1 -``` - -### "Node 'pveX' is unreachable" - -Le nœud Proxmox ne répond pas sur le port 8006. Vérifier : -```bash -curl -sk https://pve1.example.com:8006/api2/json/version -``` - -### "QEMU Guest Agent may not be running" - -Le Guest Agent n'est pas installé ou pas actif dans la VM. Voir la section [Installer le QEMU Guest Agent](#2-installer-le-qemu-guest-agent-dans-les-vms). - -### "iLO is accessible only through pve1 (SSH tunnel)" - -L'iLO est sur le réseau local. Si pve1 est down, l'iLO est inaccessible. Vérifier pve1 d'abord. - -### "SSH connection to 'X' failed" - -Vérifier que SSH par mot de passe est actif et que les credentials sont corrects : -```bash -ssh root@pve1.example.com -``` - -### Certificats SSL - -Par défaut `PVE_VERIFY_SSL=false` car Proxmox utilise des certificats self-signed. Si tu as configuré des certificats valides (Let's Encrypt), mets `PVE_VERIFY_SSL=true`. +| Erreur | Cause | Solution | +|--------|-------|----------| +| `PVE1_HOST ... required` | `.env` non chargé | Vérifier `/opt/tarkamcp/.env` | +| `Node 'pveX' is unreachable` | API Proxmox down | `curl -sk https://pve1:8006/api2/json/version` | +| `QEMU Guest Agent may not be running` | Agent non installé | Voir [Configuration Proxmox](#installer-le-qemu-guest-agent) | +| `iLO ... unreachable` | pve1 down ou iLO injoignable | Vérifier pve1 d'abord | +| `SSH connection failed` | Auth SSH désactivée | `grep PasswordAuthentication /etc/ssh/sshd_config` | +| `invalid_client` | Mauvais Client ID/Secret | `tarkamcp auth list` pour vérifier | --- ## Exemple d'utilisation -Une fois configuré, tu peux demander à Claude des choses comme : - > **"pve2 ne répond plus, qu'est-ce qui se passe ?"** > -> Claude va automatiquement : -> 1. Appeler `proxmox_list_nodes` -- voir que pve2 est offline -> 2. Appeler `ilo_power_status` -- vérifier si le serveur est physiquement allumé -> 3. Appeler `ilo_health_status` -- checker les températures, ventilateurs, disques -> 4. Te proposer un diagnostic et une action (power cycle, vérifier les logs, etc.) - -> **"Combien de RAM utilise la VM 101 ?"** -> -> Claude appelle `proxmox_vm_status(node="pve1", vmid=101)` et te donne les détails. +> L'IA va : `proxmox_list_nodes` → voit pve2 offline → `ilo_power_status` → vérifie si allumé → `ilo_health_status` → checker le hardware → proposer un diagnostic > **"Mets à jour les paquets sur tous les conteneurs"** > -> Claude utilise `proxmox_list_vms` pour lister les CTs, puis `proxmox_exec_command_async` pour lancer `apt update && apt upgrade -y` dans chacun, et poll les résultats avec `proxmox_exec_get_result`. +> L'IA utilise `proxmox_list_vms` → liste les CTs → `proxmox_exec_command_async` → lance `apt update && apt upgrade -y` dans chacun → poll les résultats --- ## Licence -Ce projet est sous licence [Apache 2.0](LICENSE). - ---- +[Apache 2.0](LICENSE)
Construit avec Claude Code diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py index 60576e8..c90aabd 100644 --- a/src/tarkamcp/__main__.py +++ b/src/tarkamcp/__main__.py @@ -15,11 +15,7 @@ def main(): sub = parser.add_subparsers(dest="command") # --- serve (default) --- - serve_parser = sub.add_parser("serve", help="Start the MCP server") - serve_parser.add_argument( - "--http", action="store_true", - help="Run as Streamable HTTP server (for Claude mobile, ChatGPT, Gemini)", - ) + serve_parser = sub.add_parser("serve", help="Start the MCP HTTP server") serve_parser.add_argument( "--port", type=int, default=int(os.environ.get("TARKAMCP_PORT", "8420")), help="HTTP port (default: 8420)", @@ -29,7 +25,7 @@ def main(): help="HTTP bind address (default: 0.0.0.0)", ) - # --- auth create --- + # --- auth --- auth_parser = sub.add_parser("auth", help="Manage OAuth client credentials") auth_sub = auth_parser.add_subparsers(dest="auth_command") @@ -46,14 +42,7 @@ def main(): args = parser.parse_args() - # Default to 'serve' if no subcommand - if args.command is None: - # Backward compat: bare `python -m tarkamcp` = stdio serve - from .server import mcp - mcp.run(transport="stdio") - return - - if args.command == "serve": + if args.command is None or args.command == "serve": _cmd_serve(args) elif args.command == "auth": _cmd_auth(args) @@ -62,16 +51,15 @@ def main(): def _cmd_serve(args): from .server import mcp - if args.http: - _run_http(mcp, args.host, args.port) - else: - mcp.run(transport="stdio") + host = getattr(args, "host", os.environ.get("TARKAMCP_HOST", "0.0.0.0")) + port = getattr(args, "port", int(os.environ.get("TARKAMCP_PORT", "8420"))) + _run_http(mcp, host, port) def _cmd_auth(args): from .auth import ClientStore - store = ClientStore(args.clients_file) + store = ClientStore(getattr(args, "clients_file", None)) if args.auth_command == "create": client_id, client_secret = store.create(args.name) @@ -84,8 +72,7 @@ def _cmd_auth(args): print() print(" Utilise ces credentials dans :") print(" - Claude web/mobile : champs OAuth Client ID / Client Secret") - print(" - ChatGPT / Gemini : en-tête Authorization: Bearer ") - print(" (obtenir un token : POST /oauth/token avec les credentials)") + print(" - ChatGPT / Gemini : POST /oauth/token pour obtenir un bearer token") print() print(" Le Client Secret ne sera plus affiché. Conserve-le maintenant.") print() @@ -131,15 +118,10 @@ def _run_http(mcp, host: str, port: int): client_store = ClientStore(Path(clients_file) if clients_file else None) token_store = TokenStore() - # --- OAuth endpoints --- - async def oauth_metadata(request: Request) -> Response: - """RFC 8414 -- OAuth Authorization Server Metadata.""" - # Build issuer from request scheme = request.headers.get("x-forwarded-proto", request.url.scheme) host_header = request.headers.get("x-forwarded-host", request.headers.get("host", "localhost")) issuer = f"{scheme}://{host_header}" - return JSONResponse({ "issuer": issuer, "token_endpoint": f"{issuer}/oauth/token", @@ -149,7 +131,6 @@ async def oauth_metadata(request: Request) -> Response: }) async def oauth_token(request: Request) -> Response: - """OAuth 2.1 token endpoint -- client_credentials grant.""" try: if request.headers.get("content-type", "").startswith("application/json"): body = await request.json() @@ -165,13 +146,12 @@ async def oauth_token(request: Request) -> Response: if grant_type != "client_credentials": return JSONResponse( - {"error": "unsupported_grant_type", "error_description": "Only client_credentials is supported"}, + {"error": "unsupported_grant_type"}, status_code=400, ) - if not client_store.verify(client_id, client_secret): return JSONResponse( - {"error": "invalid_client", "error_description": "Invalid client_id or client_secret"}, + {"error": "invalid_client"}, status_code=401, ) @@ -182,40 +162,31 @@ async def oauth_token(request: Request) -> Response: "expires_in": expires_in, }) - async def health(request: Request) -> Response: + async def health(_request: Request) -> Response: return JSONResponse({"status": "ok", "server": "tarkamcp"}) - # --- Auth middleware --- - async def auth_middleware(request: Request, call_next): path = request.url.path - - # Public endpoints -- no auth if path in ("/health", "/oauth/token", "/.well-known/oauth-authorization-server"): return await call_next(request) - # Check bearer token authorization = request.headers.get("authorization", "") if not authorization.startswith("Bearer "): return JSONResponse( - {"error": "unauthorized", "error_description": "Bearer token required"}, + {"error": "unauthorized"}, status_code=401, headers={"WWW-Authenticate": 'Bearer realm="tarkamcp"'}, ) - token = authorization[7:] - client_id = token_store.validate(token) + client_id = token_store.validate(authorization[7:]) if not client_id: return JSONResponse( - {"error": "invalid_token", "error_description": "Token is invalid or expired"}, + {"error": "invalid_token"}, status_code=401, headers={"WWW-Authenticate": 'Bearer realm="tarkamcp", error="invalid_token"'}, ) - return await call_next(request) - # --- App assembly --- - mcp_app = mcp.streamable_http_app() app = Starlette( @@ -229,13 +200,13 @@ async def auth_middleware(request: Request, call_next): ) n_clients = len(client_store.list_clients()) - print(f"TarkaMCP HTTP server starting on {host}:{port}") - print(f"Registered clients: {n_clients}") - print(f"MCP endpoint: http://{host}:{port}/mcp") - print(f"Token endpoint: http://{host}:{port}/oauth/token") - print(f"Health check: http://{host}:{port}/health") + print(f"TarkaMCP starting on {host}:{port}") + print(f"Clients: {n_clients}") + print(f"MCP: http://{host}:{port}/mcp") + print(f"OAuth: http://{host}:{port}/oauth/token") + print(f"Health: http://{host}:{port}/health") if n_clients == 0: - print(f"\nNo clients registered! Run: tarkamcp auth create --name 'My Client'") + print(f"\nAucun client ! Créer avec : tarkamcp auth create --name 'Mon Client'") uvicorn.run(app, host=host, port=port, log_level="info") From f099fad9c58b6aa7f413d951a5d7a92a26cf942b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 14:57:22 +0000 Subject: [PATCH 010/155] Fix Proxmox API usage bugs and systemd unit - tarkamcp.service: replace non-existent --http flag with serve subcommand (the CLI only exposes serve/auth; --http caused the service to fail on boot). - proxmox/system.py: pass guest-agent exec `command` as an array instead of the invented arg0/arg1 form. The previous encoding caused PVE to drop all arguments, running only the binary path. - proxmox/system.py: remove the non-existent /nodes/{node}/lxc/{vmid}/exec call and return actionable guidance (use ssh_exec_command + pct exec). Applied to both sync and async paths; the async path no longer leaves LXC sessions stuck in "running" forever. - proxmox/system.py: guard proxmox_storage_status against entries missing the "storage" key. - proxmox/vms.py: use /status/reboot for both QEMU and LXC (PVE has no /status/restart for LXC). - proxmox/vms.py: clone uses `hostname` for LXC and `name` for QEMU, matching the PVE API schema. - ssh/client.py: retain a strong reference to the asyncio.create_task used for background SSH runs so the task can't be garbage-collected mid-run. - tests/test_integration.py: align the LXC exec test with the new API-unsupported error contract. --- deploy/tarkamcp.service | 2 +- src/tarkamcp/proxmox/system.py | 83 ++++++++++++++-------------------- src/tarkamcp/proxmox/vms.py | 9 ++-- src/tarkamcp/ssh/client.py | 6 ++- tests/test_integration.py | 8 +++- 5 files changed, 51 insertions(+), 57 deletions(-) diff --git a/deploy/tarkamcp.service b/deploy/tarkamcp.service index a6168b9..b8b8a4e 100644 --- a/deploy/tarkamcp.service +++ b/deploy/tarkamcp.service @@ -7,7 +7,7 @@ Type=simple User=root WorkingDirectory=/opt/tarkamcp EnvironmentFile=/opt/tarkamcp/.env -ExecStart=/usr/bin/python3 -m tarkamcp --http +ExecStart=/usr/bin/python3 -m tarkamcp serve Restart=on-failure RestartSec=5 diff --git a/src/tarkamcp/proxmox/system.py b/src/tarkamcp/proxmox/system.py index d82b51a..1789364 100644 --- a/src/tarkamcp/proxmox/system.py +++ b/src/tarkamcp/proxmox/system.py @@ -44,14 +44,12 @@ async def _exec_qemu_sync(client: ProxmoxClient, node: str, vmid: int, command: import asyncio import shlex - # Proxmox agent/exec endpoint expects: command (binary path) + optional arg-N params + # Proxmox agent/exec endpoint expects `command` as an array (binary + args). + # proxmoxer encodes list values with doseq=True, which PVE parses as an array. parts = shlex.split(command) - exec_kwargs: dict[str, Any] = {"command": parts[0]} - for i, arg in enumerate(parts[1:]): - exec_kwargs[f"arg{i}"] = arg # Start the command via QEMU Guest Agent - result = client.post(node, f"nodes/{node}/qemu/{vmid}/agent/exec", **exec_kwargs) + result = client.post(node, f"nodes/{node}/qemu/{vmid}/agent/exec", command=parts) if isinstance(result, dict) and "error" in result: return result @@ -89,26 +87,19 @@ async def _exec_qemu_sync(client: ProxmoxClient, node: str, vmid: int, command: } -def _exec_lxc_sync(client: ProxmoxClient, node: str, vmid: int, command: str, timeout: int) -> dict[str, Any]: - """Execute a command in an LXC container via Proxmox API.""" - parts = command.split() - result = client.post( - node, - f"nodes/{node}/lxc/{vmid}/exec", - command=parts, - ) - if isinstance(result, dict) and "error" in result: - return result +def _exec_lxc_unsupported(vmid: int) -> dict[str, Any]: + """LXC exec is not exposed by the Proxmox API. - # LXC exec via API may return directly or via a different mechanism - # depending on PVE version. Handle both. - if isinstance(result, dict): - return { - "stdout": result.get("out-data", result.get("data", "")), - "stderr": result.get("err-data", ""), - "exit_code": result.get("exitcode", 0), - } - return {"stdout": str(result), "stderr": "", "exit_code": 0} + Commands inside containers must be run via `pct exec` on the host, which + requires SSH access to the node. + """ + return { + "error": ( + f"Proxmox API does not expose an exec endpoint for LXC containers. " + f"Use ssh_exec_command on the host node with " + f"'pct exec {vmid} -- ' instead." + ) + } def register_system_tools(mcp: FastMCP, client: ProxmoxClient) -> None: @@ -133,7 +124,10 @@ def proxmox_storage_status(node: str = "") -> dict[str, Any]: if not isinstance(data, list): continue for s in data: - status = client.get(n, f"nodes/{n}/storage/{s['storage']}/status") + storage_name = s.get("storage") + if not storage_name: + continue + status = client.get(n, f"nodes/{n}/storage/{storage_name}/status") used = 0 total = 0 if isinstance(status, dict) and "error" not in status: @@ -142,7 +136,7 @@ def proxmox_storage_status(node: str = "") -> dict[str, Any]: all_storage.append({ "node": n, - "storage": s.get("storage"), + "storage": storage_name, "type": s.get("type"), "content": s.get("content"), "enabled": s.get("enabled", 1) == 1, @@ -184,12 +178,13 @@ def proxmox_network_config(node: str) -> dict[str, Any]: @mcp.tool() async def proxmox_exec_command(node: str, vmid: int, command: str, timeout: int = 60) -> dict[str, Any]: - """Execute a command inside a VM (via QEMU Guest Agent) or container (via lxc exec) and wait for the result. + """Execute a command inside a QEMU VM (via QEMU Guest Agent) and wait for the result. Use for short-lived commands that complete within the timeout (default 60s, max 300s). Returns stdout, stderr, and exit_code. For long-running commands (apt upgrade, backups, etc.), use proxmox_exec_command_async instead. For commands on the Proxmox host itself, use ssh_exec_command. + LXC containers have no API exec endpoint: use ssh_exec_command with 'pct exec -- '. """ timeout = min(timeout, 300) vm_type = _detect_vm_type(client, node, vmid) @@ -198,7 +193,7 @@ async def proxmox_exec_command(node: str, vmid: int, command: str, timeout: int if vm_type == "qemu": return await _exec_qemu_sync(client, node, vmid, command, timeout) - return _exec_lxc_sync(client, node, vmid, command, timeout) + return _exec_lxc_unsupported(vmid) @mcp.tool() def proxmox_exec_command_async(node: str, vmid: int, command: str) -> dict[str, Any]: @@ -214,6 +209,10 @@ def proxmox_exec_command_async(node: str, vmid: int, command: str) -> dict[str, if not vm_type: return {"error": f"VM/CT {vmid} not found on node '{node}'. Check VMID and node name."} + if vm_type == "lxc": + # LXC has no API exec endpoint; surface the actionable error up-front. + return _exec_lxc_unsupported(vmid) + exec_id = str(uuid.uuid4())[:8] session = ExecSession( exec_id=exec_id, @@ -224,26 +223,14 @@ def proxmox_exec_command_async(node: str, vmid: int, command: str) -> dict[str, ) _exec_sessions[exec_id] = session - if vm_type == "qemu": - # Start via guest agent with proper arg format - parts = shlex.split(command) - exec_kwargs: dict[str, Any] = {"command": parts[0]} - for i, arg in enumerate(parts[1:]): - exec_kwargs[f"arg{i}"] = arg - result = client.post(node, f"nodes/{node}/qemu/{vmid}/agent/exec", **exec_kwargs) - if isinstance(result, dict) and "error" in result: - session.status = "failed" - session.stderr = str(result["error"]) - return {"exec_id": exec_id, "status": "failed", "error": result["error"]} - session.pid = result.get("pid") if isinstance(result, dict) else None - else: - # LXC -- start in background - parts = shlex.split(command) - result = client.post(node, f"nodes/{node}/lxc/{vmid}/exec", command=parts) - if isinstance(result, dict) and "error" in result: - session.status = "failed" - session.stderr = str(result["error"]) - return {"exec_id": exec_id, "status": "failed", "error": result["error"]} + # Start via guest agent (command is an array: binary + args) + parts = shlex.split(command) + result = client.post(node, f"nodes/{node}/qemu/{vmid}/agent/exec", command=parts) + if isinstance(result, dict) and "error" in result: + session.status = "failed" + session.stderr = str(result["error"]) + return {"exec_id": exec_id, "status": "failed", "error": result["error"]} + session.pid = result.get("pid") if isinstance(result, dict) else None return {"exec_id": exec_id, "status": "running", "vmid": vmid, "command": command} diff --git a/src/tarkamcp/proxmox/vms.py b/src/tarkamcp/proxmox/vms.py index 0bcf85e..42c1f94 100644 --- a/src/tarkamcp/proxmox/vms.py +++ b/src/tarkamcp/proxmox/vms.py @@ -68,10 +68,8 @@ def proxmox_vm_restart(node: str, vmid: int) -> dict[str, Any]: if not vm_type: return {"error": f"VM/CT {vmid} not found on node '{node}'. Check VMID and node name."} - # Use reboot for qemu, restart for lxc - endpoint = "reboot" if vm_type == "qemu" else "restart" - # Proxmox may not have a direct restart for lxc in older versions, try reboot - result = client.post(node, f"nodes/{node}/{vm_type}/{vmid}/status/{endpoint}") + # Both QEMU and LXC use /status/reboot (PVE 7+). LXC does not expose /status/restart. + result = client.post(node, f"nodes/{node}/{vm_type}/{vmid}/status/reboot") if isinstance(result, dict) and "error" in result: return result return {"vmid": vmid, "node": node, "action": "restart", "upid": result} @@ -107,7 +105,8 @@ def proxmox_vm_clone(node: str, vmid: int, newid: int, name: str = "") -> dict[s kwargs: dict[str, Any] = {"newid": newid} if name: - kwargs["name"] = name + # PVE uses `name` for QEMU VMs and `hostname` for LXC containers. + kwargs["name" if vm_type == "qemu" else "hostname"] = name result = client.post(node, f"nodes/{node}/{vm_type}/{vmid}/clone", **kwargs) if isinstance(result, dict) and "error" in result: diff --git a/src/tarkamcp/ssh/client.py b/src/tarkamcp/ssh/client.py index e036968..d006dc8 100644 --- a/src/tarkamcp/ssh/client.py +++ b/src/tarkamcp/ssh/client.py @@ -24,6 +24,7 @@ class SSHExecSession: _ssh_sessions: dict[str, SSHExecSession] = {} +_ssh_tasks: set[asyncio.Task[None]] = set() _connection_cache: dict[str, tuple[asyncssh.SSHClientConnection, float]] = {} _CONNECTION_TTL = 300 # 5 minutes @@ -132,7 +133,10 @@ async def _run() -> None: session.status = "failed" session.stderr = str(e) - asyncio.create_task(_run()) + # Keep a reference to the task so it isn't garbage collected mid-run. + task = asyncio.create_task(_run()) + _ssh_tasks.add(task) + task.add_done_callback(_ssh_tasks.discard) return exec_id @staticmethod diff --git a/tests/test_integration.py b/tests/test_integration.py index 9b77578..4f9fc15 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -388,9 +388,13 @@ def test_proxmox_exec_lxc(runner: TestRunner, tools: dict) -> None: result = call_tool(tools, "proxmox_exec_command", node="pve1", vmid=vmid, command="echo LXC-test", timeout=30) + # The Proxmox API does not expose an exec endpoint for LXC containers; the + # tool must return an actionable error pointing to ssh_exec_command + pct exec. runner.record( - f"proxmox_exec_command 'echo' in CT {vmid}", - isinstance(result, dict) and ("LXC-test" in result.get("stdout", "") or "error" not in result), + f"proxmox_exec_command on CT {vmid} returns LXC-not-supported guidance", + isinstance(result, dict) + and "error" in result + and "pct exec" in result.get("error", ""), f"Result: {result}", result, ) From 044d33f87c0b7ce64501cf868c748233609f116a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 15:04:43 +0000 Subject: [PATCH 011/155] Review round 2: robustness, auth hardening, cluster dedup, README Logic / correctness - proxmox_list_nodes no longer duplicates members in a real cluster. PVE returns the full cluster view from any reachable member, so we stop at the first successful GET /nodes and only fall back to other configured nodes when the first is unreachable. Unreachable nodes are still reported so stale credentials are visible. - Bound _exec_sessions and _ssh_sessions: completed sessions older than 1h are pruned whenever a new one is inserted, avoiding unbounded growth on a long-running server. - Removed dead code ProxmoxClient._resolve_node. Auth / OAuth hardening - ClientStore.verify now uses hmac.compare_digest for constant-time hash comparison. - ClientStore._save writes atomically (tmp + replace) with chmod 0600 so the secret-hash file is never world-readable. - ClientStore._load surfaces a clear error on malformed JSON instead of crashing opaquely inside json.loads. - OAuth metadata no longer advertises response_types_supported: ["token"], which is meaningless for the client_credentials grant. Misc - ilo/client.py: asyncio.get_running_loop() (get_event_loop is deprecated when a loop is already running) + drop pointless f-string. - README: correct the iLO section (the SSH tunnel is always used, not a direct connection) and fix the LXC update example since the API has no LXC exec endpoint -- the AI uses ssh + pct exec. --- README.md | 10 +++++++-- src/tarkamcp/__main__.py | 4 +++- src/tarkamcp/auth.py | 31 ++++++++++++++++++++++----- src/tarkamcp/ilo/client.py | 4 ++-- src/tarkamcp/proxmox/client.py | 6 ------ src/tarkamcp/proxmox/monitoring.py | 34 ++++++++++++++++++++++-------- src/tarkamcp/proxmox/system.py | 15 +++++++++++++ src/tarkamcp/ssh/client.py | 13 ++++++++++++ 8 files changed, 92 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 9cc2db2..a6b8f84 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,10 @@ grep "^PasswordAuthentication" /etc/ssh/sshd_config ## Configuration iLO -L'iLO est sur le réseau local. Comme TarkaMCP tourne sur pve1, il y accède directement. +L'iLO est sur le réseau local. TarkaMCP y accède via un tunnel SSH ouvert sur +`ILO_JUMP_HOST` (par défaut `pve1`) ; les credentials SSH doivent donc être +configurés. Quand TarkaMCP tourne lui-même sur pve1, le tunnel est trivial +(localhost → iLO) mais reste nécessaire vu que python-hpilo est synchrone. ```bash # Trouver l'IP de l'iLO depuis pve1 @@ -390,7 +393,10 @@ python tests/test_integration.py --test-vmid 9999 > **"Mets à jour les paquets sur tous les conteneurs"** > -> L'IA utilise `proxmox_list_vms` → liste les CTs → `proxmox_exec_command_async` → lance `apt update && apt upgrade -y` dans chacun → poll les résultats +> L'API Proxmox n'expose pas d'endpoint `exec` pour les LXC. L'IA utilise +> donc `proxmox_list_vms` → liste les CTs → `ssh_exec_command_async` sur le +> nœud hôte avec `pct exec -- sh -c 'apt update && apt upgrade -y'` +> pour chacun → poll les résultats. --- diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py index c90aabd..d75c85a 100644 --- a/src/tarkamcp/__main__.py +++ b/src/tarkamcp/__main__.py @@ -122,12 +122,14 @@ async def oauth_metadata(request: Request) -> Response: scheme = request.headers.get("x-forwarded-proto", request.url.scheme) host_header = request.headers.get("x-forwarded-host", request.headers.get("host", "localhost")) issuer = f"{scheme}://{host_header}" + # Note: `response_types_supported` is omitted on purpose. It describes + # the authorization-code flow's response_type; it has no meaning for + # the client_credentials grant we advertise. return JSONResponse({ "issuer": issuer, "token_endpoint": f"{issuer}/oauth/token", "grant_types_supported": ["client_credentials"], "token_endpoint_auth_methods_supported": ["client_secret_post"], - "response_types_supported": ["token"], }) async def oauth_token(request: Request) -> Response: diff --git a/src/tarkamcp/auth.py b/src/tarkamcp/auth.py index 0d13504..c49ddd4 100644 --- a/src/tarkamcp/auth.py +++ b/src/tarkamcp/auth.py @@ -3,8 +3,11 @@ from __future__ import annotations import hashlib +import hmac import json +import os import secrets +import sys import time from dataclasses import dataclass from pathlib import Path @@ -42,10 +45,19 @@ def __init__(self, path: Path | None = None) -> None: self._load() def _load(self) -> None: - if self._path.exists(): + if not self._path.exists(): + return + try: data = json.loads(self._path.read_text()) - for c in data.get("clients", []): - self._clients[c["client_id"]] = Client(**c) + except json.JSONDecodeError as e: + print( + f"ERROR: {self._path} is not valid JSON ({e}). " + "Fix or delete it before restarting.", + file=sys.stderr, + ) + raise + for c in data.get("clients", []): + self._clients[c["client_id"]] = Client(**c) def _save(self) -> None: self._path.parent.mkdir(parents=True, exist_ok=True) @@ -60,7 +72,15 @@ def _save(self) -> None: for c in self._clients.values() ] } - self._path.write_text(json.dumps(data, indent=2)) + # Write atomically with restrictive permissions: the file holds secret + # hashes and must not be world-readable. + tmp = self._path.with_suffix(self._path.suffix + ".tmp") + tmp.write_text(json.dumps(data, indent=2)) + try: + os.chmod(tmp, 0o600) + except OSError: + pass + os.replace(tmp, self._path) def create(self, name: str) -> tuple[str, str]: """Create a new client. Returns (client_id, client_secret).""" @@ -81,7 +101,8 @@ def verify(self, client_id: str, client_secret: str) -> bool: client = self._clients.get(client_id) if not client: return False - return client.client_secret_hash == _hash_secret(client_secret) + # Constant-time comparison to avoid leaking the hash via timing. + return hmac.compare_digest(client.client_secret_hash, _hash_secret(client_secret)) def list_clients(self) -> list[dict[str, Any]]: """List all registered clients (without secrets).""" diff --git a/src/tarkamcp/ilo/client.py b/src/tarkamcp/ilo/client.py index 30ec16f..ccfe889 100644 --- a/src/tarkamcp/ilo/client.py +++ b/src/tarkamcp/ilo/client.py @@ -89,7 +89,7 @@ async def _call_ilo(self, method: str, **kwargs: Any) -> Any: def _sync_call() -> Any: ilo = hpilo.Ilo( - f"localhost", + "localhost", port=local_port, login=ilo_cfg.user, password=ilo_cfg.password, @@ -97,7 +97,7 @@ def _sync_call() -> Any: ) return getattr(ilo, method)(**kwargs) - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() return await loop.run_in_executor(None, _sync_call) async def get_server_info(self) -> dict[str, Any]: diff --git a/src/tarkamcp/proxmox/client.py b/src/tarkamcp/proxmox/client.py index bd4223b..87cdd84 100644 --- a/src/tarkamcp/proxmox/client.py +++ b/src/tarkamcp/proxmox/client.py @@ -33,12 +33,6 @@ def _get_connection(self, node_name: str) -> ProxmoxAPI: self._connections[node_name] = conn return conn - def _resolve_node(self, node: str | None) -> str: - """Return the node name, defaulting to pve1 if not specified.""" - if node: - return node - return self._config.pve_nodes[0].name - def api_call(self, node_name: str, method: str, path: str, **kwargs: Any) -> Any: """Execute an API call against a Proxmox node. diff --git a/src/tarkamcp/proxmox/monitoring.py b/src/tarkamcp/proxmox/monitoring.py index 15d4c4c..a7bb970 100644 --- a/src/tarkamcp/proxmox/monitoring.py +++ b/src/tarkamcp/proxmox/monitoring.py @@ -19,24 +19,40 @@ def proxmox_list_nodes() -> dict[str, Any]: If a node appears offline, use ilo_health_status to check if it's a hardware issue, or ssh_exec_command to try reaching it directly. """ - results = [] + # GET /nodes returns the full cluster view from whichever member answers, + # so we stop at the first reachable configured node. Nodes we can't reach + # via their API are still reported as "unreachable" so the caller knows + # which credentials are stale. + results: dict[str, dict[str, Any]] = {} + unreachable: list[dict[str, Any]] = [] + for node_name in client.configured_nodes: data = client.get(node_name, "nodes") if isinstance(data, dict) and "error" in data: - results.append({"name": node_name, "status": "unreachable", "error": data["error"]}) - elif isinstance(data, list): + unreachable.append({"name": node_name, "status": "unreachable", "error": data["error"]}) + continue + if isinstance(data, list): for node in data: - results.append({ - "name": node.get("node"), + name = node.get("node") + if not name or name in results: + continue + results[name] = { + "name": name, "status": node.get("status", "unknown"), "cpu": round(node.get("cpu", 0) * 100, 1), "memory_used_gb": round(node.get("mem", 0) / 1073741824, 1), "memory_total_gb": round(node.get("maxmem", 0) / 1073741824, 1), "uptime_hours": round(node.get("uptime", 0) / 3600, 1), - }) - else: - results.append({"name": node_name, "status": "unknown", "raw": str(data)}) - return {"nodes": results} + } + break + unreachable.append({"name": node_name, "status": "unknown", "raw": str(data)}) + + # Surface any configured-but-unreachable node that didn't appear in the + # cluster view (e.g., single-node setup where pve2 is down). + for entry in unreachable: + results.setdefault(entry["name"], entry) + + return {"nodes": list(results.values())} @mcp.tool() def proxmox_node_status(node: str) -> dict[str, Any]: diff --git a/src/tarkamcp/proxmox/system.py b/src/tarkamcp/proxmox/system.py index 1789364..07df9fe 100644 --- a/src/tarkamcp/proxmox/system.py +++ b/src/tarkamcp/proxmox/system.py @@ -27,6 +27,20 @@ class ExecSession: _exec_sessions: dict[str, ExecSession] = {} +# Drop finished sessions older than this (seconds) on each new insertion so the +# store can't grow unboundedly in a long-running server. +_EXEC_SESSION_TTL = 3600 + + +def _prune_exec_sessions() -> None: + now = time.time() + stale = [ + eid + for eid, s in _exec_sessions.items() + if s.status != "running" and now - s.started_at > _EXEC_SESSION_TTL + ] + for eid in stale: + del _exec_sessions[eid] def _detect_vm_type(client: ProxmoxClient, node: str, vmid: int) -> str | None: @@ -213,6 +227,7 @@ def proxmox_exec_command_async(node: str, vmid: int, command: str) -> dict[str, # LXC has no API exec endpoint; surface the actionable error up-front. return _exec_lxc_unsupported(vmid) + _prune_exec_sessions() exec_id = str(uuid.uuid4())[:8] session = ExecSession( exec_id=exec_id, diff --git a/src/tarkamcp/ssh/client.py b/src/tarkamcp/ssh/client.py index d006dc8..ddc8ab4 100644 --- a/src/tarkamcp/ssh/client.py +++ b/src/tarkamcp/ssh/client.py @@ -27,6 +27,18 @@ class SSHExecSession: _ssh_tasks: set[asyncio.Task[None]] = set() _connection_cache: dict[str, tuple[asyncssh.SSHClientConnection, float]] = {} _CONNECTION_TTL = 300 # 5 minutes +_SSH_SESSION_TTL = 3600 # drop completed sessions older than this + + +def _prune_ssh_sessions() -> None: + now = time.time() + stale = [ + eid + for eid, s in _ssh_sessions.items() + if s.status != "running" and now - s.started_at > _SSH_SESSION_TTL + ] + for eid in stale: + del _ssh_sessions[eid] class SSHClient: @@ -115,6 +127,7 @@ async def exec_command(self, host: str, command: str, timeout: int = 60) -> dict async def exec_command_async(self, host: str, command: str) -> str: """Start a long-running command and return an exec_id.""" + _prune_ssh_sessions() exec_id = str(uuid.uuid4())[:8] session = SSHExecSession(exec_id=exec_id, host=host, command=command) _ssh_sessions[exec_id] = session From 0b3b008608cace1a0315f2e11fd09b0ea3c59c51 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 20:03:31 +0200 Subject: [PATCH 012/155] Use Python venv for install and update systemd unit Install script now provisions python3/python3-venv, creates a dedicated virtualenv at /opt/tarkamcp/.venv, installs a /usr/local/bin/tarkamcp wrapper, and the systemd unit launches the server via `serve` from the venv instead of the system python with the removed `--http` flag. Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/install.sh | 45 +++++++++++++++++++++++++++++++---------- deploy/tarkamcp.service | 4 ++-- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 79a9bdd..addc8d3 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,16 +1,28 @@ #!/bin/bash # TarkaMCP - Installation rapide sur un noeud Proxmox -# Usage: curl -sSL ... | bash OU bash deploy/install.sh +# Usage: bash deploy/install.sh set -e INSTALL_DIR="/opt/tarkamcp" REPO="https://github.com/Showdown76py/TarkaMCP.git" +VENV_DIR="$INSTALL_DIR/.venv" echo "=== TarkaMCP - Installation ===" -# 1. Cloner ou mettre à jour -if [ -d "$INSTALL_DIR" ]; then +# 1. Dépendances système +if ! command -v python3 >/dev/null 2>&1; then + echo "[*] Installation de python3..." + apt-get install -y python3 python3-venv python3-pip git +fi + +if ! python3 -c "import venv" 2>/dev/null; then + echo "[*] Installation de python3-venv..." + apt-get install -y python3-venv +fi + +# 2. Cloner ou mettre à jour +if [ -d "$INSTALL_DIR/.git" ]; then echo "[*] Mise à jour de TarkaMCP..." cd "$INSTALL_DIR" && git pull else @@ -19,24 +31,35 @@ else cd "$INSTALL_DIR" fi -# 2. Installer les dépendances +# 3. Environnement virtuel Python +if [ ! -d "$VENV_DIR" ]; then + echo "[*] Création du virtual env Python..." + python3 -m venv "$VENV_DIR" +fi + +# 4. Installer les dépendances dans le venv echo "[*] Installation des dépendances Python..." -pip3 install -e . --quiet +"$VENV_DIR/bin/pip" install --upgrade pip --quiet +"$VENV_DIR/bin/pip" install -e . --quiet -# 3. Fichier .env +# 5. Fichier .env if [ ! -f "$INSTALL_DIR/.env" ]; then echo "[*] Création du fichier .env..." cp .env.example .env - - echo "" echo " .env créé. Remplis-le avec tes credentials Proxmox." - echo "" - echo "[!] Édite /opt/tarkamcp/.env pour ajouter tes credentials Proxmox." else echo "[*] .env existant conservé." fi -# 4. Service systemd +# 6. Wrapper tarkamcp dans /usr/local/bin +echo "[*] Installation du wrapper 'tarkamcp' dans /usr/local/bin..." +cat > /usr/local/bin/tarkamcp < Date: Thu, 16 Apr 2026 20:06:05 +0200 Subject: [PATCH 013/155] Make install.sh robust when python3-venv is missing Always refresh apt and install python3-venv plus the versioned variant (python3.X-venv) required on Debian. Recreate the venv when bin/pip is absent instead of trusting the directory's existence, so a half-created venv from a prior run no longer breaks the install. Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/install.sh | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index addc8d3..1f62fe2 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -11,15 +11,13 @@ VENV_DIR="$INSTALL_DIR/.venv" echo "=== TarkaMCP - Installation ===" # 1. Dépendances système -if ! command -v python3 >/dev/null 2>&1; then - echo "[*] Installation de python3..." - apt-get install -y python3 python3-venv python3-pip git -fi +echo "[*] Vérification des dépendances système..." +apt-get update -qq +apt-get install -y python3 python3-pip python3-venv git -if ! python3 -c "import venv" 2>/dev/null; then - echo "[*] Installation de python3-venv..." - apt-get install -y python3-venv -fi +# Paquet venv versionné (ex: python3.11-venv sur Debian 12) +PY_VER=$(python3 -c 'import sys; print(f"python{sys.version_info.major}.{sys.version_info.minor}")') +apt-get install -y "${PY_VER}-venv" 2>/dev/null || true # 2. Cloner ou mettre à jour if [ -d "$INSTALL_DIR/.git" ]; then @@ -32,8 +30,9 @@ else fi # 3. Environnement virtuel Python -if [ ! -d "$VENV_DIR" ]; then - echo "[*] Création du virtual env Python..." +if [ ! -x "$VENV_DIR/bin/pip" ]; then + echo "[*] (Re)création du virtual env Python..." + rm -rf "$VENV_DIR" python3 -m venv "$VENV_DIR" fi From d4aeae8faf5f61264716fd8e419bbaea6e76c5ea Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 20:19:49 +0200 Subject: [PATCH 014/155] Add OAuth 2.1 authorization_code + PKCE flow for browser clients Claude Web and other interactive MCP connectors require the authorization code flow with PKCE. Previously only client_credentials was supported, causing /authorize to return 401 and the connector to fail. - Add /oauth/authorize endpoint enforcing PKCE S256 and https redirect_uri (localhost allowed for development). - Extend /oauth/token to support grant_type=authorization_code with one-time code consumption bound to client_id, redirect_uri, and PKCE code_verifier. client_credentials remains for script/server use. - Update OAuth metadata to advertise both grants, code response type, and S256 as the only PKCE method. - Explicit /oauth/register endpoint returns 403 with a clear message: dynamic client registration is disabled, clients must be provisioned via `tarkamcp auth create`. - Filter form values to str to avoid UploadFile leaking into credential checks. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/__main__.py | 130 +++++++++++++++++++++++++++++++-------- src/tarkamcp/auth.py | 95 +++++++++++++++++++++++++++- 2 files changed, 197 insertions(+), 28 deletions(-) diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py index d75c85a..9a1f7ef 100644 --- a/src/tarkamcp/__main__.py +++ b/src/tarkamcp/__main__.py @@ -112,33 +112,81 @@ def _run_http(mcp, host: str, port: int): from starlette.responses import JSONResponse, Response from starlette.routing import Mount, Route - from .auth import ClientStore, TokenStore + from urllib.parse import urlencode, urlparse + + from .auth import ClientStore, CodeStore, TokenStore clients_file = os.environ.get("TARKAMCP_CLIENTS_FILE") client_store = ClientStore(Path(clients_file) if clients_file else None) token_store = TokenStore() + code_store = CodeStore() async def oauth_metadata(request: Request) -> Response: scheme = request.headers.get("x-forwarded-proto", request.url.scheme) host_header = request.headers.get("x-forwarded-host", request.headers.get("host", "localhost")) issuer = f"{scheme}://{host_header}" - # Note: `response_types_supported` is omitted on purpose. It describes - # the authorization-code flow's response_type; it has no meaning for - # the client_credentials grant we advertise. + # registration_endpoint is intentionally omitted: dynamic client + # registration is disabled, clients must be provisioned via CLI. return JSONResponse({ "issuer": issuer, + "authorization_endpoint": f"{issuer}/oauth/authorize", "token_endpoint": f"{issuer}/oauth/token", - "grant_types_supported": ["client_credentials"], + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "client_credentials"], + "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["client_secret_post"], }) + async def oauth_authorize(request: Request) -> Response: + params = request.query_params + response_type = params.get("response_type", "") + client_id = params.get("client_id", "") + redirect_uri = params.get("redirect_uri", "") + state = params.get("state", "") + code_challenge = params.get("code_challenge", "") + code_challenge_method = params.get("code_challenge_method", "") + + # Pre-redirect validation: until client_id + redirect_uri are trusted, + # errors must be rendered directly, never redirected (OAuth 2.1 §4.1.2.1). + if response_type != "code": + return JSONResponse({"error": "unsupported_response_type"}, status_code=400) + if not client_id or not client_store.exists(client_id): + return JSONResponse({"error": "unauthorized_client"}, status_code=400) + parsed = urlparse(redirect_uri) + if parsed.scheme not in ("https", "http") or not parsed.netloc: + return JSONResponse( + {"error": "invalid_request", "error_description": "redirect_uri must be an absolute URL"}, + status_code=400, + ) + # Only allow http for localhost (dev); everything else must be https. + if parsed.scheme == "http" and parsed.hostname not in ("localhost", "127.0.0.1", "::1"): + return JSONResponse( + {"error": "invalid_request", "error_description": "redirect_uri must use https"}, + status_code=400, + ) + if not code_challenge or code_challenge_method != "S256": + return JSONResponse( + {"error": "invalid_request", "error_description": "PKCE with S256 is required"}, + status_code=400, + ) + + code = code_store.issue(client_id, redirect_uri, code_challenge, code_challenge_method) + + query = {"code": code} + if state: + query["state"] = state + sep = "&" if parsed.query else "?" + location = f"{redirect_uri}{sep}{urlencode(query)}" + return Response(status_code=302, headers={"Location": location}) + async def oauth_token(request: Request) -> Response: try: if request.headers.get("content-type", "").startswith("application/json"): - body = await request.json() + raw = await request.json() + body: dict[str, str] = {k: v for k, v in raw.items() if isinstance(v, str)} else: form = await request.form() - body = dict(form) + body = {k: v for k, v in form.items() if isinstance(v, str)} except Exception: return JSONResponse({"error": "invalid_request"}, status_code=400) @@ -146,30 +194,55 @@ async def oauth_token(request: Request) -> Response: client_id = body.get("client_id", "") client_secret = body.get("client_secret", "") - if grant_type != "client_credentials": - return JSONResponse( - {"error": "unsupported_grant_type"}, - status_code=400, - ) if not client_store.verify(client_id, client_secret): - return JSONResponse( - {"error": "invalid_client"}, - status_code=401, - ) - - token, expires_in = token_store.issue(client_id) - return JSONResponse({ - "access_token": token, - "token_type": "bearer", - "expires_in": expires_in, - }) + return JSONResponse({"error": "invalid_client"}, status_code=401) + + if grant_type == "client_credentials": + token, expires_in = token_store.issue(client_id) + return JSONResponse({ + "access_token": token, + "token_type": "bearer", + "expires_in": expires_in, + }) + + if grant_type == "authorization_code": + code = body.get("code", "") + redirect_uri = body.get("redirect_uri", "") + code_verifier = body.get("code_verifier", "") + if not code_store.consume(code, client_id, redirect_uri, code_verifier): + return JSONResponse({"error": "invalid_grant"}, status_code=400) + token, expires_in = token_store.issue(client_id) + return JSONResponse({ + "access_token": token, + "token_type": "bearer", + "expires_in": expires_in, + }) + + return JSONResponse({"error": "unsupported_grant_type"}, status_code=400) + + async def oauth_register(_request: Request) -> Response: + # Dynamic client registration is disabled by design. Respond explicitly + # instead of letting the request fall through to a generic 404. + return JSONResponse( + { + "error": "registration_not_supported", + "error_description": "Dynamic client registration is disabled. Ask the administrator to provision a client via `tarkamcp auth create`.", + }, + status_code=403, + ) async def health(_request: Request) -> Response: return JSONResponse({"status": "ok", "server": "tarkamcp"}) async def auth_middleware(request: Request, call_next): path = request.url.path - if path in ("/health", "/oauth/token", "/.well-known/oauth-authorization-server"): + if path in ( + "/health", + "/oauth/token", + "/oauth/authorize", + "/oauth/register", + "/.well-known/oauth-authorization-server", + ): return await call_next(request) authorization = request.headers.get("authorization", "") @@ -195,7 +268,9 @@ async def auth_middleware(request: Request, call_next): routes=[ Route("/health", health), Route("/.well-known/oauth-authorization-server", oauth_metadata), + Route("/oauth/authorize", oauth_authorize, methods=["GET"]), Route("/oauth/token", oauth_token, methods=["POST"]), + Route("/oauth/register", oauth_register, methods=["POST"]), Mount("/", app=mcp_app), ], middleware=[Middleware(BaseHTTPMiddleware, dispatch=auth_middleware)], @@ -204,9 +279,10 @@ async def auth_middleware(request: Request, call_next): n_clients = len(client_store.list_clients()) print(f"TarkaMCP starting on {host}:{port}") print(f"Clients: {n_clients}") - print(f"MCP: http://{host}:{port}/mcp") - print(f"OAuth: http://{host}:{port}/oauth/token") - print(f"Health: http://{host}:{port}/health") + print(f"MCP: http://{host}:{port}/mcp") + print(f"Authorize: http://{host}:{port}/oauth/authorize") + print(f"Token: http://{host}:{port}/oauth/token") + print(f"Health: http://{host}:{port}/health") if n_clients == 0: print(f"\nAucun client ! Créer avec : tarkamcp auth create --name 'Mon Client'") uvicorn.run(app, host=host, port=port, log_level="info") diff --git a/src/tarkamcp/auth.py b/src/tarkamcp/auth.py index c49ddd4..48c59c0 100644 --- a/src/tarkamcp/auth.py +++ b/src/tarkamcp/auth.py @@ -1,7 +1,17 @@ -"""OAuth 2.1 client credentials management for TarkaMCP HTTP mode.""" +"""OAuth 2.1 client & token management for TarkaMCP HTTP mode. + +Supports two grants on top of a pre-provisioned client store: +- ``client_credentials`` for non-interactive clients (scripts, server-to-server) +- ``authorization_code`` with mandatory PKCE (S256) for browser-based clients + such as Claude Web / mobile connectors + +Dynamic client registration (RFC 7591) is intentionally NOT supported: clients +must be created out-of-band via ``tarkamcp auth create``. +""" from __future__ import annotations +import base64 import hashlib import hmac import json @@ -32,10 +42,34 @@ class AccessToken: expires_at: float +@dataclass +class AuthCode: + code: str + client_id: str + redirect_uri: str + code_challenge: str + code_challenge_method: str + expires_at: float + + def _hash_secret(secret: str) -> str: return hashlib.sha256(secret.encode()).hexdigest() +def verify_pkce(code_verifier: str, code_challenge: str, method: str) -> bool: + """Verify a PKCE code_verifier against the stored code_challenge. + + OAuth 2.1 forbids the ``plain`` method; only S256 is accepted. + """ + if method != "S256": + return False + if not code_verifier or not code_challenge: + return False + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() + computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return hmac.compare_digest(computed, code_challenge) + + class ClientStore: """Persistent client credential storage backed by a JSON file.""" @@ -104,6 +138,9 @@ def verify(self, client_id: str, client_secret: str) -> bool: # Constant-time comparison to avoid leaking the hash via timing. return hmac.compare_digest(client.client_secret_hash, _hash_secret(client_secret)) + def exists(self, client_id: str) -> bool: + return client_id in self._clients + def list_clients(self) -> list[dict[str, Any]]: """List all registered clients (without secrets).""" return [ @@ -158,3 +195,59 @@ def _cleanup(self) -> None: expired = [t for t, at in self._tokens.items() if now > at.expires_at] for t in expired: del self._tokens[t] + + +class CodeStore: + """In-memory single-use authorization-code store with PKCE binding.""" + + CODE_TTL = 60 # OAuth 2.1 recommends very short codes (<= 60s). + + def __init__(self) -> None: + self._codes: dict[str, AuthCode] = {} + + def issue( + self, + client_id: str, + redirect_uri: str, + code_challenge: str, + code_challenge_method: str, + ) -> str: + code = secrets.token_urlsafe(32) + self._codes[code] = AuthCode( + code=code, + client_id=client_id, + redirect_uri=redirect_uri, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + expires_at=time.time() + self.CODE_TTL, + ) + self._cleanup() + return code + + def consume( + self, + code: str, + client_id: str, + redirect_uri: str, + code_verifier: str, + ) -> bool: + """Validate and one-time consume a code. + + The code is popped unconditionally so a replay attempt cannot retry. + """ + auth_code = self._codes.pop(code, None) + if not auth_code: + return False + if time.time() > auth_code.expires_at: + return False + if auth_code.client_id != client_id: + return False + if auth_code.redirect_uri != redirect_uri: + return False + return verify_pkce(code_verifier, auth_code.code_challenge, auth_code.code_challenge_method) + + def _cleanup(self) -> None: + now = time.time() + expired = [c for c, ac in self._codes.items() if now > ac.expires_at] + for c in expired: + del self._codes[c] From 43ba161b38caa636445443cc7f12a6b27e003922 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 20:22:46 +0200 Subject: [PATCH 015/155] Forward MCP app lifespan to Starlette root The FastMCP streamable-HTTP app initializes its session manager task group via its own lifespan. When it is Mount()ed under a parent Starlette, only the parent's lifespan runs, so requests to /mcp failed with "Task group is not initialized. Make sure to use run()." Wrap the child lifespan in the parent's and pass it via lifespan=. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/__main__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py index 9a1f7ef..4860a11 100644 --- a/src/tarkamcp/__main__.py +++ b/src/tarkamcp/__main__.py @@ -105,6 +105,7 @@ def _cmd_auth(args): def _run_http(mcp, host: str, port: int): """Run the MCP server over Streamable HTTP with OAuth client credentials.""" import uvicorn + from contextlib import asynccontextmanager from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware @@ -264,6 +265,15 @@ async def auth_middleware(request: Request, call_next): mcp_app = mcp.streamable_http_app() + # The MCP streamable-HTTP app starts its session manager task group in its + # own lifespan. When we Mount it under a parent Starlette, only the parent + # app's lifespan runs — so we forward the child's lifespan explicitly, + # otherwise requests fail with "Task group is not initialized". + @asynccontextmanager + async def lifespan(_app): + async with mcp_app.router.lifespan_context(_app): + yield + app = Starlette( routes=[ Route("/health", health), @@ -274,6 +284,7 @@ async def auth_middleware(request: Request, call_next): Mount("/", app=mcp_app), ], middleware=[Middleware(BaseHTTPMiddleware, dispatch=auth_middleware)], + lifespan=lifespan, ) n_clients = len(client_store.list_clients()) From 6d536c1ceb09048441e798a229e0c316f02409bd Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 20:28:17 +0200 Subject: [PATCH 016/155] Configure MCP DNS-rebinding allowlist via env vars Without an explicit TransportSecuritySettings, the MCP SDK defaults to only allowing localhost Host headers, which returned 421 Misdirected Request for any request hitting the public hostname (e.g. mcp.example.com). Expose TARKAMCP_ALLOWED_HOSTS and TARKAMCP_ALLOWED_ORIGINS (comma separated) so operators declare their public host(s). Defaults keep localhost working and preauthorize Claude/ChatGPT/Gemini origins. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 6 ++++++ src/tarkamcp/server.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/.env.example b/.env.example index fccb707..aeadad0 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,9 @@ PVE_VERIFY_SSL=false # TARKAMCP_CLIENTS_FILE=/opt/tarkamcp/clients.json # TARKAMCP_PORT=8420 # TARKAMCP_HOST=0.0.0.0 + +# DNS-rebinding protection (MCP SDK). The public hostname this server is +# exposed on MUST be listed here, otherwise requests get 421 Misdirected. +# Comma-separated. Wildcard ports with ":*" are supported. +TARKAMCP_ALLOWED_HOSTS=mcp.example.com,127.0.0.1:*,localhost:*,[::1]:* +# TARKAMCP_ALLOWED_ORIGINS=https://claude.ai,https://chat.openai.com,https://gemini.google.com diff --git a/src/tarkamcp/server.py b/src/tarkamcp/server.py index ec1e09f..dc2ac68 100644 --- a/src/tarkamcp/server.py +++ b/src/tarkamcp/server.py @@ -1,4 +1,7 @@ +import os + from mcp.server.fastmcp import FastMCP +from mcp.server.transport_security import TransportSecuritySettings from .config import Config from .proxmox.client import ProxmoxClient @@ -15,6 +18,26 @@ ssh_client = SSHClient(config) ilo_client = ILOClient(config) + +def _csv_env(name: str, default: list[str]) -> list[str]: + raw = os.environ.get(name, "").strip() + if not raw: + return default + return [v.strip() for v in raw.split(",") if v.strip()] + + +# DNS-rebinding protection: the MCP SDK rejects any Host header that is not +# explicitly allowlisted. The public hostname this server is reverse-proxied +# behind (e.g. mcp.example.com) MUST be set via TARKAMCP_ALLOWED_HOSTS. +_allowed_hosts = _csv_env( + "TARKAMCP_ALLOWED_HOSTS", + ["127.0.0.1:*", "localhost:*", "[::1]:*"], +) +_allowed_origins = _csv_env( + "TARKAMCP_ALLOWED_ORIGINS", + ["https://claude.ai", "https://chat.openai.com", "https://gemini.google.com"], +) + mcp = FastMCP( "tarkamcp", instructions=( @@ -24,6 +47,11 @@ "and ssh_* tools for direct shell access as fallback. " "Start with proxmox_list_nodes to see cluster status." ), + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=_allowed_hosts, + allowed_origins=_allowed_origins, + ), ) From cc724c97e45d808c43ca06b0989cd665782aae7a Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 20:31:58 +0200 Subject: [PATCH 017/155] Document TARKAMCP_ALLOWED_HOSTS and auth error troubleshooting The DNS-rebinding allowlist is now a required-in-prod env var: without the public hostname declared, every request returns 421. Call it out in the Cloudflare section, the .env reference block, and the troubleshooting table. Also document the 'unauthorized' error on /authorize when the client ID has not been provisioned. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index a6b8f84..4bc7152 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,9 @@ Dans le dashboard Cloudflare Zero Trust, ajouter un tunnel : | **Hostname** | `mcp.example.com` | | **Service** | `http://localhost:8420` | +Puis déclarer ce hostname dans `.env` via `TARKAMCP_ALLOWED_HOSTS`, sinon le +SDK MCP renverra `421 Misdirected Request` (protection DNS-rebinding). + ### Gérer les clients ```bash @@ -281,6 +284,11 @@ SSH_PASSWORD=xxxxx # OPTIONS PVE_VERIFY_SSL=false # TARKAMCP_PORT=8420 + +# OBLIGATOIRE en prod -- hostnames publics autorisés par la protection +# DNS-rebinding du SDK MCP (sinon 421 Misdirected Request). Virgules. +TARKAMCP_ALLOWED_HOSTS=mcp.example.com,127.0.0.1:*,localhost:*,[::1]:* +# TARKAMCP_ALLOWED_ORIGINS=https://claude.ai,https://chat.openai.com,https://gemini.google.com ``` Modules chargés conditionnellement : sans SSH → pas de `ssh_*`, sans iLO → pas de `ilo_*`. @@ -382,6 +390,8 @@ python tests/test_integration.py --test-vmid 9999 | `iLO ... unreachable` | pve1 down ou iLO injoignable | Vérifier pve1 d'abord | | `SSH connection failed` | Auth SSH désactivée | `grep PasswordAuthentication /etc/ssh/sshd_config` | | `invalid_client` | Mauvais Client ID/Secret | `tarkamcp auth list` pour vérifier | +| `421 Misdirected Request` | Hostname public absent de l'allowlist | Ajouter le domaine à `TARKAMCP_ALLOWED_HOSTS` dans `.env` puis redémarrer | +| `{"error":"unauthorized"}` sur `/authorize` | Client ID inexistant côté serveur | Créer le client avec `tarkamcp auth create`, puis recoller l'ID dans le connecteur | --- From eb2c448724762aad0104d68ecf2b697b2350199c Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 20:42:12 +0200 Subject: [PATCH 018/155] Fix iLO client falling back to hponcfg local mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit python-hpilo hardcodes the literal string "localhost" as a signal to switch to ILO_LOCAL mode — it shells out to /sbin/hponcfg and ignores host, port, login and password. Because we tunnel via SSH and pointed hpilo.Ilo at "localhost", every iLO tool error was "hponcfg not installed" on machines without the HPE utility. Use 127.0.0.1 instead so the RIBCL over HTTPS path is taken through the tunnel. No other change needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/ilo/client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/tarkamcp/ilo/client.py b/src/tarkamcp/ilo/client.py index ccfe889..4db58bf 100644 --- a/src/tarkamcp/ilo/client.py +++ b/src/tarkamcp/ilo/client.py @@ -88,8 +88,13 @@ async def _call_ilo(self, method: str, **kwargs: Any) -> Any: raise ILONotConfiguredError() def _sync_call() -> Any: + # IMPORTANT: use 127.0.0.1, NOT "localhost". python-hpilo treats the + # literal string "localhost" as a signal to switch to ILO_LOCAL mode + # (which shells out to the hponcfg utility on the local machine) and + # ignores host/port/credentials. We need remote RIBCL over our SSH + # tunnel, so bind to the loopback IP instead. ilo = hpilo.Ilo( - "localhost", + "127.0.0.1", port=local_port, login=ilo_cfg.user, password=ilo_cfg.password, From 690312a8d613cbda26df3306001b1c400f050895 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 21:04:20 +0200 Subject: [PATCH 019/155] Enforce TOTP 2FA for every OAuth client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second authentication factor (Google Authenticator / Authy compatible TOTP) on top of client_id/client_secret. The server is publicly exposed via Cloudflare Tunnel and client_secret leaks would let an attacker pilot the Proxmox infra end-to-end; 2FA closes that. Enrollment `tarkamcp auth create` now also generates a base32 TOTP seed and prints the otpauth:// URI plus an ASCII QR code to scan once into the authenticator app. Seed is stored plaintext in clients.json (0600) — TOTP validation requires the original secret, not a hash. Browser flow (Claude Web, ChatGPT MCP) /oauth/authorize is split into GET (renders an HTML form asking for the 6-digit code, with hidden inputs preserving the original OAuth params) and POST (revalidates params, validates TOTP, then issues the authorization code). The connector never sees the popup — it's rendered during the redirect, like a consent screen. Non-interactive flow (client_credentials for ChatGPT curl / Gemini) /oauth/token now requires a `totp` field alongside client_id and client_secret for client_credentials. Missing or invalid TOTP returns invalid_grant. The authorization_code grant does not require TOTP because the code itself proves the user passed 2FA at /authorize. Hardening 5 failed TOTP attempts per client_id trigger a 5-minute lockout (in-memory counter, reset on first success). pyotp's valid_window=1 tolerates ±30 s clock drift. The HTML form uses html.escape on all dynamic fields and keeps PKCE S256 mandatory. Migration clients.json entries without a totp_secret are filtered at load, logged to stderr, and removed from disk. Operators must recreate every existing client after upgrade — there is no backwards-compat path. Docs README updated: new 2FA section in the create step, popup mention for Claude, `totp=` added to every curl/Gemini example (with a warning against embedding the seed in code), new troubleshooting rows for invalid_grant / lockout / silent revocation after upgrade. Deps pyotp and qrcode added to pyproject.toml. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 35 +++++-- pyproject.toml | 2 + src/tarkamcp/__main__.py | 202 +++++++++++++++++++++++++++++++++++---- src/tarkamcp/auth.py | 57 ++++++++++- 4 files changed, 266 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 4bc7152..4d159d1 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ nano /opt/tarkamcp/.env Remplir au minimum `PVE1_HOST`, `PVE1_TOKEN_ID`, `PVE1_TOKEN_SECRET` (voir [Configuration .env](#configuration-env)). -### 3. Créer un client OAuth +### 3. Créer un client OAuth (avec 2FA) ```bash tarkamcp auth create --name "Claude Web" @@ -98,9 +98,19 @@ tarkamcp auth create --name "Claude Web" ``` Client ID: tarkamcp_a1b2c3... Client Secret: sk_d4e5f6... + + --- 2FA / Google Authenticator --- + Scanne ce QR code dans ton app (Google Authenticator, Authy, 1Password) : + + █▀▀▀▀▀█ ▄▀ ▄█ █▀▀▀▀▀█ + █ ███ █ ▀ ▄▄▄ █ ███ █ + ... + + Secret manuel : JBSWY3DPEHPK3PXP + URI otpauth : otpauth://totp/TarkaMCP:tarkamcp_...?secret=...&issuer=TarkaMCP ``` -Conserver ces credentials -- le secret ne sera plus affiché. +**Important** : le Client Secret ET le secret TOTP ne sont affichés qu'une seule fois. Scanne le QR tout de suite dans ton app d'authentification, sinon tu devras révoquer et recréer le client. ### 4. Démarrer le serveur @@ -150,38 +160,46 @@ tarkamcp auth revoke tarkamcp_abc123... - **OAuth Client Secret** : `sk_d4e5f6...` 3. **Add** +À la connexion, une page TarkaMCP s'ouvre dans ton navigateur et demande le code 2FA à 6 chiffres depuis Google Authenticator. Saisis-le, tu es redirigé vers Claude automatiquement. Le token dure 24 h, après quoi Claude redemande le code. + ### ChatGPT 1. **Settings** > **Developer Mode** > **MCP Servers** 2. URL : `https://mcp.example.com/mcp` -3. Obtenir un bearer token : +3. Obtenir un bearer token (TOTP requis à chaque refresh, toutes les 24 h) : ```bash + TOTP=$(oathtool --totp -b "$TOTP_SECRET") # ou tape-le depuis l'app curl -X POST https://mcp.example.com/oauth/token \ - -d "grant_type=client_credentials&client_id=ID&client_secret=SECRET" + -d "grant_type=client_credentials&client_id=ID&client_secret=SECRET&totp=$TOTP" ``` 4. Utiliser l'`access_token` retourné comme bearer token ### Gemini CLI ```bash +TOTP=$(oathtool --totp -b "$TOTP_SECRET") TOKEN=$(curl -s -X POST https://mcp.example.com/oauth/token \ - -d "grant_type=client_credentials&client_id=ID&client_secret=SECRET" \ + -d "grant_type=client_credentials&client_id=ID&client_secret=SECRET&totp=$TOTP" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") gemini mcp add tarkamcp --url https://mcp.example.com/mcp \ --header "Authorization: Bearer $TOKEN" ``` +Le token étant valide 24 h, il faut relancer ce bloc (avec un nouveau code TOTP) une fois par jour. + ### Gemini API ```python -import requests +import requests, pyotp from google import genai +totp = pyotp.TOTP("JBSWY3DPEHPK3PXP").now() # le secret affiché à la création token = requests.post("https://mcp.example.com/oauth/token", data={ "grant_type": "client_credentials", "client_id": "tarkamcp_...", "client_secret": "sk_...", + "totp": totp, }).json()["access_token"] client = genai.Client() @@ -195,6 +213,8 @@ response = client.models.generate_content( ) ``` +> Stocker le secret TOTP dans le code va à l'encontre de l'intérêt du 2FA. Préfère un vault (1Password CLI, `pass`, secret manager) ou tape le code à la main. + --- ## Configuration Proxmox @@ -392,6 +412,9 @@ python tests/test_integration.py --test-vmid 9999 | `invalid_client` | Mauvais Client ID/Secret | `tarkamcp auth list` pour vérifier | | `421 Misdirected Request` | Hostname public absent de l'allowlist | Ajouter le domaine à `TARKAMCP_ALLOWED_HOSTS` dans `.env` puis redémarrer | | `{"error":"unauthorized"}` sur `/authorize` | Client ID inexistant côté serveur | Créer le client avec `tarkamcp auth create`, puis recoller l'ID dans le connecteur | +| `invalid_grant` + `missing or invalid totp` | Code 2FA faux, expiré (>30 s), ou déjà utilisé | Générer un nouveau code dans l'app. Vérifier l'horloge du serveur vs celle du téléphone (`timedatectl`). | +| Page 2FA affiche "Trop de tentatives" | 5 codes faux consécutifs → lockout 5 min | Attendre. Le compteur se réinitialise à la prochaine validation correcte. | +| Clients silencieusement révoqués après update | Migration 2FA : les anciens clients sans TOTP sont rejetés au démarrage | Regarder `journalctl -u tarkamcp` pour la liste, recréer via `tarkamcp auth create` | --- diff --git a/pyproject.toml b/pyproject.toml index e9cd9f8..74227c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,8 @@ dependencies = [ "asyncssh", "python-dotenv", "pyyaml", + "pyotp", + "qrcode", ] [project.scripts] diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py index 4860a11..e241c41 100644 --- a/src/tarkamcp/__main__.py +++ b/src/tarkamcp/__main__.py @@ -62,7 +62,17 @@ def _cmd_auth(args): store = ClientStore(getattr(args, "clients_file", None)) if args.auth_command == "create": - client_id, client_secret = store.create(args.name) + import pyotp + import qrcode + + client_id, client_secret, totp_secret = store.create(args.name) + provisioning_uri = pyotp.TOTP(totp_secret).provisioning_uri( + name=client_id, issuer_name="TarkaMCP" + ) + qr = qrcode.QRCode(border=1) + qr.add_data(provisioning_uri) + qr.make(fit=True) + print() print(" Client créé avec succès !") print() @@ -70,11 +80,16 @@ def _cmd_auth(args): print(f" Client ID: {client_id}") print(f" Client Secret: {client_secret}") print() - print(" Utilise ces credentials dans :") - print(" - Claude web/mobile : champs OAuth Client ID / Client Secret") - print(" - ChatGPT / Gemini : POST /oauth/token pour obtenir un bearer token") + print(" --- 2FA / Google Authenticator ---") + print(" Scanne ce QR code dans ton app (Google Authenticator, Authy, 1Password) :") + print() + qr.print_ascii(invert=True) + print() + print(f" Secret manuel (si le scan ne marche pas) : {totp_secret}") + print(f" URI otpauth : {provisioning_uri}") print() - print(" Le Client Secret ne sera plus affiché. Conserve-le maintenant.") + print(" Le Client Secret et le secret TOTP ne seront PLUS affichés.") + print(" Conserve-les maintenant, sinon tu devras recréer le client.") print() elif args.auth_command == "list": @@ -104,13 +119,15 @@ def _cmd_auth(args): def _run_http(mcp, host: str, port: int): """Run the MCP server over Streamable HTTP with OAuth client credentials.""" + import html + import time import uvicorn from contextlib import asynccontextmanager from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request - from starlette.responses import JSONResponse, Response + from starlette.responses import HTMLResponse, JSONResponse, Response from starlette.routing import Mount, Route from urllib.parse import urlencode, urlparse @@ -122,6 +139,32 @@ def _run_http(mcp, host: str, port: int): token_store = TokenStore() code_store = CodeStore() + # In-memory TOTP bruteforce guard: per client_id, (failures, cooldown_until). + # 5 failed attempts → 5-minute lockout. Reset on first success. + totp_fail_max = 5 + totp_lockout_seconds = 300 + totp_failures: dict[str, tuple[int, float]] = {} + + def totp_locked(client_id: str) -> bool: + entry = totp_failures.get(client_id) + if not entry: + return False + count, until = entry + if count < totp_fail_max: + return False + if time.time() >= until: + totp_failures.pop(client_id, None) + return False + return True + + def totp_record_failure(client_id: str) -> None: + count, _ = totp_failures.get(client_id, (0, 0.0)) + count += 1 + totp_failures[client_id] = (count, time.time() + totp_lockout_seconds) + + def totp_record_success(client_id: str) -> None: + totp_failures.pop(client_id, None) + async def oauth_metadata(request: Request) -> Response: scheme = request.headers.get("x-forwarded-proto", request.url.scheme) host_header = request.headers.get("x-forwarded-host", request.headers.get("host", "localhost")) @@ -138,8 +181,16 @@ async def oauth_metadata(request: Request) -> Response: "token_endpoint_auth_methods_supported": ["client_secret_post"], }) - async def oauth_authorize(request: Request) -> Response: - params = request.query_params + def _validate_authorize_params( + params: dict[str, str], + ) -> tuple[dict[str, str], Response | None]: + """Validate the standard OAuth2 authorize parameters. + + Returns ``(normalized, error_response)``. If ``error_response`` is not + None it must be returned directly without redirecting, because until + client_id and redirect_uri are trusted we cannot send the user back + to an attacker-controlled URL (OAuth 2.1 §4.1.2.1). + """ response_type = params.get("response_type", "") client_id = params.get("client_id", "") redirect_uri = params.get("redirect_uri", "") @@ -147,35 +198,123 @@ async def oauth_authorize(request: Request) -> Response: code_challenge = params.get("code_challenge", "") code_challenge_method = params.get("code_challenge_method", "") - # Pre-redirect validation: until client_id + redirect_uri are trusted, - # errors must be rendered directly, never redirected (OAuth 2.1 §4.1.2.1). if response_type != "code": - return JSONResponse({"error": "unsupported_response_type"}, status_code=400) + return {}, JSONResponse({"error": "unsupported_response_type"}, status_code=400) if not client_id or not client_store.exists(client_id): - return JSONResponse({"error": "unauthorized_client"}, status_code=400) + return {}, JSONResponse({"error": "unauthorized_client"}, status_code=400) parsed = urlparse(redirect_uri) if parsed.scheme not in ("https", "http") or not parsed.netloc: - return JSONResponse( + return {}, JSONResponse( {"error": "invalid_request", "error_description": "redirect_uri must be an absolute URL"}, status_code=400, ) - # Only allow http for localhost (dev); everything else must be https. if parsed.scheme == "http" and parsed.hostname not in ("localhost", "127.0.0.1", "::1"): - return JSONResponse( + return {}, JSONResponse( {"error": "invalid_request", "error_description": "redirect_uri must use https"}, status_code=400, ) if not code_challenge or code_challenge_method != "S256": - return JSONResponse( + return {}, JSONResponse( {"error": "invalid_request", "error_description": "PKCE with S256 is required"}, status_code=400, ) - code = code_store.issue(client_id, redirect_uri, code_challenge, code_challenge_method) + return ( + { + "response_type": response_type, + "client_id": client_id, + "redirect_uri": redirect_uri, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": code_challenge_method, + }, + None, + ) + + def _render_authorize_form( + normalized: dict[str, str], error: str | None = None, locked: bool = False + ) -> HTMLResponse: + client_name = client_store.get_name(normalized["client_id"]) or normalized["client_id"] + hidden = "\n".join( + f'' + for k, v in normalized.items() + ) + banner = "" + if locked: + banner = ( + '

Trop de tentatives. R\u00e9essaie dans 5 minutes.

' + ) + elif error: + banner = f'

{html.escape(error)}

' + + disabled = "disabled" if locked else "" + page = f""" + +TarkaMCP - Authentification 2FA + + +
+

TarkaMCP

+

Authentification \u00e0 deux facteurs pour {html.escape(client_name)}.

+{banner} +
+{hidden} + + +
+
+""" + return HTMLResponse(page) + + async def oauth_authorize_get(request: Request) -> Response: + normalized, err = _validate_authorize_params(dict(request.query_params)) + if err is not None: + return err + return _render_authorize_form( + normalized, locked=totp_locked(normalized["client_id"]) + ) + async def oauth_authorize_post(request: Request) -> Response: + form = await request.form() + body = {k: v for k, v in form.items() if isinstance(v, str)} + normalized, err = _validate_authorize_params(body) + if err is not None: + return err + + client_id = normalized["client_id"] + if totp_locked(client_id): + return _render_authorize_form(normalized, locked=True) + + code_totp = body.get("totp", "") + if not client_store.verify_totp(client_id, code_totp): + totp_record_failure(client_id) + return _render_authorize_form( + normalized, + error="Code incorrect. V\u00e9rifie l'horloge de ton t\u00e9l\u00e9phone.", + locked=totp_locked(client_id), + ) + totp_record_success(client_id) + + redirect_uri = normalized["redirect_uri"] + code = code_store.issue( + client_id, + redirect_uri, + normalized["code_challenge"], + normalized["code_challenge_method"], + ) query = {"code": code} - if state: - query["state"] = state + if normalized["state"]: + query["state"] = normalized["state"] + parsed = urlparse(redirect_uri) sep = "&" if parsed.query else "?" location = f"{redirect_uri}{sep}{urlencode(query)}" return Response(status_code=302, headers={"Location": location}) @@ -199,6 +338,28 @@ async def oauth_token(request: Request) -> Response: return JSONResponse({"error": "invalid_client"}, status_code=401) if grant_type == "client_credentials": + # 2FA mandatory on every client_credentials exchange (design choice: + # no non-interactive escape hatch, the operator must re-type a TOTP + # code at every 24 h token refresh). + if totp_locked(client_id): + return JSONResponse( + { + "error": "invalid_grant", + "error_description": "too many failed TOTP attempts, retry later", + }, + status_code=400, + ) + code_totp = body.get("totp", "") + if not client_store.verify_totp(client_id, code_totp): + totp_record_failure(client_id) + return JSONResponse( + { + "error": "invalid_grant", + "error_description": "missing or invalid totp", + }, + status_code=400, + ) + totp_record_success(client_id) token, expires_in = token_store.issue(client_id) return JSONResponse({ "access_token": token, @@ -278,7 +439,8 @@ async def lifespan(_app): routes=[ Route("/health", health), Route("/.well-known/oauth-authorization-server", oauth_metadata), - Route("/oauth/authorize", oauth_authorize, methods=["GET"]), + Route("/oauth/authorize", oauth_authorize_get, methods=["GET"]), + Route("/oauth/authorize", oauth_authorize_post, methods=["POST"]), Route("/oauth/token", oauth_token, methods=["POST"]), Route("/oauth/register", oauth_register, methods=["POST"]), Mount("/", app=mcp_app), diff --git a/src/tarkamcp/auth.py b/src/tarkamcp/auth.py index 48c59c0..9dcbe59 100644 --- a/src/tarkamcp/auth.py +++ b/src/tarkamcp/auth.py @@ -23,6 +23,8 @@ from pathlib import Path from typing import Any +import pyotp + CLIENTS_FILE = Path("/opt/tarkamcp/clients.json") @@ -33,6 +35,7 @@ class Client: client_secret_hash: str name: str created_at: float + totp_secret: str # base32-encoded TOTP seed; plaintext on purpose @dataclass @@ -90,8 +93,29 @@ def _load(self) -> None: file=sys.stderr, ) raise + + # Clients missing totp_secret predate the 2FA migration and are + # implicitly revoked. We log them, skip them, and rewrite the file + # below so they can't be re-loaded next boot. + revoked: list[str] = [] for c in data.get("clients", []): - self._clients[c["client_id"]] = Client(**c) + if not c.get("totp_secret"): + revoked.append(f"{c.get('client_id', '?')} ({c.get('name', '?')})") + continue + try: + self._clients[c["client_id"]] = Client(**c) + except TypeError: + # Unknown fields or missing required fields: treat as revoked. + revoked.append(f"{c.get('client_id', '?')} ({c.get('name', '?')})") + + if revoked: + print( + "WARNING: the following clients were revoked because they " + "predate the 2FA migration (no TOTP secret). Recreate them " + "with `tarkamcp auth create`: " + ", ".join(revoked), + file=sys.stderr, + ) + self._save() def _save(self) -> None: self._path.parent.mkdir(parents=True, exist_ok=True) @@ -102,6 +126,7 @@ def _save(self) -> None: "client_secret_hash": c.client_secret_hash, "name": c.name, "created_at": c.created_at, + "totp_secret": c.totp_secret, } for c in self._clients.values() ] @@ -116,19 +141,26 @@ def _save(self) -> None: pass os.replace(tmp, self._path) - def create(self, name: str) -> tuple[str, str]: - """Create a new client. Returns (client_id, client_secret).""" + def create(self, name: str) -> tuple[str, str, str]: + """Create a new client. + + Returns ``(client_id, client_secret, totp_secret)``. The TOTP secret is + base32-encoded and meant to be displayed once so the operator can + register it in Google Authenticator / Authy / 1Password. + """ client_id = "tarkamcp_" + secrets.token_hex(8) client_secret = "sk_" + secrets.token_hex(32) + totp_secret = pyotp.random_base32() self._clients[client_id] = Client( client_id=client_id, client_secret_hash=_hash_secret(client_secret), name=name, created_at=time.time(), + totp_secret=totp_secret, ) self._save() - return client_id, client_secret + return client_id, client_secret, totp_secret def verify(self, client_id: str, client_secret: str) -> bool: """Verify client credentials.""" @@ -141,6 +173,23 @@ def verify(self, client_id: str, client_secret: str) -> bool: def exists(self, client_id: str) -> bool: return client_id in self._clients + def get_name(self, client_id: str) -> str | None: + client = self._clients.get(client_id) + return client.name if client else None + + def verify_totp(self, client_id: str, code: str) -> bool: + """Validate a TOTP code for a given client. + + Uses ``valid_window=1`` so a ±30 s clock drift between the server and + the authenticator app is tolerated. + """ + client = self._clients.get(client_id) + if not client: + return False + if not code or not code.isdigit() or len(code) != 6: + return False + return pyotp.TOTP(client.totp_secret).verify(code, valid_window=1) + def list_clients(self) -> list[dict[str, Any]]: """List all registered clients (without secrets).""" return [ From e059e96c8b23df7253e473dabc326e0b3a6c340b Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 21:17:12 +0200 Subject: [PATCH 020/155] Serve OAuth protected-resource metadata (RFC 9728) Claude Web probes /.well-known/oauth-protected-resource and /.well-known/oauth-protected-resource/mcp before running the OAuth flow, per MCP 2025-06-18. We did not serve those paths, so they fell through to the authenticated MCP mount and returned 401, which caused Claude to show "unauthorized" even though everything else worked. - Add a /.well-known/oauth-protected-resource handler (plus the resource-scoped /mcp variant) that returns RFC 9728 metadata pointing at ourselves as the authorization server. - Whitelist both paths in auth_middleware. - Emit WWW-Authenticate: Bearer realm=..., resource_metadata= on 401 responses so unauth'd clients can discover the AS per spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/__main__.py | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py index e241c41..390ad16 100644 --- a/src/tarkamcp/__main__.py +++ b/src/tarkamcp/__main__.py @@ -165,10 +165,15 @@ def totp_record_failure(client_id: str) -> None: def totp_record_success(client_id: str) -> None: totp_failures.pop(client_id, None) - async def oauth_metadata(request: Request) -> Response: + def _issuer(request: Request) -> str: scheme = request.headers.get("x-forwarded-proto", request.url.scheme) - host_header = request.headers.get("x-forwarded-host", request.headers.get("host", "localhost")) - issuer = f"{scheme}://{host_header}" + host_header = request.headers.get( + "x-forwarded-host", request.headers.get("host", "localhost") + ) + return f"{scheme}://{host_header}" + + async def oauth_metadata(request: Request) -> Response: + issuer = _issuer(request) # registration_endpoint is intentionally omitted: dynamic client # registration is disabled, clients must be provisioned via CLI. return JSONResponse({ @@ -181,6 +186,17 @@ async def oauth_metadata(request: Request) -> Response: "token_endpoint_auth_methods_supported": ["client_secret_post"], }) + async def protected_resource_metadata(request: Request) -> Response: + # RFC 9728 - required by the MCP 2025-06-18 spec so that clients + # (Claude Web in particular) can discover which authorization server + # protects the /mcp resource. We act as our own authorization server. + issuer = _issuer(request) + return JSONResponse({ + "resource": issuer, + "authorization_servers": [issuer], + "bearer_methods_supported": ["header"], + }) + def _validate_authorize_params( params: dict[str, str], ) -> tuple[dict[str, str], Response | None]: @@ -404,15 +420,24 @@ async def auth_middleware(request: Request, call_next): "/oauth/authorize", "/oauth/register", "/.well-known/oauth-authorization-server", + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp", ): return await call_next(request) + # MCP 2025-06-18 + RFC 9728: point unauth'd clients at the resource + # metadata so they can discover the authorization server. + issuer = _issuer(request) + resource_meta = f"{issuer}/.well-known/oauth-protected-resource" + authorization = request.headers.get("authorization", "") if not authorization.startswith("Bearer "): return JSONResponse( {"error": "unauthorized"}, status_code=401, - headers={"WWW-Authenticate": 'Bearer realm="tarkamcp"'}, + headers={ + "WWW-Authenticate": f'Bearer realm="tarkamcp", resource_metadata="{resource_meta}"', + }, ) client_id = token_store.validate(authorization[7:]) @@ -420,7 +445,9 @@ async def auth_middleware(request: Request, call_next): return JSONResponse( {"error": "invalid_token"}, status_code=401, - headers={"WWW-Authenticate": 'Bearer realm="tarkamcp", error="invalid_token"'}, + headers={ + "WWW-Authenticate": f'Bearer realm="tarkamcp", error="invalid_token", resource_metadata="{resource_meta}"', + }, ) return await call_next(request) @@ -439,6 +466,8 @@ async def lifespan(_app): routes=[ Route("/health", health), Route("/.well-known/oauth-authorization-server", oauth_metadata), + Route("/.well-known/oauth-protected-resource", protected_resource_metadata), + Route("/.well-known/oauth-protected-resource/mcp", protected_resource_metadata), Route("/oauth/authorize", oauth_authorize_get, methods=["GET"]), Route("/oauth/authorize", oauth_authorize_post, methods=["POST"]), Route("/oauth/token", oauth_token, methods=["POST"]), From f767f6b565932b2e25d7244cb834ec863e16f225 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 21:45:04 +0200 Subject: [PATCH 021/155] Add security_end_session tool to revoke the caller's bearer token Lets the model voluntarily shorten the lifetime of its own access token once a task is finished. Reduces the replay window if a token leaks, and forces the next session to go through the full OAuth + 2FA flow. Plumbing: - ContextVar ``current_bearer_token`` in tarkamcp.auth, set by the HTTP auth middleware around call_next and cleared in a finally block. - TokenStore gains a ``revoke(token)`` method (single-use pop from the in-memory dict). - The middleware registers its TokenStore via ``register_token_store`` so the tool reaches it without circular imports. - New module ``tarkamcp.security.tools`` exposes the MCP tool ``security_end_session`` which calls ``revoke_current_token()`` and returns ``{"revoked": bool, ...}``. The docstring tells the model to call it at the very end of a task, never mid-flow. - ``server.py`` registers the new tool alongside the Proxmox/SSH/iLO modules. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/__main__.py | 18 +++++++++++--- src/tarkamcp/auth.py | 35 +++++++++++++++++++++++++++ src/tarkamcp/security/__init__.py | 0 src/tarkamcp/security/tools.py | 39 +++++++++++++++++++++++++++++++ src/tarkamcp/server.py | 2 ++ 5 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 src/tarkamcp/security/__init__.py create mode 100644 src/tarkamcp/security/tools.py diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py index 390ad16..85ebb91 100644 --- a/src/tarkamcp/__main__.py +++ b/src/tarkamcp/__main__.py @@ -132,12 +132,16 @@ def _run_http(mcp, host: str, port: int): from urllib.parse import urlencode, urlparse - from .auth import ClientStore, CodeStore, TokenStore + from . import auth + from .auth import ClientStore, CodeStore, TokenStore, current_bearer_token clients_file = os.environ.get("TARKAMCP_CLIENTS_FILE") client_store = ClientStore(Path(clients_file) if clients_file else None) token_store = TokenStore() code_store = CodeStore() + # Share the TokenStore with MCP tools so security_end_session can revoke + # the caller's bearer without an import cycle. + auth.register_token_store(token_store) # In-memory TOTP bruteforce guard: per client_id, (failures, cooldown_until). # 5 failed attempts → 5-minute lockout. Reset on first success. @@ -440,7 +444,8 @@ async def auth_middleware(request: Request, call_next): }, ) - client_id = token_store.validate(authorization[7:]) + bearer = authorization[7:] + client_id = token_store.validate(bearer) if not client_id: return JSONResponse( {"error": "invalid_token"}, @@ -449,7 +454,14 @@ async def auth_middleware(request: Request, call_next): "WWW-Authenticate": f'Bearer realm="tarkamcp", error="invalid_token", resource_metadata="{resource_meta}"', }, ) - return await call_next(request) + + # Expose the bearer to downstream MCP tools via ContextVar so + # security_end_session can revoke it after responding. + token_var = current_bearer_token.set(bearer) + try: + return await call_next(request) + finally: + current_bearer_token.reset(token_var) mcp_app = mcp.streamable_http_app() diff --git a/src/tarkamcp/auth.py b/src/tarkamcp/auth.py index 9dcbe59..5b45db1 100644 --- a/src/tarkamcp/auth.py +++ b/src/tarkamcp/auth.py @@ -19,6 +19,7 @@ import secrets import sys import time +from contextvars import ContextVar from dataclasses import dataclass from pathlib import Path from typing import Any @@ -26,6 +27,36 @@ import pyotp +# Populated by the HTTP auth middleware at the start of each request and +# cleared at the end. Lets MCP tools discover which bearer token they are +# executing under, so a tool like ``security_end_session`` can revoke it. +current_bearer_token: ContextVar[str | None] = ContextVar( + "current_bearer_token", default=None +) + +# Registered by the HTTP layer so MCP tools (instantiated via FastMCP, which +# runs before ``_run_http``) can reach the running TokenStore without an +# import cycle. +_active_token_store: "TokenStore | None" = None + + +def register_token_store(store: "TokenStore") -> None: + global _active_token_store + _active_token_store = store + + +def revoke_current_token() -> bool: + """Revoke the bearer token associated with the in-flight request. + + Returns True if a token was found and revoked. Safe no-op if called + outside an HTTP request context. + """ + token = current_bearer_token.get() + if not token or _active_token_store is None: + return False + return _active_token_store.revoke(token) + + CLIENTS_FILE = Path("/opt/tarkamcp/clients.json") @@ -239,6 +270,10 @@ def validate(self, token: str) -> str | None: return None return access_token.client_id + def revoke(self, token: str) -> bool: + """Revoke a token immediately. Returns True if the token existed.""" + return self._tokens.pop(token, None) is not None + def _cleanup(self) -> None: now = time.time() expired = [t for t, at in self._tokens.items() if now > at.expires_at] diff --git a/src/tarkamcp/security/__init__.py b/src/tarkamcp/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tarkamcp/security/tools.py b/src/tarkamcp/security/tools.py new file mode 100644 index 0000000..efe9b90 --- /dev/null +++ b/src/tarkamcp/security/tools.py @@ -0,0 +1,39 @@ +"""Security-related MCP tools (session termination, etc.).""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from ..auth import revoke_current_token + + +def register_security_tools(mcp: FastMCP) -> None: + @mcp.tool() + def security_end_session() -> dict: + """Invalidate the current bearer token. + + Call this as the very last step of a task when the caller has + finished using the server. After this returns, every subsequent + request made with the same bearer token will fail with 401, forcing + a fresh OAuth + 2FA round-trip. Use this to shrink the window during + which a stolen token could be replayed. + + Do not call this in the middle of a multi-step task; the next tool + call would be rejected. + + Returns ``{"revoked": true}`` on success, or + ``{"revoked": false, "reason": "..."}`` if no active token was found + (e.g. called outside an HTTP request context). + """ + if revoke_current_token(): + return { + "revoked": True, + "message": ( + "Bearer token revoked. The next request will need a new " + "authorization (OAuth + 2FA)." + ), + } + return { + "revoked": False, + "reason": "no active bearer token in request context", + } diff --git a/src/tarkamcp/server.py b/src/tarkamcp/server.py index dc2ac68..fe0b022 100644 --- a/src/tarkamcp/server.py +++ b/src/tarkamcp/server.py @@ -12,6 +12,7 @@ from .ssh.tools import register_ssh_tools from .ilo.client import ILOClient from .ilo.tools import register_ilo_tools +from .security.tools import register_security_tools config = Config.from_env() proxmox_client = ProxmoxClient(config) @@ -110,3 +111,4 @@ def tarkamcp_context() -> str: register_ssh_tools(mcp, ssh_client) if config.ilo: register_ilo_tools(mcp, ilo_client) +register_security_tools(mcp) From 82c2c5cb78579b17a7af8976268082c0f98804ba Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 21:51:27 +0200 Subject: [PATCH 022/155] Add 30s grace period before revoked tokens actually expire Revoking the token synchronously from inside an MCP tool was racing against the streamable-HTTP response: the JSON-RPC result goes back to the client via an SSE stream that re-enters the auth middleware, so by the time the client tried to read the tool's response the bearer was already gone and Claude showed "Authentication required" before the answer surfaced. TokenStore.revoke now pulls the token's expires_at down to now + 30s instead of popping it immediately, giving the in-flight response and any immediate follow-up enough time to complete. The standard validate() path enforces the new deadline, so the second a fresh request arrives past the window it is rejected as before. The security_end_session tool advertises the grace period in its response so the model knows the token isn't dead instantly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/auth.py | 22 ++++++++++++++++++++-- src/tarkamcp/security/tools.py | 6 ++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/tarkamcp/auth.py b/src/tarkamcp/auth.py index 5b45db1..bbaed2e 100644 --- a/src/tarkamcp/auth.py +++ b/src/tarkamcp/auth.py @@ -270,9 +270,27 @@ def validate(self, token: str) -> str | None: return None return access_token.client_id + # Seconds to keep a revoked token alive so the current MCP response / + # SSE stream has time to reach the client before the middleware starts + # rejecting follow-up requests. + REVOKE_GRACE_SECONDS = 30.0 + def revoke(self, token: str) -> bool: - """Revoke a token immediately. Returns True if the token existed.""" - return self._tokens.pop(token, None) is not None + """Schedule a token for revocation after a short grace period. + + Returns True if the token existed. The token stays technically valid + for :attr:`REVOKE_GRACE_SECONDS` seconds so the in-flight HTTP + response (and any immediate SSE follow-up that MCP streamable-HTTP + needs) can finish; after that the standard expiration check in + :meth:`validate` rejects it. + """ + access_token = self._tokens.get(token) + if access_token is None: + return False + deadline = time.time() + self.REVOKE_GRACE_SECONDS + if access_token.expires_at > deadline: + access_token.expires_at = deadline + return True def _cleanup(self) -> None: now = time.time() diff --git a/src/tarkamcp/security/tools.py b/src/tarkamcp/security/tools.py index efe9b90..43cfb8f 100644 --- a/src/tarkamcp/security/tools.py +++ b/src/tarkamcp/security/tools.py @@ -28,9 +28,11 @@ def security_end_session() -> dict: if revoke_current_token(): return { "revoked": True, + "grace_seconds": 30, "message": ( - "Bearer token revoked. The next request will need a new " - "authorization (OAuth + 2FA)." + "Bearer token scheduled for revocation. It remains valid " + "for ~30s so this response can reach the client; after " + "that the next request will need a fresh OAuth + 2FA." ), } return { From 191838d73c3c253f790692a1150eed5099b487b4 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 21:52:07 +0200 Subject: [PATCH 023/155] Shorten revocation grace period from 30s to 8s 8s is enough for the streamable-HTTP response plus any immediate SSE follow-up, and it closes the replay window faster after the model calls security_end_session. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/auth.py | 2 +- src/tarkamcp/security/tools.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tarkamcp/auth.py b/src/tarkamcp/auth.py index bbaed2e..fdb05f3 100644 --- a/src/tarkamcp/auth.py +++ b/src/tarkamcp/auth.py @@ -273,7 +273,7 @@ def validate(self, token: str) -> str | None: # Seconds to keep a revoked token alive so the current MCP response / # SSE stream has time to reach the client before the middleware starts # rejecting follow-up requests. - REVOKE_GRACE_SECONDS = 30.0 + REVOKE_GRACE_SECONDS = 8.0 def revoke(self, token: str) -> bool: """Schedule a token for revocation after a short grace period. diff --git a/src/tarkamcp/security/tools.py b/src/tarkamcp/security/tools.py index 43cfb8f..00c3072 100644 --- a/src/tarkamcp/security/tools.py +++ b/src/tarkamcp/security/tools.py @@ -28,10 +28,10 @@ def security_end_session() -> dict: if revoke_current_token(): return { "revoked": True, - "grace_seconds": 30, + "grace_seconds": 8, "message": ( "Bearer token scheduled for revocation. It remains valid " - "for ~30s so this response can reach the client; after " + "for ~8s so this response can reach the client; after " "that the next request will need a fresh OAuth + 2FA." ), } From 4d60321deeb081a5f6a6d64fe7c6013456662cb6 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Thu, 16 Apr 2026 22:24:30 +0200 Subject: [PATCH 024/155] Advertise ExampleCore logo as the MCP server icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle the 512×512 WebP logo in src/tarkamcp/assets/logo.webp and expose it to clients via FastMCP(icons=[...]) as an inline data URL. Data URL avoids having to open a public static route through the Cloudflare tunnel and keeps the icon available whatever reverse-proxy config is used. Also set website_url to the GitHub repo so clients that surface it can link home. The asset is force-included in the wheel so `pip install -e .` and real installs both ship it. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 3 +++ src/tarkamcp/assets/logo.webp | Bin 0 -> 2892 bytes src/tarkamcp/server.py | 26 ++++++++++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 src/tarkamcp/assets/logo.webp diff --git a/pyproject.toml b/pyproject.toml index 74227c1..6ec18ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,3 +24,6 @@ tarkamcp = "tarkamcp.__main__:main" [tool.hatch.build.targets.wheel] packages = ["src/tarkamcp"] + +[tool.hatch.build.targets.wheel.force-include] +"src/tarkamcp/assets/logo.webp" = "tarkamcp/assets/logo.webp" diff --git a/src/tarkamcp/assets/logo.webp b/src/tarkamcp/assets/logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..fcdbbb507dde6b37b360db4899bbb856d068f516 GIT binary patch literal 2892 zcmcJQ`8yPh7RSdh<+bl)$sTGlQX*Ss62=l^C)v%|w>P^m6Ov_KTN25>Wgn_R*`q93 zqO3C{#K_Xv*O^;)dEWa6+{_<9PDm&R>_03T08JaDPcJzfxjF6x% zMUjuKFtqXbt-+$O$+*Yv58|tMn;Snf0x7i_jjFzZL~`?3K-se4P)_dk|CM}pX3#G) z+Iv=Z%elJE@ zWem1I6*?YCP;AJe&!`;Amb{`zS3damGa6^icWwA|m$5c5K3T{hZwN=`0#P3guda?0TNmpzlrU5lL?2 zmbA!Kpy^2+OdI?wz8vp}Ya1DjN@X`^whwP--1YDc9EO8Lvq_4d`T*wclYxKK+|A{h z>d$o@L@D`t9{y5{^AG;$2Crw@&6Xm%D$p!(nL`fQNPusxzH>Ro_ zNRzZKiNC`fG0f@7yKTmNHcX$gbEm@xI+XM5I~M_6ex{*hOZCuQdhpw@Gz3N9S=7!u z?5=g0UsljTbKW?06^5Qfl>pS5tn<~ zCrV}F(7$DO(KpZ_?PllC>F21}xy;1)>u}rqRMkmNH{a=}og6K%~lGl1`_MJiFfO^I(|13&I{K?Hn)6i&n1+ zUh1i0;{@;lvKvH>YQ(3OD(y_E&XZM~lsE4>8z2==9a2;@>CeYJ#yqJyaMLl=1{FIm zG4jZ)7wr08_+!XTsCl<`EAd{~)C2Xkj=W$;q?)`;n%}9i?UKlQQ>*e9<6(VmQQ`QL zstEbvye|@|F%k=F0l#2E?4?@K~BbBp)t%W!mK!qB)zIvmw!xWrhgy80V(XSd4+FALjUmd`tUqQ}?(V#WnMwA@q`fL)Wo_PX6HNqQ ziWFSE7HF1LL(&%n&@yM1J_r=_+}RY|?uwj`NpT zctIhS1J_o=j8~jX%KQDVy=^kal3DV;%AB9@>SAOEZf7tgJRHh$l|O{J61) zAd5baRssSR7ERJ1SQ+>@WqxR(4287!LK3~d2dF$B3K!x+-*X=OQ15!-HQsXWKLHjJ=4Ll}KK^KQJH%EZ|pPfB{TJR->sBoLz&i z%x9)qaomuv z0w?CEn(~odUu$$G8igJj{D5 zLAhetRqivu+Vnc+2wodJbVqm?RKIH3Pf~4fa+}gx_pUq#{v_8kPrnwu!j=v26b7C{ zYwKd0>&v*|!EcK;O_%@xDM8p*)K@w%^(O$)spb&c72R4ntD@GVad$I94d{_FW!EYI zi{y|B(1}5Ya<9DA2H@69O`$@Do65JGL*7r$tNmHf<8P)-CYg#y97g*nL8A``SJkQv33tOWLojU(^@065wEHnj_}Y z8{#m-Xs~m+5$h^ASzL@ACOu6p)`vfzt!?p7()wWWL_|7qRj=sh)gXU9_M2kieT`WV` zunUA#3ZqkMsa|n`*~*FDR_puhQ|lmpLvf|k41AZUlWf`kk|yugHfxvn zvc{fj!H1--g??*P)@nzM)#wwa6tsP7T#DXg{fQvT) zDGYq0FAGvEQ&Kh6Q@_ZB5uq&XQh9B5)jZ51a>tuploZ#fldA7jtKG)WG3&-BA2*(w zTb6H)QCCNOB5WND6ifCHG%S&^12-DgD)oU*Z)p2f;9mBc4c+y>a_7Y-+vK?8wXIkn z{C!84ni+%ppYt}me~UItB0^Y&>~}1w{gFa#(U}K=tyg& zZX!_T(sRQTGOH!#3yJ4zn#?!>@^ci=4uJX!CMXjibjf$9P4x8nRKe(JQ}Zy zF`TfA7&KOlQ58N98rGEQ6meC@d3unt1i}?+ list[str]: ["https://claude.ai", "https://chat.openai.com", "https://gemini.google.com"], ) + +def _load_icons() -> list[Icon]: + """Load the bundled logo as a data-URL icon for MCP clients. + + Shipped inline so clients get the icon without needing a public static + route, and so nothing breaks when the server is hidden behind a tunnel + that only forwards /mcp. + """ + logo_path = Path(__file__).parent / "assets" / "logo.webp" + if not logo_path.is_file(): + return [] + data = base64.b64encode(logo_path.read_bytes()).decode("ascii") + return [ + Icon( + src=f"data:image/webp;base64,{data}", + mimeType="image/webp", + sizes=["512x512"], + ) + ] + + mcp = FastMCP( "tarkamcp", instructions=( @@ -48,6 +72,8 @@ def _csv_env(name: str, default: list[str]) -> list[str]: "and ssh_* tools for direct shell access as fallback. " "Start with proxmox_list_nodes to see cluster status." ), + website_url="https://github.com/Showdown76py/TarkaMCP", + icons=_load_icons(), transport_security=TransportSecuritySettings( enable_dns_rebinding_protection=True, allowed_hosts=_allowed_hosts, From efd8f7839604f4135ea7c043dcb937e630ba4d95 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 00:44:02 +0200 Subject: [PATCH 025/155] Add dashboard design spec for login + Gemini chat panels Captures the brainstormed design: OAuth-backed login with 90-day session cookie, Gemini 3 chat with MCP tool integration, SQLite persistence, light/dark responsive UI, and scoped security + testing plan. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-17-tarka-dashboard-design.md | 525 ++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-17-tarka-dashboard-design.md diff --git a/docs/superpowers/specs/2026-04-17-tarka-dashboard-design.md b/docs/superpowers/specs/2026-04-17-tarka-dashboard-design.md new file mode 100644 index 0000000..852b870 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-tarka-dashboard-design.md @@ -0,0 +1,525 @@ +# TarkaMCP Dashboard — Design + +**Date:** 2026-04-17 +**Status:** Draft (awaiting review) +**Scope:** Web dashboard adjacent to the existing MCP server, served on the same origin (`mcp.example.com`). + +## 1. Goals + +Add a web-accessible companion dashboard to TarkaMCP providing two surfaces: + +- **Login panel** — collect `client_id` + `client_secret` + TOTP, exchange for an MCP bearer, and persist a browser session for 90 days so users (especially on mobile) no longer need to run `curl + oathtool` to obtain a token. +- **Chat panel** — multi-conversation chat interface powered by Gemini 3 Flash / 3.1 Pro, invoking TarkaMCP tools through the Gemini SDK's native MCP integration. Usable from phone or desktop. + +### Success criteria + +1. From a phone browser, an operator can log in, open a chat, ask "status de pve2", and see tool-call results without typing in a shell. +2. Session survives closing the browser and coming back within 90 days; only TOTP is re-prompted every 24h. +3. UI feels smooth (streaming responses, no full-page reloads, < 100 ms perceived click latency). +4. The dashboard is optional — if `GEMINI_API_KEY` is absent, the rest of TarkaMCP runs unchanged. + +### Non-goals (explicit) + +- No multi-user model — one `client_id` = one operator. Conversations are scoped to the client that owns them. +- No rate-limiting beyond the existing TOTP lockout (5 failures / 5 min). +- No conversation sharing, export, or public links. +- No voice / STT. +- No push notifications. +- No syntax-highlighting in code blocks (v1). +- No full-text search over history (v1). + +## 2. Decisions (from brainstorming) + +| Decision | Choice | +|---|---| +| Gemini API key location | Server-side env var `GEMINI_API_KEY`. Server proxies all Gemini calls. | +| Tool execution path | Approach **A** — Gemini SDK invoked with an `McpServer` tool pointing to `https://mcp.example.com/mcp` + user bearer in headers. Google's backend calls TarkaMCP directly. | +| Session durability | 90-day HttpOnly server-side session cookie. TOTP re-prompt every 24 h to refresh MCP bearer. | +| Conversation model | Multiple conversations with sidebar, server-persisted in SQLite. | +| Thinking control | Gemini 3 effort presets: `minimal` / `low` / `medium` / `high`. Per-conversation setting. | +| Tool-call visualization | Inline collapsible cards showing `name · duration · status`; expand for args + result preview. | +| Frontend stack | Vanilla HTML + CSS custom properties + ES modules. Jinja2 server-rendered templates. Marked + DOMPurify vendored for markdown. No build step. | +| Chat history storage | SQLite at `/opt/tarkamcp/dashboard.db` (WAL, foreign keys). Single DB for sessions + conversations + messages. | +| Theme | Light / dark via `prefers-color-scheme`, slate-neutral palette + Proxmox orange `#e57000` accent. | +| Model selector | Default `gemini-3-flash`; dropdown to switch to `gemini-3.1-pro`. Choice persisted per conversation. | +| Iconography | Inline monochrome SVG icons (plus, chevron, arrow, ellipsis, status check/warn/spinner). **No Unicode emojis anywhere.** The "no AI slop" rule bans emojis 🎉✨🤖 etc., not vector icons. | + +## 3. Module layout + +``` +src/tarkamcp/ + dashboard/ NEW MODULE + __init__.py register_dashboard_routes(app, ...) + app.py Starlette routes: login, refresh, logout, chat, api/* + session.py SessionStore (SQLite + AES-GCM) and cookie helpers + db.py Connection pooling, migrations (PRAGMA user_version) + chat.py ChatEngine: Gemini SDK wrapper, SSE event generation + csrf.py Double-submit cookie middleware + templates/ + base.html Layout, CSS vars, icon sprite + login.html + totp_refresh.html + chat.html Shell (sidebar + messages + composer) + static/ + app.css + chat.js SSE client, sidebar, markdown render, composer + marked.min.js Vendored 15 kb + dompurify.min.js Vendored 20 kb + icons.svg SVG sprite (6-7 icons) + __main__.py + mount dashboard routes when enabled +``` + +The dashboard is mounted conditionally in `__main__.py::_run_http`, similar to how SSH / iLO modules register themselves. Requires `GEMINI_API_KEY` set. Can be disabled via `TARKAMCP_DASHBOARD_ENABLED=false`. + +## 4. URL routing + +| Path | Method | Auth | Role | +|---|---|---|---| +| `/` | GET | — | 302 to `/app/chat` if session, else `/app/login` | +| `/app/login` | GET | — | Render login form (or single-field refresh if cookie exists) | +| `/app/login` | POST | — | Validate creds + TOTP, create session, set cookie, 302 to `/app/chat` | +| `/app/refresh` | GET | cookie | Render TOTP-only form | +| `/app/refresh` | POST | cookie | Re-issue MCP bearer, update session | +| `/app/logout` | POST | cookie | Revoke bearer, delete session, clear cookie, 302 to `/app/login` | +| `/app/chat` | GET | cookie | Render chat shell HTML | +| `/app/api/conversations` | GET | cookie | JSON list (scoped to `client_id`) | +| `/app/api/conversations` | POST | cookie + CSRF | Create empty conversation | +| `/app/api/conversations/{id}` | GET | cookie | Fetch full conversation + messages | +| `/app/api/conversations/{id}` | PATCH | cookie + CSRF | Rename, change model/effort | +| `/app/api/conversations/{id}` | DELETE | cookie + CSRF | Delete conversation and messages | +| `/app/api/chat/stream` | POST | cookie + CSRF | SSE streaming turn | +| `/app/static/*` | GET | — | Static assets | +| `/mcp`, `/oauth/*`, `/.well-known/*`, `/health` | — | — | **Unchanged** | + +All `/app/*` routes go through a dedicated `DashboardSessionMiddleware`, not the existing bearer middleware that protects `/mcp`. + +## 5. Authentication & sessions + +### Principle + +Cookie holds only an opaque `session_id` (256-bit random). All sensitive material (client_secret, current MCP bearer) lives in SQLite, encrypted at rest. + +### Session table + +```sql +CREATE TABLE sessions ( + session_id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + client_secret_enc BLOB NOT NULL, + mcp_bearer TEXT, + mcp_bearer_expires_at REAL, + created_at REAL NOT NULL, + last_seen_at REAL NOT NULL, + expires_at REAL NOT NULL, + user_agent TEXT +); +CREATE INDEX idx_sessions_client ON sessions(client_id); +``` + +### Client secret encryption + +- Master key: env var `TARKAMCP_SESSION_KEY` (32 bytes, base64-encoded). Generated by `deploy/install.sh` if absent. +- Algorithm: AES-256-GCM via `cryptography.hazmat.primitives.ciphers.aead.AESGCM`. +- Storage layout: `nonce(12 bytes) || ciphertext || tag`. +- Rotation: not addressed in v1. Compromise recovery = wipe `sessions` table, rotate key, users log in again. + +### Cookie + +``` +Set-Cookie: tarkamcp_session=; + HttpOnly; Secure; SameSite=Strict; + Path=/app; + Max-Age=7776000 +``` + +`Path=/app` prevents the cookie from being sent on `/mcp`, `/oauth/*`, or Google's back-channel calls to the MCP endpoint. + +### Initial login flow (`POST /app/login`) + +``` +1. ClientStore.verify(client_id, client_secret) → 401 if fail +2. totp_locked(client_id) → 429 if locked +3. ClientStore.verify_totp(client_id, totp) → 401 + increment fail count +4. token_store.issue(client_id) → (bearer, 86400) +5. Generate session_id = secrets.token_urlsafe(32) +6. AES-GCM encrypt client_secret with TARKAMCP_SESSION_KEY +7. INSERT INTO sessions (...) +8. Set cookie; 302 → /app/chat +``` + +### 24h bearer refresh flow + +Middleware detects `mcp_bearer_expires_at < now()` when an `/app/api/chat/stream` request hits it: + +``` +1. Return SSE event {type: "session_expired"} +2. Client redirects to /app/refresh +3. /app/refresh GET: render "Code TOTP pour {client_name}" (1 field) +4. /app/refresh POST: verify_totp → token_store.issue → UPDATE sessions +5. 302 → /app/chat; UI auto-retries the last turn +``` + +If the session cookie is missing, unknown, or past `expires_at`: 302 → `/app/login` (full form). + +### Logout + +``` +1. Load session +2. token_store.revoke(session.mcp_bearer) (existing 8s grace) +3. DELETE FROM sessions WHERE session_id=? +4. Clear-Site-Data: "cookies" + Set-Cookie tarkamcp_session=; Max-Age=0 +5. 302 → /app/login +``` + +### Multi-session and admin revocation + +- Multiple sessions per `client_id` (phone + desktop) are allowed. +- `tarkamcp auth revoke ` cascades: deletes the client, deletes all its sessions, revokes all its bearers. +- New CLI subcommand `tarkamcp dashboard sessions [--client-id X]` lists sessions with last-seen timestamps and supports `--kill `. + +### Security + +| Surface | Measure | +|---|---| +| Session cookie | HttpOnly, Secure, SameSite=Strict, Path=/app, Max-Age=7776000 | +| CSRF | Double-submit cookie `tarkamcp_csrf_token` (JS-readable, `SameSite=Strict`, `Path=/app`) + header `X-CSRF-Token` required on POST/PATCH/DELETE | +| Session fixation | Regenerate `session_id` at login | +| Secret at rest | AES-256-GCM with env-derived key | +| Secret in logs | Logging filter redacts `sk_*` and `tarkamcp_*` tokens | +| Login brute-force | Reuses existing 5-failure / 5-minute TOTP lockout | +| Clickjacking | `X-Frame-Options: DENY` on `/app/*` | +| MIME sniffing | `X-Content-Type-Options: nosniff` | +| Referrer | `Referrer-Policy: strict-origin-when-cross-origin` | +| CSP | `default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; img-src 'self' data:` | +| DNS rebinding | Existing `TransportSecuritySettings` + `TARKAMCP_ALLOWED_HOSTS` | + +## 6. Database schema + +SQLite at `/opt/tarkamcp/dashboard.db` (overridable via `TARKAMCP_DASHBOARD_DB`). Mode WAL, `synchronous=NORMAL`, `foreign_keys=ON`. Versioned via `PRAGMA user_version`. + +```sql +CREATE TABLE sessions (...); -- see §5 + +CREATE TABLE conversations ( + id TEXT PRIMARY KEY, -- UUID v4 + client_id TEXT NOT NULL, + title TEXT, + model TEXT NOT NULL DEFAULT 'gemini-3-flash', + thinking_effort TEXT NOT NULL DEFAULT 'low', -- minimal|low|medium|high + created_at REAL NOT NULL, + updated_at REAL NOT NULL +); +CREATE INDEX idx_conv_client ON conversations(client_id, updated_at DESC); + +CREATE TABLE messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + role TEXT NOT NULL, -- user|assistant + content TEXT, + tool_calls TEXT, -- JSON array + thinking_summary TEXT, + model TEXT, + effort TEXT, + created_at REAL NOT NULL +); +CREATE INDEX idx_msg_conv ON messages(conversation_id, created_at); +``` + +`tool_calls` is a JSON array: `[{id, name, args, result, status, duration_ms, preview}]`. JSON instead of a child table keeps reads to a single SELECT + parse. + +## 7. Chat flow + +### Turn end-to-end + +``` +POST /app/api/chat/stream { conversation_id, content, model, effort } + ↓ +1. DashboardSessionMiddleware validates cookie + CSRF, loads session +2. If bearer expired → SSE event "session_expired" and close +3. Load conversation history from SQLite (ordered by created_at) +4. INSERT message (role=user) +5. Open SSE response (Content-Type: text/event-stream, no-cache) +6. Call google-genai streaming API with: + - model: the conversation's model + - contents: conversation history + new user message + - thinking config: effort level from the conversation + - tools: MCP server pointed at https://mcp.example.com/mcp + with the session's bearer in the Authorization header + (Exact SDK class names — Tool, McpServer, ThinkingConfig, + GenerateContentConfig — are verified at implementation time against + the installed google-genai version.) +7. For each chunk, emit matching SSE event (see table below) +8. On stream end, INSERT message (role=assistant, content, tool_calls, ...) +9. UPDATE conversations SET updated_at=now +10. If this was the first user turn in the conversation, fire auto-title: + - Secondary short genai call (gemini-3-flash, effort=minimal, no tools) + - UPDATE conversations SET title=? + - SSE event "title_updated" +11. Emit "done" event and close stream +``` + +### SSE event vocabulary + +| Event | Payload | +|---|---| +| `text_delta` | `{"text": "..."}` | +| `thinking_delta` | `{"summary": "..."}` | +| `tool_call` | `{"id": "...", "name": "...", "args": {...}}` | +| `tool_result` | `{"id": "...", "status": "ok"\|"error", "preview": "...", "duration_ms": 234}` | +| `error` | `{"code": "...", "message": "..."}` | +| `session_expired` | `{}` | +| `title_updated` | `{"conversation_id": "...", "title": "..."}` | +| `aborted` | `{}` | +| `done` | `{"message_id": "..."}` | + +Server honors `Request.is_disconnected` — if the client aborts (user pressed "Arrêter"), the loop breaks, a partial assistant message is persisted with a marker, and `"aborted"` is emitted before close. + +### Model and effort + +Per-conversation columns. Default for new chat: `gemini-3-flash` + `low`. Changing model or effort applies to subsequent turns; existing messages are unaffected (each message records the `model` and `effort` used). + +### Auto-title + +Performed after the first *user* message gets its assistant response. Call is isolated (no tools, no streaming), prompt: "Donne un titre de 4 mots maximum, sans emoji, sans ponctuation finale, pour: {user_msg}". Persisted on the conversation. UI updates the sidebar on `title_updated`. + +## 8. UI + +### Login (`/app/login`) + +Centered card layout, styled with the dashboard's light/dark palette (not coupled to the existing `/oauth/authorize` page, which is left untouched). First-time visit shows 3 fields (Client ID, Client Secret, TOTP) + "Rester connecté 90j" checkbox. On subsequent visits where a valid session cookie exists but its bearer is stale, `/app/refresh` renders a single TOTP field with the client name in evidence. + +### Chat desktop layout + +``` +┌──────────────┬──────────────────────────────────────────────┐ +│ TarkaMCP │ │ +│ + Nouveau │ [user bubble, right-aligned] │ +│ ───────── │ │ +│ > pve2 down? │ Gemini 3 Flash · low │ +│ LXC update │ [streaming text …] │ +│ … │ │ +│ │ ▸ proxmox_list_vms · 180 ms · ok │ +│ │ │ +│ ───────── │ ┌──────────────────────────────────────────┐ │ +│ Modèle │ │ Envoyer un message à TarkaMCP… │ │ +│ Flash ▼ │ └──────────────────────────────────────────┘ │ +│ Effort: low │ Flash ▼ · effort ▼ [Envoyer] │ +│ Déconnexion │ │ +└──────────────┴──────────────────────────────────────────────┘ +``` + +- Sidebar 260 px, collapsible on narrow screens. +- Conversation list sorted by `updated_at DESC`, single-line truncation, hover reveals ⋯ menu (rename, delete). +- Active conversation highlighted with `--bg-soft` and a left accent bar. +- Model / effort controls live at the bottom of the sidebar AND in the composer row (both edit the same conversation settings). + +### Chat mobile layout (< 768 px) + +``` +┌─────────────────────────────┐ +│ ☰ pve2 down? ⋯ │ +├─────────────────────────────┤ +│ │ +│ msg user │ +│ │ +│ Gemini 3 Flash · low │ +│ streaming text … │ +│ │ +│ ▸ proxmox_list_vms · 180ms │ +│ │ +├─────────────────────────────┤ +│ [input message ... ] │ +│ Flash ▼ effort ▼ [↑] │ +└─────────────────────────────┘ +``` + +- Hamburger icon opens sidebar as a left drawer with an overlay dim. +- Safe-area insets respected on iOS (`env(safe-area-inset-bottom)`). +- Touch targets ≥ 44 px. + +### Messages zone + +- Centered column, `max-width: 48rem`. +- **User** message: `--user-bubble` background, right-aligned, 80 % max-width, plain text (no markdown parsing). +- **Assistant** message: no bubble, plain flow on canvas, with a small header "`{model} · {effort}`" in `--fg-muted`. Markdown rendered via `marked` + sanitized with `DOMPurify` (strict allowlist, no raw HTML passes through). +- **Tool-call card** — collapsed: + ``` + [chevron-right] proxmox_list_vms · 180 ms · ok + ``` + Expanded: + ``` + [chevron-down] proxmox_list_vms · 180 ms · ok + args: { "node": "pve1" } + result: 12 VMs listées + [vmid=101] web-prod · running + ... + [Copier le résultat] + ``` + Border 1 px, rounded, `--bg-soft` background. Status icon (check / warn / spinner) is an inline SVG, no emoji. + +### Composer + +- Auto-growing ` + +
+ + + + + + + +{% endblock %} diff --git a/tests/test_dashboard_chat.py b/tests/test_dashboard_chat.py new file mode 100644 index 0000000..a60e47c --- /dev/null +++ b/tests/test_dashboard_chat.py @@ -0,0 +1,406 @@ +"""Integration tests for Stage 2: conversations + chat stream with FakeChatEngine.""" + +from __future__ import annotations + +import json +import os +import sys +import time +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from tarkamcp.dashboard.app import BEARER_TTL_SECONDS, DashboardDeps, build_dashboard_routes +from tarkamcp.dashboard.chat import ( + ErrorEvent, + FakeChatEngine, + FakeScript, + TextDelta, + ThinkingDelta, + ToolCallEnd, + ToolCallStart, +) +from tarkamcp.dashboard.conversations import ConversationStore +from tarkamcp.dashboard.csrf import CSRF_COOKIE +from tarkamcp.dashboard.db import Database +from tarkamcp.dashboard.session import SessionStore + + +class FakeClientStore: + def verify(self, cid, sec): return cid == "c" and sec == "s" + def verify_totp(self, cid, code): return code == "123456" + def get_name(self, cid): return "Test" + + +class FakeTokenStore: + def __init__(self): + self.revoked = [] + self._n = 0 + def issue(self, cid): + self._n += 1 + return f"b_{self._n}", BEARER_TTL_SECONDS + def validate(self, token): return None + def revoke(self, token): + self.revoked.append(token) + return True + + +@pytest.fixture() +def engine(): + return FakeChatEngine(FakeScript(events=[TextDelta(text="pong")], title_text="Un titre")) + + +@pytest.fixture() +def deps(tmp_path, engine): + db = Database(tmp_path / "dashboard.db") + return DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + conversations=ConversationStore(db), + engine=engine, + mcp_public_url="https://mcp.example/", + ) + + +@pytest.fixture() +def app_and_client(deps): + app = Starlette(routes=build_dashboard_routes(deps)) + return app, TestClient(app, follow_redirects=False) + + +def _login(client): + r = client.get("/app/login") + csrf = r.cookies.get(CSRF_COOKIE) + r = client.post("/app/login", data={ + "csrf_token": csrf, "client_id": "c", + "client_secret": "s", "totp": "123456", "remember": "on", + }) + assert r.status_code == 303 + return client.cookies.get(CSRF_COOKIE) + + +# --------------------------------------------------------------------------- +# Conversations API +# --------------------------------------------------------------------------- + +def test_conv_list_empty(app_and_client): + _, client = app_and_client + _login(client) + r = client.get("/app/api/conversations") + assert r.status_code == 200 + assert r.json() == {"conversations": []} + + +def test_conv_list_requires_auth(app_and_client): + _, client = app_and_client + r = client.get("/app/api/conversations") + assert r.status_code == 401 + + +def test_conv_create_and_list(app_and_client): + _, client = app_and_client + csrf = _login(client) + r = client.post( + "/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"model": "gemini-3-flash", "effort": "medium"}), + ) + assert r.status_code == 201 + conv = r.json()["conversation"] + assert conv["model"] == "gemini-3-flash" + assert conv["thinking_effort"] == "medium" + + r = client.get("/app/api/conversations") + assert r.status_code == 200 + assert len(r.json()["conversations"]) == 1 + + +def test_conv_create_csrf(app_and_client): + _, client = app_and_client + _login(client) + r = client.post( + "/app/api/conversations", + headers={"Content-Type": "application/json"}, + content="{}", + ) + assert r.status_code == 403 + + +def test_conv_patch(app_and_client): + _, client = app_and_client + csrf = _login(client) + r = client.post( + "/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}", + ) + cid = r.json()["conversation"]["id"] + r = client.patch( + f"/app/api/conversations/{cid}", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"title": "Nouveau titre", "effort": "high"}), + ) + assert r.status_code == 200 + conv = r.json()["conversation"] + assert conv["title"] == "Nouveau titre" + assert conv["thinking_effort"] == "high" + + +def test_conv_patch_rejects_invalid_effort(app_and_client): + _, client = app_and_client + csrf = _login(client) + r = client.post("/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}") + cid = r.json()["conversation"]["id"] + r = client.patch( + f"/app/api/conversations/{cid}", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"effort": "ULTRA"}), + ) + assert r.status_code == 200 + assert r.json()["conversation"]["thinking_effort"] == "low" # unchanged + + +def test_conv_delete(app_and_client): + _, client = app_and_client + csrf = _login(client) + r = client.post("/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}") + cid = r.json()["conversation"]["id"] + r = client.delete(f"/app/api/conversations/{cid}", + headers={"X-CSRF-Token": csrf}) + assert r.status_code == 204 + r = client.get(f"/app/api/conversations/{cid}") + assert r.status_code == 404 + + +def test_conv_scoped_to_client(deps, app_and_client): + _, client = app_and_client + csrf = _login(client) + # Create a conversation for client c + r = client.post("/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}") + cid = r.json()["conversation"]["id"] + + # Another client's conversation -- should not be visible + other = deps.conversations.create(client_id="otherclient", model="gemini-3-flash", effort="low") + + r = client.get("/app/api/conversations") + ids = [c["id"] for c in r.json()["conversations"]] + assert cid in ids + assert other.id not in ids + + +# --------------------------------------------------------------------------- +# Chat stream (SSE) with FakeChatEngine +# --------------------------------------------------------------------------- + +def _parse_sse(text): + events = [] + for frame in text.strip().split("\n\n"): + ev = None + data = [] + for line in frame.split("\n"): + if line.startswith("event:"): + ev = line[6:].strip() + elif line.startswith("data:"): + data.append(line[5:].strip()) + payload = json.loads("\n".join(data)) if data else {} + events.append((ev, payload)) + return events + + +def test_chat_stream_simple_text(app_and_client, engine, deps): + _, client = app_and_client + csrf = _login(client) + r = client.post("/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}") + cid = r.json()["conversation"]["id"] + + r = client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"conversation_id": cid, "content": "ping"}), + ) + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/event-stream") + + events = _parse_sse(r.text) + names = [e[0] for e in events] + assert "text_delta" in names + assert "done" in names + assert "title_updated" in names + + text_events = [e[1] for e in events if e[0] == "text_delta"] + assert text_events[0]["text"] == "pong" + + # Engine saw the call + assert len(engine.calls) == 1 + turn = engine.calls[0] + assert turn.user_text == "ping" + assert turn.bearer.startswith("b_") + assert turn.mcp_url == "https://mcp.example/mcp" + assert turn.history == [] + + # Message persisted in DB + msgs = deps.conversations.list_messages(cid) + assert [m.role for m in msgs] == ["user", "assistant"] + assert msgs[0].content == "ping" + assert msgs[1].content == "pong" + + +def test_chat_stream_tool_call(app_and_client, engine, deps): + _, client = app_and_client + engine.script = FakeScript(events=[ + ToolCallStart(id="tc1", name="proxmox_list_vms", args={"node": "pve1"}), + ToolCallEnd(id="tc1", status="ok", preview="2 VMs", duration_ms=42), + TextDelta(text="Voici la liste."), + ]) + csrf = _login(client) + r = client.post("/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}") + cid = r.json()["conversation"]["id"] + + r = client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"conversation_id": cid, "content": "liste"}), + ) + events = _parse_sse(r.text) + names = [e[0] for e in events] + assert "tool_call" in names + assert "tool_result" in names + + msgs = deps.conversations.list_messages(cid) + assistant = msgs[1] + assert len(assistant.tool_calls) == 1 + tc = assistant.tool_calls[0] + assert tc.name == "proxmox_list_vms" + assert tc.status == "ok" + assert tc.preview == "2 VMs" + assert tc.duration_ms == 42 + + +def test_chat_stream_session_expired(app_and_client, deps): + _, client = app_and_client + csrf = _login(client) + r = client.post("/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}") + cid = r.json()["conversation"]["id"] + + # Expire the bearer + sessions = deps.session_store.list_for_client("c") + deps.session_store._db.conn().execute( + "UPDATE sessions SET mcp_bearer_expires_at = 0 WHERE session_id = ?", + (sessions[0].session_id,), + ) + + r = client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"conversation_id": cid, "content": "ping"}), + ) + events = _parse_sse(r.text) + assert events[0][0] == "session_expired" + + +def test_chat_stream_invalid_body(app_and_client): + _, client = app_and_client + csrf = _login(client) + r = client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({}), + ) + assert r.status_code == 400 + + +def test_chat_stream_not_found(app_and_client): + _, client = app_and_client + csrf = _login(client) + r = client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"conversation_id": "nope", "content": "x"}), + ) + assert r.status_code == 404 + + +def test_chat_stream_error_event(app_and_client, engine): + _, client = app_and_client + engine.script = FakeScript(events=[ + TextDelta(text="partial "), + ErrorEvent(code="boom", message="tool failure"), + ]) + csrf = _login(client) + r = client.post("/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}") + cid = r.json()["conversation"]["id"] + r = client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"conversation_id": cid, "content": "ping"}), + ) + events = _parse_sse(r.text) + codes = [e[0] for e in events] + assert "error" in codes + # Stream should still emit a done event so client state is coherent. + assert "done" in codes + + +def test_chat_persists_history_for_second_turn(app_and_client, engine, deps): + _, client = app_and_client + engine.script = FakeScript(events=[TextDelta(text="ack")], title_text="t") + csrf = _login(client) + r = client.post("/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}") + cid = r.json()["conversation"]["id"] + + client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"conversation_id": cid, "content": "first"}), + ) + client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"conversation_id": cid, "content": "second"}), + ) + # Second turn: history should contain the first user + first assistant. + history = engine.calls[1].history + assert [m.role for m in history] == ["user", "assistant"] + assert history[0].content == "first" + assert history[1].content == "ack" + + +def test_chat_page_auth_required(app_and_client): + _, client = app_and_client + r = client.get("/app/chat") + assert r.status_code == 302 + + +def test_chat_page_renders_after_login(app_and_client): + _, client = app_and_client + _login(client) + r = client.get("/app/chat") + assert r.status_code == 200 + assert "chat-root" in r.text + assert "gemini-3-flash" in r.text + assert "gemini-3.1-pro" in r.text From 02960f75c44bb74fc19055537b5a194257fe0485 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 01:13:12 +0200 Subject: [PATCH 028/155] Use correct Gemini 3 preview model IDs The real API model names are gemini-3-flash-preview and gemini-3.1-pro-preview. The initial commit used the shorter aliases which return 404 on generateContent. Schema migrates to v2 and rewrites stored model IDs on existing conversations and messages so past chats keep resolving. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/dashboard/app.py | 12 +++++-- src/tarkamcp/dashboard/conversations.py | 8 +++-- src/tarkamcp/dashboard/db.py | 26 ++++++++++++-- tests/test_dashboard_chat.py | 8 ++--- tests/test_dashboard_unit.py | 45 +++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 12 deletions(-) diff --git a/src/tarkamcp/dashboard/app.py b/src/tarkamcp/dashboard/app.py index 6ca4f70..85bd529 100644 --- a/src/tarkamcp/dashboard/app.py +++ b/src/tarkamcp/dashboard/app.py @@ -34,7 +34,13 @@ ToolCallStart, TurnInput, ) -from .conversations import VALID_EFFORTS, VALID_MODELS, ConversationStore +from .conversations import ( + DEFAULT_EFFORT, + DEFAULT_MODEL, + VALID_EFFORTS, + VALID_MODELS, + ConversationStore, +) from .db import Database from .session import SESSION_TTL_SECONDS, Session, SessionStore @@ -390,8 +396,8 @@ async def chat_get(request: Request) -> Response: deps.client_store.get_name(session.client_id) # type: ignore[attr-defined] or session.client_id ), - default_model="gemini-3-flash", - default_effort="low", + default_model=DEFAULT_MODEL, + default_effort=DEFAULT_EFFORT, valid_models=list(VALID_MODELS), valid_efforts=list(VALID_EFFORTS), ) diff --git a/src/tarkamcp/dashboard/conversations.py b/src/tarkamcp/dashboard/conversations.py index a8bf3cc..b02224f 100644 --- a/src/tarkamcp/dashboard/conversations.py +++ b/src/tarkamcp/dashboard/conversations.py @@ -18,7 +18,9 @@ VALID_EFFORTS = ("minimal", "low", "medium", "high") -VALID_MODELS = ("gemini-3-flash", "gemini-3.1-pro") +VALID_MODELS = ("gemini-3-flash-preview", "gemini-3.1-pro-preview") +DEFAULT_MODEL = "gemini-3-flash-preview" +DEFAULT_EFFORT = "low" @dataclass @@ -120,9 +122,9 @@ def __init__(self, db: Database) -> None: def create(self, *, client_id: str, model: str, effort: str) -> Conversation: if model not in VALID_MODELS: - model = "gemini-3-flash" + model = DEFAULT_MODEL if effort not in VALID_EFFORTS: - effort = "low" + effort = DEFAULT_EFFORT conv = Conversation( id=_new_id(), client_id=client_id, diff --git a/src/tarkamcp/dashboard/db.py b/src/tarkamcp/dashboard/db.py index 7086344..4dbad3f 100644 --- a/src/tarkamcp/dashboard/db.py +++ b/src/tarkamcp/dashboard/db.py @@ -19,7 +19,7 @@ def db_path() -> Path: return Path(override) if override else DEFAULT_DB_PATH -_LATEST_VERSION = 1 +_LATEST_VERSION = 2 def _migrate(conn: sqlite3.Connection) -> None: @@ -49,7 +49,7 @@ def _migrate(conn: sqlite3.Connection) -> None: id TEXT PRIMARY KEY, client_id TEXT NOT NULL, title TEXT, - model TEXT NOT NULL DEFAULT 'gemini-3-flash', + model TEXT NOT NULL DEFAULT 'gemini-3-flash-preview', thinking_effort TEXT NOT NULL DEFAULT 'low', created_at REAL NOT NULL, updated_at REAL NOT NULL @@ -74,6 +74,28 @@ def _migrate(conn: sqlite3.Connection) -> None: """ ) + if version < 2: + # Google's real Gemini 3 model IDs are suffixed `-preview` at the + # time of writing. The initial schema shipped without the suffix; + # migrate existing rows so stored conversations/messages keep + # resolving to a real model the API accepts. + conn.execute( + "UPDATE conversations SET model = 'gemini-3-flash-preview' " + "WHERE model = 'gemini-3-flash'" + ) + conn.execute( + "UPDATE conversations SET model = 'gemini-3.1-pro-preview' " + "WHERE model = 'gemini-3.1-pro'" + ) + conn.execute( + "UPDATE messages SET model = 'gemini-3-flash-preview' " + "WHERE model = 'gemini-3-flash'" + ) + conn.execute( + "UPDATE messages SET model = 'gemini-3.1-pro-preview' " + "WHERE model = 'gemini-3.1-pro'" + ) + conn.execute(f"PRAGMA user_version = {_LATEST_VERSION}") conn.commit() diff --git a/tests/test_dashboard_chat.py b/tests/test_dashboard_chat.py index a60e47c..789b11a 100644 --- a/tests/test_dashboard_chat.py +++ b/tests/test_dashboard_chat.py @@ -112,11 +112,11 @@ def test_conv_create_and_list(app_and_client): r = client.post( "/app/api/conversations", headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, - content=json.dumps({"model": "gemini-3-flash", "effort": "medium"}), + content=json.dumps({"model": "gemini-3-flash-preview", "effort": "medium"}), ) assert r.status_code == 201 conv = r.json()["conversation"] - assert conv["model"] == "gemini-3-flash" + assert conv["model"] == "gemini-3-flash-preview" assert conv["thinking_effort"] == "medium" r = client.get("/app/api/conversations") @@ -402,5 +402,5 @@ def test_chat_page_renders_after_login(app_and_client): r = client.get("/app/chat") assert r.status_code == 200 assert "chat-root" in r.text - assert "gemini-3-flash" in r.text - assert "gemini-3.1-pro" in r.text + assert "gemini-3-flash-preview" in r.text + assert "gemini-3.1-pro-preview" in r.text diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index 6a4f53d..572fa55 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -220,6 +220,51 @@ def test_cleanup_expired(store): assert store.load(s_new.session_id) is not None +def test_migration_v1_to_v2_renames_gemini_models(tmp_path): + """A DB created under schema v1 with bare model names must be upgraded.""" + import sqlite3 + + path = tmp_path / "legacy.db" + # Hand-build a v1 database that predates the migration. + conn = sqlite3.connect(path) + conn.executescript( + """ + CREATE TABLE conversations ( + id TEXT PRIMARY KEY, client_id TEXT NOT NULL, title TEXT, + model TEXT NOT NULL DEFAULT 'gemini-3-flash', + thinking_effort TEXT NOT NULL DEFAULT 'low', + created_at REAL NOT NULL, updated_at REAL NOT NULL + ); + CREATE TABLE messages ( + id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, + role TEXT NOT NULL, content TEXT, tool_calls TEXT, + thinking_summary TEXT, model TEXT, effort TEXT, + created_at REAL NOT NULL + ); + """ + ) + conn.execute("INSERT INTO conversations VALUES ('c1', 'cli', NULL, 'gemini-3-flash', 'low', 0, 0)") + conn.execute("INSERT INTO conversations VALUES ('c2', 'cli', NULL, 'gemini-3.1-pro', 'medium', 0, 0)") + conn.execute("INSERT INTO messages VALUES ('m1', 'c1', 'assistant', 'hi', NULL, NULL, 'gemini-3-flash', 'low', 0)") + conn.execute("PRAGMA user_version = 1") + conn.commit() + conn.close() + + # Opening via Database() should run migration v2. + db = Database(path) + rows = db.conn().execute( + "SELECT id, model FROM conversations ORDER BY id" + ).fetchall() + assert dict(rows[0]) == {"id": "c1", "model": "gemini-3-flash-preview"} + assert dict(rows[1]) == {"id": "c2", "model": "gemini-3.1-pro-preview"} + msg = db.conn().execute("SELECT model FROM messages WHERE id='m1'").fetchone() + assert msg["model"] == "gemini-3-flash-preview" + + # user_version reflects the migration. + ver = db.conn().execute("PRAGMA user_version").fetchone()[0] + assert ver == 2 + + def test_short_ciphertext_decryption_returns_none(store): s = store.create( client_id="c", client_secret="sk", mcp_bearer="b", From 08cfde6a325acaf8359d88542c651a9ce3390afc Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 01:20:20 +0200 Subject: [PATCH 029/155] Fix Gemini PERMISSION_DENIED by switching to local MCP session Google's remote-MCP-server tool (McpServer) is preview-gated and rejects standard API keys with 403 PERMISSION_DENIED. The google-genai SDK also supports a second mode where we open a local MCP ClientSession from the dashboard process and hand it to the SDK as a tool; Gemini then sees the tools as regular function declarations and the SDK orchestrates call/response, bypassing the preview restriction. - GeminiChatEngine now uses httpx.AsyncClient + streamable_http_client + ClientSession against the dashboard's own 127.0.0.1:/mcp endpoint with the user's bearer, then passes the session to the SDK via tools=[session]. All orchestration is fully async. - Adds gemini-2.5-flash and gemini-2.5-pro to VALID_MODELS for users without preview access; defaults to gemini-2.5-flash. - Thinking config now branches: Gemini 3 uses thinking_level enum (MINIMAL/LOW/MEDIUM/HIGH); Gemini 2.5 uses thinking_budget in tokens mapped from the same four effort names. - Three new unit tests cover the thinking-config branching. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- src/tarkamcp/dashboard/app.py | 17 +- src/tarkamcp/dashboard/chat.py | 304 ++++++++++++++---------- src/tarkamcp/dashboard/conversations.py | 11 +- tests/test_dashboard_unit.py | 27 +++ 5 files changed, 222 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index cd0689f..47f86c3 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ response = client.models.generate_content( Un panel web optionnel est servi par TarkaMCP sur la même URL (`https://mcp.example.com/app/...`). Il offre : - `/app/login` — récupérer un bearer MCP via Client ID + Secret + TOTP, session persistée 90 jours dans un cookie HttpOnly. Plus de curl sur téléphone. -- `/app/chat` — chat multi-conversations avec Gemini 3 Flash / 3.1 Pro et contrôle de l'effort de thinking (`minimal` / `low` / `medium` / `high`). Les outils TarkaMCP sont exposés à Gemini via MCP natif. +- `/app/chat` — chat multi-conversations avec Gemini 2.5 Flash/Pro (stable) ou Gemini 3 Flash / 3.1 Pro (preview, allowlist Google requise). Contrôle de l'effort de thinking (`minimal` / `low` / `medium` / `high`). Les outils TarkaMCP sont exposés à Gemini via une session MCP locale tenue côté dashboard, ce qui évite les limitations preview du mode "MCP remote" de l'API Gemini. ### Activation diff --git a/src/tarkamcp/dashboard/app.py b/src/tarkamcp/dashboard/app.py index 85bd529..3a99d27 100644 --- a/src/tarkamcp/dashboard/app.py +++ b/src/tarkamcp/dashboard/app.py @@ -646,13 +646,20 @@ def _require_active_session( def _resolve_mcp_url(request: Request, deps: DashboardDeps) -> str: + """Return the URL the engine should connect to for MCP. + + Since the chat engine opens an MCP client session *from the dashboard + process*, the fastest and most reliable target is the local + 127.0.0.1: endpoint served by the same Uvicorn instance. That + avoids a round-trip through Cloudflare and keeps working when the + public hostname is unreachable. Callers can override via + ``TARKAMCP_DASHBOARD_PUBLIC_URL`` if they need a different target. + """ if deps.mcp_public_url: return deps.mcp_public_url.rstrip("/") + "/mcp" - scheme = request.headers.get("x-forwarded-proto", request.url.scheme) - host = request.headers.get( - "x-forwarded-host", request.headers.get("host", "localhost"), - ) - return f"{scheme}://{host}/mcp" + import os as _os + port = _os.environ.get("TARKAMCP_PORT", "8420") + return f"http://127.0.0.1:{port}/mcp" def _sse(event: str, data: Any) -> bytes: diff --git a/src/tarkamcp/dashboard/chat.py b/src/tarkamcp/dashboard/chat.py index a77fbf4..8e25a88 100644 --- a/src/tarkamcp/dashboard/chat.py +++ b/src/tarkamcp/dashboard/chat.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio +import json as _json import time from dataclasses import dataclass, field from typing import Any, AsyncIterator, Iterable, Protocol, runtime_checkable @@ -22,6 +23,26 @@ from .conversations import Message, ToolCall +# --------------------------------------------------------------------------- +# Effort level mapping +# --------------------------------------------------------------------------- + +def _is_gemini_3(model: str) -> bool: + return model.startswith("gemini-3") + + +# Approximate token budgets mapped from the four effort presets. Used for +# Gemini 2.5 which doesn't accept the `thinking_level` enum (that one is +# Gemini 3+). Numbers err on the higher side so "high" actually has room +# to think; Gemini will clamp to the model ceiling if needed. +_BUDGET_BY_EFFORT = { + "minimal": 0, + "low": 1024, + "medium": 4096, + "high": 16384, +} + + # --------------------------------------------------------------------------- # Events # --------------------------------------------------------------------------- @@ -156,19 +177,29 @@ def assemble_assistant_message( # --------------------------------------------------------------------------- -# Real Gemini engine (wired in Stage 3) +# Real Gemini engine # --------------------------------------------------------------------------- -# Mapping our string effort values to the SDK enum is done lazily inside -# GeminiChatEngine.run() to keep import cost low and to make it easy to -# stub out the SDK in tests. +# The SDK is imported lazily so test suites that only exercise the fake +# engine do not pay the (large) import cost. All google-genai / mcp +# references inside GeminiChatEngine are thus guarded. class GeminiChatEngine: - """Real-Gemini implementation. Imports google-genai lazily.""" + """Gemini-backed engine that orchestrates MCP tool calls in-process. + + We open a local MCP ``ClientSession`` against the TarkaMCP endpoint + using the user's bearer, and pass that session to google-genai as a + tool. The SDK auto-discovers the tools, handles function-call / + function-response bookkeeping, and emits text / function_call / + function_response parts through the streaming API. This path does + NOT use the ``McpServer`` remote tool (where Google's backend calls + our MCP directly) — that feature is preview-gated and returned + ``PERMISSION_DENIED`` on standard API keys. + """ def __init__(self, *, api_key: str) -> None: self._api_key = api_key - self._client = None # built on first use + self._client = None # type: ignore[assignment] def _ensure_client(self): if self._client is None: @@ -177,15 +208,27 @@ def _ensure_client(self): return self._client @staticmethod - def _to_thinking_level(effort: str): + def _build_thinking_config(model: str, effort: str): from google.genai import types # type: ignore - mapping = { - "minimal": types.ThinkingLevel.MINIMAL, - "low": types.ThinkingLevel.LOW, - "medium": types.ThinkingLevel.MEDIUM, - "high": types.ThinkingLevel.HIGH, - } - return mapping.get(effort, types.ThinkingLevel.LOW) + + if _is_gemini_3(model): + mapping = { + "minimal": types.ThinkingLevel.MINIMAL, + "low": types.ThinkingLevel.LOW, + "medium": types.ThinkingLevel.MEDIUM, + "high": types.ThinkingLevel.HIGH, + } + return types.ThinkingConfig( + thinking_level=mapping.get(effort, types.ThinkingLevel.LOW), + include_thoughts=False, + ) + + # Gemini 2.5: use thinking_budget instead. + budget = _BUDGET_BY_EFFORT.get(effort, _BUDGET_BY_EFFORT["low"]) + return types.ThinkingConfig( + thinking_budget=budget, + include_thoughts=False, + ) @staticmethod def _build_contents(history, user_text): @@ -210,143 +253,142 @@ def _build_contents(history, user_text): async def run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: try: + import httpx # type: ignore from google.genai import types # type: ignore - except ImportError: - yield ErrorEvent(code="sdk_missing", message="google-genai not installed") + from mcp.client.session import ClientSession # type: ignore + from mcp.client.streamable_http import streamable_http_client # type: ignore + except ImportError as e: # noqa: BLE001 + yield ErrorEvent(code="sdk_missing", message=str(e)) return client = self._ensure_client() - config = types.GenerateContentConfig( - thinking_config=types.ThinkingConfig( - thinking_level=self._to_thinking_level(turn.effort), - include_thoughts=False, - ), - tools=[types.Tool( - mcp_servers=[types.McpServer( - name="tarkamcp", - streamable_http_transport=types.StreamableHttpTransport( - url=turn.mcp_url, - headers={"Authorization": f"Bearer {turn.bearer}"}, - ), - )], - )], - ) - contents = self._build_contents(turn.history, turn.user_text) - - loop = asyncio.get_running_loop() - queue: asyncio.Queue[ChatEvent | None] = asyncio.Queue() - seen_tool_ids: set[str] = set() + headers = {"Authorization": f"Bearer {turn.bearer}"} tool_starts: dict[str, float] = {} + seen_tool_ids: set[str] = set() - def _producer(): - try: - stream = client.models.generate_content_stream( - model=turn.model, contents=contents, config=config, - ) - for chunk in stream: - self._emit_chunk(chunk, queue, loop, seen_tool_ids, tool_starts) - except Exception as e: # noqa: BLE001 - asyncio.run_coroutine_threadsafe( - queue.put(ErrorEvent(code="gemini_error", message=str(e))), loop, - ).result() - finally: - asyncio.run_coroutine_threadsafe(queue.put(None), loop).result() - - producer_task = loop.run_in_executor(None, _producer) try: - while True: - event = await queue.get() - if event is None: - break - yield event - finally: - await producer_task - - def _emit_chunk(self, chunk, queue, loop, seen_tool_ids, tool_starts): - # The SDK shape varies; handle the common fields defensively. - candidates = getattr(chunk, "candidates", None) or [] - for cand in candidates: - content = getattr(cand, "content", None) - if not content: - continue - parts = getattr(content, "parts", None) or [] - for part in parts: - text = getattr(part, "text", None) - if text: - asyncio.run_coroutine_threadsafe( - queue.put(TextDelta(text=text)), loop, - ) - fc = getattr(part, "function_call", None) - if fc: - fc_id = getattr(fc, "id", None) or f"fc_{len(seen_tool_ids)}" - if fc_id not in seen_tool_ids: - seen_tool_ids.add(fc_id) - tool_starts[fc_id] = time.monotonic() - asyncio.run_coroutine_threadsafe( - queue.put(ToolCallStart( - id=fc_id, - name=getattr(fc, "name", "?"), - args=dict(getattr(fc, "args", {}) or {}), - )), loop, + async with httpx.AsyncClient( + headers=headers, + timeout=httpx.Timeout(30.0, read=120.0), + ) as http_client: + async with streamable_http_client( + turn.mcp_url, http_client=http_client, + ) as (read_stream, write_stream, _get_session_id): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + config = types.GenerateContentConfig( + thinking_config=self._build_thinking_config( + turn.model, turn.effort, + ), + tools=[session], # type: ignore[list-item] ) - fr = getattr(part, "function_response", None) - if fr: - fr_id = getattr(fr, "id", None) or "" - if not fr_id: - # Some SDK versions don't echo the id; fall back to - # the most recent unresolved one. - fr_id = next(iter(reversed(list(tool_starts.keys()))), "") - started = tool_starts.pop(fr_id, time.monotonic()) - duration = int((time.monotonic() - started) * 1000) - response = getattr(fr, "response", None) or {} - status = "error" if "error" in response else "ok" - preview = _short_preview(response) - asyncio.run_coroutine_threadsafe( - queue.put(ToolCallEnd( - id=fr_id, status=status, - preview=preview, duration_ms=duration, - )), loop, - ) + contents = self._build_contents(turn.history, turn.user_text) + + stream = await client.aio.models.generate_content_stream( + model=turn.model, contents=contents, config=config, + ) + async for chunk in stream: + for event in _emit_chunk(chunk, tool_starts, seen_tool_ids): + yield event + except Exception as e: # noqa: BLE001 + yield ErrorEvent(code="gemini_error", message=str(e)) async def title(self, *, model: str, user_text: str) -> str | None: try: from google.genai import types # type: ignore except ImportError: return None - - loop = asyncio.get_running_loop() - - def _call() -> str | None: - try: - client = self._ensure_client() - resp = client.models.generate_content( - model=model, - contents=[ - types.Content(role="user", parts=[types.Part(text=( - "Donne un titre français de 4 mots maximum, sans emoji, " - "sans guillemets, sans ponctuation finale, qui résume cette demande:\n\n" - f"{user_text}" - ))]), - ], - config=types.GenerateContentConfig( - thinking_config=types.ThinkingConfig( - thinking_level=types.ThinkingLevel.MINIMAL, - ), - ), - ) - text = (getattr(resp, "text", "") or "").strip().strip('"').strip() - return text[:80] or None - except Exception: # noqa: BLE001 - return None - - return await loop.run_in_executor(None, _call) + client = self._ensure_client() + try: + # Force minimal thinking to keep titling fast and cheap. + resp = await client.aio.models.generate_content( + model=model, + contents=[types.Content( + role="user", + parts=[types.Part(text=( + "Donne un titre français de 4 mots maximum, sans emoji, " + "sans guillemets, sans ponctuation finale, qui résume cette demande:\n\n" + f"{user_text}" + ))], + )], + config=types.GenerateContentConfig( + thinking_config=self._build_thinking_config(model, "minimal"), + ), + ) + except Exception: # noqa: BLE001 + return None + text = (getattr(resp, "text", "") or "").strip().strip('"').strip() + return text[:80] or None + + +def _emit_chunk( + chunk: Any, + tool_starts: dict[str, float], + seen_tool_ids: set[str], +) -> list[ChatEvent]: + """Translate a google-genai stream chunk into ChatEvents. + + The SDK shape varies across minor versions; everything is looked up + defensively. When the SDK auto-executes MCP tools via the local + ClientSession, we still observe ``function_call`` parts for each + invocation and ``function_response`` parts for each result. + """ + out: list[ChatEvent] = [] + candidates = getattr(chunk, "candidates", None) or [] + for cand in candidates: + content = getattr(cand, "content", None) + if not content: + continue + parts = getattr(content, "parts", None) or [] + for part in parts: + text = getattr(part, "text", None) + if text: + if getattr(part, "thought", False): + out.append(ThinkingDelta(summary=text)) + else: + out.append(TextDelta(text=text)) + + fc = getattr(part, "function_call", None) + if fc: + fc_id = getattr(fc, "id", None) or f"fc_{len(seen_tool_ids)}" + if fc_id not in seen_tool_ids: + seen_tool_ids.add(fc_id) + tool_starts[fc_id] = time.monotonic() + args = getattr(fc, "args", {}) or {} + out.append(ToolCallStart( + id=fc_id, + name=getattr(fc, "name", "?"), + args=dict(args), + )) + + fr = getattr(part, "function_response", None) + if fr: + fr_id = getattr(fr, "id", None) or "" + if not fr_id: + # Some SDK versions omit the id on the response; fall + # back to the most recent unresolved invocation. + fr_id = next( + iter(reversed(list(tool_starts.keys()))), "", + ) + started = tool_starts.pop(fr_id, time.monotonic()) + duration = int((time.monotonic() - started) * 1000) + response = getattr(fr, "response", None) or {} + status = "error" if ( + isinstance(response, dict) and "error" in response + ) else "ok" + out.append(ToolCallEnd( + id=fr_id, status=status, + preview=_short_preview(response), + duration_ms=duration, + )) + return out def _short_preview(payload: Any, *, limit: int = 500) -> str: if isinstance(payload, dict) and "result" in payload: payload = payload["result"] if isinstance(payload, (dict, list)): - import json as _json text = _json.dumps(payload, ensure_ascii=False)[:limit] return text text = str(payload) diff --git a/src/tarkamcp/dashboard/conversations.py b/src/tarkamcp/dashboard/conversations.py index b02224f..64aa015 100644 --- a/src/tarkamcp/dashboard/conversations.py +++ b/src/tarkamcp/dashboard/conversations.py @@ -18,8 +18,15 @@ VALID_EFFORTS = ("minimal", "low", "medium", "high") -VALID_MODELS = ("gemini-3-flash-preview", "gemini-3.1-pro-preview") -DEFAULT_MODEL = "gemini-3-flash-preview" +# Stable Gemini 2.5 ship first (wider access), then Gemini 3 preview +# variants for users on the allowlist. +VALID_MODELS = ( + "gemini-2.5-flash", + "gemini-2.5-pro", + "gemini-3-flash-preview", + "gemini-3.1-pro-preview", +) +DEFAULT_MODEL = "gemini-2.5-flash" DEFAULT_EFFORT = "low" diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index 572fa55..ca0bf4b 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -220,6 +220,33 @@ def test_cleanup_expired(store): assert store.load(s_new.session_id) is not None +def test_thinking_config_for_gemini_3(): + from tarkamcp.dashboard.chat import GeminiChatEngine + + cfg = GeminiChatEngine._build_thinking_config("gemini-3-flash-preview", "high") + assert cfg.thinking_level is not None + assert cfg.thinking_budget is None + assert cfg.include_thoughts is False + + +def test_thinking_config_for_gemini_2_5(): + from tarkamcp.dashboard.chat import GeminiChatEngine + + cfg = GeminiChatEngine._build_thinking_config("gemini-2.5-flash", "medium") + assert cfg.thinking_level is None + assert cfg.thinking_budget == 4096 + + +def test_thinking_config_unknown_effort_defaults_to_low(): + from tarkamcp.dashboard.chat import GeminiChatEngine + + cfg3 = GeminiChatEngine._build_thinking_config("gemini-3.1-pro-preview", "nonsense") + # Enum repr contains LOW + assert "LOW" in str(cfg3.thinking_level) + cfg25 = GeminiChatEngine._build_thinking_config("gemini-2.5-pro", "nonsense") + assert cfg25.thinking_budget == 1024 # low + + def test_migration_v1_to_v2_renames_gemini_models(tmp_path): """A DB created under schema v1 with bare model names must be upgraded.""" import sqlite3 From 9a261080bd184785a84f1b3a3ed6e23df798f765 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 01:26:55 +0200 Subject: [PATCH 030/155] Unwrap TaskGroup ExceptionGroups + restore remote MCP mode "unhandled errors in a TaskGroup (1 sub-exception)" from anyio swallowed the real failure. GeminiChatEngine.run now walks nested ExceptionGroups to surface the leaf exception in the ErrorEvent message and prints the full traceback to stderr (journalctl) for diagnosis. Also restores the remote-MCP-server path as an opt-in alternative to the local ClientSession mode, since Gemini 3 natively supports McpServer (StreamableHttpTransport) as a tool. Select via TARKAMCP_DASHBOARD_MCP_MODE=remote|local (default local). Remote mode uses the public URL so Google's backend can reach the MCP endpoint; local mode stays on 127.0.0.1 for speed and works without preview allowlist. Four new unit tests cover the exception unwrapping helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/__main__.py | 4 + src/tarkamcp/dashboard/app.py | 33 +++++--- src/tarkamcp/dashboard/chat.py | 147 +++++++++++++++++++++++++-------- tests/test_dashboard_unit.py | 33 ++++++++ 4 files changed, 174 insertions(+), 43 deletions(-) diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py index d7da078..283590b 100644 --- a/src/tarkamcp/__main__.py +++ b/src/tarkamcp/__main__.py @@ -534,6 +534,9 @@ def _build_dashboard_routes(client_store, token_store, totp_locked, api_key = os.environ.get("GEMINI_API_KEY", "") engine = GeminiChatEngine(api_key=api_key) if api_key else None mcp_public_url = os.environ.get("TARKAMCP_DASHBOARD_PUBLIC_URL", "").strip() or None + mcp_mode = os.environ.get("TARKAMCP_DASHBOARD_MCP_MODE", "local").strip().lower() + if mcp_mode not in ("local", "remote"): + mcp_mode = "local" deps = DashboardDeps( database=database, @@ -546,6 +549,7 @@ def _build_dashboard_routes(client_store, token_store, totp_locked, conversations=conversations, engine=engine, mcp_public_url=mcp_public_url, + mcp_mode=mcp_mode, ) return build_dashboard_routes(deps) diff --git a/src/tarkamcp/dashboard/app.py b/src/tarkamcp/dashboard/app.py index 3a99d27..79b575d 100644 --- a/src/tarkamcp/dashboard/app.py +++ b/src/tarkamcp/dashboard/app.py @@ -68,9 +68,14 @@ class DashboardDeps: totp_record_success: Callable[[str], None] conversations: ConversationStore | None = None engine: ChatEngine | None = None - # Public URL used by Gemini's backend to call this MCP server. Falls - # back to the request's Host header at call time when unset. + # Public URL used by Gemini's backend to call this MCP server in + # "remote" mode. Falls back to the request's Host header at call time + # when unset. Ignored in "local" mode. mcp_public_url: str | None = None + # "local": dashboard holds an MCP ClientSession (default; works on + # any API key). "remote": pass an McpServer to Gemini with a public + # URL so Google's backend calls MCP directly (Gemini 3 native). + mcp_mode: str = "local" # --------------------------------------------------------------------------- @@ -530,6 +535,7 @@ async def stream(): effort=effort, bearer=session.mcp_bearer or "", mcp_url=_resolve_mcp_url(request, deps), + mcp_mode=deps.mcp_mode, ) try: @@ -646,17 +652,24 @@ def _require_active_session( def _resolve_mcp_url(request: Request, deps: DashboardDeps) -> str: - """Return the URL the engine should connect to for MCP. - - Since the chat engine opens an MCP client session *from the dashboard - process*, the fastest and most reliable target is the local - 127.0.0.1: endpoint served by the same Uvicorn instance. That - avoids a round-trip through Cloudflare and keeps working when the - public hostname is unreachable. Callers can override via - ``TARKAMCP_DASHBOARD_PUBLIC_URL`` if they need a different target. + """Return the URL the chat engine should use for MCP. + + - In **local** mode the dashboard opens the session itself, so a + loopback target (``http://127.0.0.1:/mcp``) is ideal: no + Cloudflare round-trip and no dependency on the public hostname. + - In **remote** mode Google's backend calls the URL directly, so we + must return a publicly reachable address. Users can override via + ``TARKAMCP_DASHBOARD_PUBLIC_URL``; otherwise we fall back to the + reverse-proxy Host header. """ if deps.mcp_public_url: return deps.mcp_public_url.rstrip("/") + "/mcp" + if deps.mcp_mode == "remote": + scheme = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get( + "x-forwarded-host", request.headers.get("host", "localhost"), + ) + return f"{scheme}://{host}/mcp" import os as _os port = _os.environ.get("TARKAMCP_PORT", "8420") return f"http://127.0.0.1:{port}/mcp" diff --git a/src/tarkamcp/dashboard/chat.py b/src/tarkamcp/dashboard/chat.py index 8e25a88..b5d5218 100644 --- a/src/tarkamcp/dashboard/chat.py +++ b/src/tarkamcp/dashboard/chat.py @@ -16,12 +16,38 @@ import asyncio import json as _json +import logging +import sys import time +import traceback from dataclasses import dataclass, field from typing import Any, AsyncIterator, Iterable, Protocol, runtime_checkable from .conversations import Message, ToolCall +_logger = logging.getLogger("tarkamcp.dashboard.chat") + + +def _unwrap_exception(exc: BaseException) -> BaseException: + """Drill into nested ExceptionGroups to return the leaf exception. + + anyio and asyncio wrap concurrent errors in ``ExceptionGroup``; the + default string representation is ``"unhandled errors in a TaskGroup + (N sub-exception)"`` which hides the actual failure. We walk the + chain to expose the most specific underlying error. + """ + current = exc + for _ in range(8): # bounded recursion, exception chains are shallow + inner = getattr(current, "exceptions", None) + if not inner: + return current + # Prefer the first exception that is itself NOT an ExceptionGroup. + for sub in inner: + if not getattr(sub, "exceptions", None): + return sub + current = inner[0] + return current + # --------------------------------------------------------------------------- # Effort level mapping @@ -94,7 +120,13 @@ class TurnInput: model: str effort: str bearer: str - mcp_url: str # public URL for the MCP endpoint + mcp_url: str # URL (remote mode) or connect URL (local mode) + # "local": dashboard opens an MCP ClientSession against mcp_url and + # passes the session to Gemini as a tool. Works on any key. + # "remote": passes an McpServer(url, headers) to Gemini and lets + # Google's backend call mcp_url directly. Natively supported + # on Gemini 3; requires mcp_url to be publicly reachable. + mcp_mode: str = "local" # --------------------------------------------------------------------------- @@ -253,46 +285,95 @@ def _build_contents(history, user_text): async def run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: try: - import httpx # type: ignore + async for event in self._run(turn): + yield event + except BaseException as e: # noqa: BLE001 + leaf = _unwrap_exception(e) + # Write the full traceback to stderr so journalctl has the + # real cause; the client only gets a short message. + traceback.print_exception(e, file=sys.stderr) + _logger.error("gemini chat turn failed: %s", leaf, exc_info=False) + yield ErrorEvent( + code="gemini_error", + message=f"{type(leaf).__name__}: {leaf}", + ) + + async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: + try: from google.genai import types # type: ignore - from mcp.client.session import ClientSession # type: ignore - from mcp.client.streamable_http import streamable_http_client # type: ignore - except ImportError as e: # noqa: BLE001 + except ImportError as e: yield ErrorEvent(code="sdk_missing", message=str(e)) return - client = self._ensure_client() - headers = {"Authorization": f"Bearer {turn.bearer}"} tool_starts: dict[str, float] = {} seen_tool_ids: set[str] = set() + contents = self._build_contents(turn.history, turn.user_text) + thinking = self._build_thinking_config(turn.model, turn.effort) + + if turn.mcp_mode == "remote": + # Google's backend calls the MCP endpoint directly. Requires a + # publicly reachable URL and (on many keys) allowlist access to + # the mcp_servers feature. + config = types.GenerateContentConfig( + thinking_config=thinking, + tools=[types.Tool( + mcp_servers=[types.McpServer( + name="tarkamcp", + streamable_http_transport=types.StreamableHttpTransport( + url=turn.mcp_url, + headers={ + "Authorization": f"Bearer {turn.bearer}", + }, + ), + )], + )], + ) + client = self._ensure_client() + stream = await client.aio.models.generate_content_stream( + model=turn.model, contents=contents, config=config, + ) + async for chunk in stream: + for event in _emit_chunk(chunk, tool_starts, seen_tool_ids): + yield event + return + + # Default "local" mode: open an MCP ClientSession from the + # dashboard process and hand it to the SDK as a tool. try: - async with httpx.AsyncClient( - headers=headers, - timeout=httpx.Timeout(30.0, read=120.0), - ) as http_client: - async with streamable_http_client( - turn.mcp_url, http_client=http_client, - ) as (read_stream, write_stream, _get_session_id): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - - config = types.GenerateContentConfig( - thinking_config=self._build_thinking_config( - turn.model, turn.effort, - ), - tools=[session], # type: ignore[list-item] - ) - contents = self._build_contents(turn.history, turn.user_text) - - stream = await client.aio.models.generate_content_stream( - model=turn.model, contents=contents, config=config, - ) - async for chunk in stream: - for event in _emit_chunk(chunk, tool_starts, seen_tool_ids): - yield event - except Exception as e: # noqa: BLE001 - yield ErrorEvent(code="gemini_error", message=str(e)) + import httpx # type: ignore + from mcp.client.session import ClientSession # type: ignore + from mcp.client.streamable_http import ( # type: ignore + streamable_http_client, + ) + except ImportError as e: + yield ErrorEvent(code="sdk_missing", message=str(e)) + return + + headers = {"Authorization": f"Bearer {turn.bearer}"} + async with httpx.AsyncClient( + headers=headers, + timeout=httpx.Timeout(30.0, read=120.0), + ) as http_client: + async with streamable_http_client( + turn.mcp_url, http_client=http_client, + ) as (read_stream, write_stream, _get_session_id): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + config = types.GenerateContentConfig( + thinking_config=thinking, + tools=[session], # type: ignore[list-item] + ) + client = self._ensure_client() + stream = await client.aio.models.generate_content_stream( + model=turn.model, contents=contents, config=config, + ) + async for chunk in stream: + for event in _emit_chunk( + chunk, tool_starts, seen_tool_ids, + ): + yield event async def title(self, *, model: str, user_text: str) -> str | None: try: diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index ca0bf4b..401b07a 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -220,6 +220,39 @@ def test_cleanup_expired(store): assert store.load(s_new.session_id) is not None +def test_unwrap_exception_single(): + from tarkamcp.dashboard.chat import _unwrap_exception + + err = ValueError("boom") + assert _unwrap_exception(err) is err + + +def test_unwrap_exception_simple_group(): + from tarkamcp.dashboard.chat import _unwrap_exception + + inner = RuntimeError("real cause") + group = ExceptionGroup("task group", [inner]) + assert _unwrap_exception(group) is inner + + +def test_unwrap_exception_nested_groups(): + from tarkamcp.dashboard.chat import _unwrap_exception + + inner = ConnectionError("network") + nested = ExceptionGroup("inner", [inner]) + outer = ExceptionGroup("outer", [nested]) + assert _unwrap_exception(outer) is inner + + +def test_unwrap_exception_prefers_leaf_over_group(): + from tarkamcp.dashboard.chat import _unwrap_exception + + leaf = TypeError("t") + sibling_group = ExceptionGroup("sibling", [RuntimeError("deep")]) + outer = ExceptionGroup("outer", [sibling_group, leaf]) + assert _unwrap_exception(outer) is leaf + + def test_thinking_config_for_gemini_3(): from tarkamcp.dashboard.chat import GeminiChatEngine From f114f8d9eda99393eed50e91ac46065cc5d1fd61 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 01:30:32 +0200 Subject: [PATCH 031/155] Classify 403 as model-access error with a clear remediation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real cause of the earlier PERMISSION_DENIED was that Gemini 3 preview models (gemini-3-flash-preview, gemini-3.1-pro-preview) require allowlist access on Google AI Studio -- the user's key was on standard tier. MCP plumbing was never reached. _classify_error now detects 403/PERMISSION_DENIED on preview models and returns a French-language message telling the user to switch to gemini-2.5-flash or gemini-2.5-pro via the dropdown. Also handles 404/NOT_FOUND and 429/RESOURCE_EXHAUSTED with distinct codes. Five new unit tests cover the classification branches. README gained a "Modèles & accès" section documenting the allowlist gotcha and a "Mode MCP (local / remote)" table for picking the integration path. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 28 ++++++++++++++++- src/tarkamcp/dashboard/chat.py | 56 +++++++++++++++++++++++++++++++--- tests/test_dashboard_unit.py | 46 ++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 47f86c3..4c02a6f 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ response = client.models.generate_content( Un panel web optionnel est servi par TarkaMCP sur la même URL (`https://mcp.example.com/app/...`). Il offre : - `/app/login` — récupérer un bearer MCP via Client ID + Secret + TOTP, session persistée 90 jours dans un cookie HttpOnly. Plus de curl sur téléphone. -- `/app/chat` — chat multi-conversations avec Gemini 2.5 Flash/Pro (stable) ou Gemini 3 Flash / 3.1 Pro (preview, allowlist Google requise). Contrôle de l'effort de thinking (`minimal` / `low` / `medium` / `high`). Les outils TarkaMCP sont exposés à Gemini via une session MCP locale tenue côté dashboard, ce qui évite les limitations preview du mode "MCP remote" de l'API Gemini. +- `/app/chat` — chat multi-conversations avec Gemini 2.5 Flash/Pro (stable, accessibles avec toute clé AI Studio) ou Gemini 3 Flash / 3.1 Pro (preview, allowlist Google requise — sinon 403 PERMISSION_DENIED). Le défaut est `gemini-2.5-flash`. Contrôle de l'effort de thinking (`minimal` / `low` / `medium` / `high`). ### Activation @@ -259,6 +259,32 @@ SQLite à `/opt/tarkamcp/dashboard.db` (WAL). Trois tables : Tout est scopé par `client_id` ; tu peux avoir plusieurs sessions actives (téléphone + PC) pour un même client. +### Mode MCP (local / remote) + +Le dashboard expose deux chemins pour que Gemini utilise les outils TarkaMCP : + +| Mode | Comment | Quand l'utiliser | +|------|---------|------------------| +| `local` (défaut) | Le dashboard tient une session MCP en process et la passe à Gemini comme "tool". Google ne voit que des function declarations. | Marche sur toutes les clés, indépendant du preview. | +| `remote` | On passe un `McpServer(url, headers)` à Gemini, Google appelle `mcp.example.com/mcp` directement. | Natif Gemini 3 — à activer si ta clé a l'allowlist. | + +Switch via : +```env +TARKAMCP_DASHBOARD_MCP_MODE=remote +TARKAMCP_DASHBOARD_PUBLIC_URL=https://mcp.example.com # requis en mode remote si pas de X-Forwarded-Host +``` + +### Modèles & accès + +- **Gemini 2.5 Flash / Pro** : dispos sur toute clé AI Studio. **Utilise-les par défaut.** +- **Gemini 3 Flash / 3.1 Pro (preview)** : gated par allowlist Google. Si ta clé n'y a pas accès, tu verras : + ``` + Ta clé Gemini n'a pas accès à gemini-3-flash-preview (allowlist Google requise + pour les modèles preview). Bascule sur gemini-2.5-flash ou gemini-2.5-pro via + le dropdown en bas à gauche — ils sont dispos sur toutes les clés AI Studio. + ``` + Demande l'accès sur [ai.google.dev](https://ai.google.dev) ou reste sur 2.5. + ### Désactiver ```env diff --git a/src/tarkamcp/dashboard/chat.py b/src/tarkamcp/dashboard/chat.py index b5d5218..1e55cc1 100644 --- a/src/tarkamcp/dashboard/chat.py +++ b/src/tarkamcp/dashboard/chat.py @@ -293,10 +293,8 @@ async def run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: # real cause; the client only gets a short message. traceback.print_exception(e, file=sys.stderr) _logger.error("gemini chat turn failed: %s", leaf, exc_info=False) - yield ErrorEvent( - code="gemini_error", - message=f"{type(leaf).__name__}: {leaf}", - ) + code, message = _classify_error(leaf, turn.model) + yield ErrorEvent(code=code, message=message) async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: try: @@ -466,6 +464,56 @@ def _emit_chunk( return out +def _classify_error(exc: BaseException, model: str) -> tuple[str, str]: + """Map a leaf exception to a short user-facing (code, message). + + The dashboard UI renders the message verbatim, so it must be concise + and actionable in French. Full technical detail lands in journalctl + via the stderr traceback dump. + """ + name = type(exc).__name__ + msg = str(exc) + + # Google API 403 usually means "this API key cannot access this + # model or feature" -- preview models require allowlist access. + if "PERMISSION_DENIED" in msg or "403" in msg and "caller" in msg.lower(): + is_preview = "preview" in model + if is_preview: + return ( + "model_access_denied", + ( + f"Ta clé Gemini n'a pas accès à {model} (allowlist Google " + "requise pour les modèles preview). Bascule sur " + "gemini-2.5-flash ou gemini-2.5-pro via le dropdown en bas " + "à gauche — ils sont dispos sur toutes les clés AI Studio." + ), + ) + return ( + "permission_denied", + ( + f"Gemini a refusé la requête sur {model} (403 PERMISSION_DENIED). " + "Vérifie que ta clé API peut utiliser ce modèle." + ), + ) + + # 404 on the model -> invalid model ID for the API version. + if "NOT_FOUND" in msg or "404" in msg and "model" in msg.lower(): + return ( + "model_not_found", + f"Modèle {model} introuvable côté Gemini. Essaie un autre modèle.", + ) + + # 429 rate limit / quota. + if "RESOURCE_EXHAUSTED" in msg or "quota" in msg.lower(): + return ( + "rate_limited", + "Quota Gemini dépassé. Réessaie dans quelques secondes.", + ) + + # Default: include the exception name to help future triage. + return ("gemini_error", f"{name}: {msg}") + + def _short_preview(payload: Any, *, limit: int = 500) -> str: if isinstance(payload, dict) and "result" in payload: payload = payload["result"] diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index 401b07a..76a56d1 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -253,6 +253,52 @@ def test_unwrap_exception_prefers_leaf_over_group(): assert _unwrap_exception(outer) is leaf +def test_classify_error_preview_model_permission_denied(): + from tarkamcp.dashboard.chat import _classify_error + + err = Exception( + "403 PERMISSION_DENIED. The caller does not have permission" + ) + code, msg = _classify_error(err, "gemini-3-flash-preview") + assert code == "model_access_denied" + assert "gemini-2.5" in msg + assert "gemini-3-flash-preview" in msg + + +def test_classify_error_stable_model_permission_denied(): + from tarkamcp.dashboard.chat import _classify_error + + err = Exception("403 PERMISSION_DENIED. caller issue") + code, msg = _classify_error(err, "gemini-2.5-flash") + assert code == "permission_denied" + assert "gemini-2.5-flash" in msg + + +def test_classify_error_model_not_found(): + from tarkamcp.dashboard.chat import _classify_error + + err = Exception("404 NOT_FOUND. models/foo is not found") + code, msg = _classify_error(err, "foo") + assert code == "model_not_found" + + +def test_classify_error_rate_limit(): + from tarkamcp.dashboard.chat import _classify_error + + err = Exception("429 RESOURCE_EXHAUSTED. Quota exceeded") + code, _msg = _classify_error(err, "gemini-2.5-flash") + assert code == "rate_limited" + + +def test_classify_error_generic(): + from tarkamcp.dashboard.chat import _classify_error + + err = RuntimeError("boom") + code, msg = _classify_error(err, "gemini-2.5-flash") + assert code == "gemini_error" + assert "RuntimeError" in msg + + def test_thinking_config_for_gemini_3(): from tarkamcp.dashboard.chat import GeminiChatEngine From 69c6ad090cf6d726279afcfd9c79e9d75b1de3de Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 01:40:11 +0200 Subject: [PATCH 032/155] Auto-retry transient Gemini 5xx + classify 500 INTERNAL Google's generateContentStream sporadically returns 500 INTERNAL on preview and stable models alike. The dashboard now retries up to 3 times with 1s/2.5s/5s backoff before streaming any output, and only surfaces the error once retries are exhausted or the stream has already emitted content (mid-stream failures can't be replayed). Added friendly classifications for upstream_internal (500), upstream_unavailable (503) and upstream_timeout (504), plus six new unit tests covering the retry loop and new error branches. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/dashboard/chat.py | 89 +++++++++++++++++++++---- tests/test_dashboard_unit.py | 116 +++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 11 deletions(-) diff --git a/src/tarkamcp/dashboard/chat.py b/src/tarkamcp/dashboard/chat.py index 1e55cc1..480e5f2 100644 --- a/src/tarkamcp/dashboard/chat.py +++ b/src/tarkamcp/dashboard/chat.py @@ -284,17 +284,40 @@ def _build_contents(history, user_text): return contents async def run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: - try: - async for event in self._run(turn): - yield event - except BaseException as e: # noqa: BLE001 - leaf = _unwrap_exception(e) - # Write the full traceback to stderr so journalctl has the - # real cause; the client only gets a short message. - traceback.print_exception(e, file=sys.stderr) - _logger.error("gemini chat turn failed: %s", leaf, exc_info=False) - code, message = _classify_error(leaf, turn.model) - yield ErrorEvent(code=code, message=message) + # Retry schedule for transient Google 5xx errors. We only retry + # while nothing has been streamed yet -- mid-stream failures are + # surfaced as-is because we can't replay partial output. + backoffs = [1.0, 2.5, 5.0] + attempt = 0 + while True: + yielded_any = False + try: + async for event in self._run(turn): + yielded_any = True + yield event + return + except BaseException as e: # noqa: BLE001 + leaf = _unwrap_exception(e) + traceback.print_exception(e, file=sys.stderr) + + if ( + not yielded_any + and attempt < len(backoffs) + and _is_transient_error(leaf) + ): + delay = backoffs[attempt] + attempt += 1 + _logger.warning( + "gemini chat transient error (%s); retry %d/%d in %.1fs", + leaf, attempt, len(backoffs), delay, + ) + await asyncio.sleep(delay) + continue + + _logger.error("gemini chat turn failed: %s", leaf, exc_info=False) + code, message = _classify_error(leaf, turn.model) + yield ErrorEvent(code=code, message=message) + return async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: try: @@ -464,6 +487,21 @@ def _emit_chunk( return out +def _is_transient_error(exc: BaseException) -> bool: + """Return True if ``exc`` looks like a retryable Google 5xx / network blip.""" + msg = str(exc) + if "INTERNAL" in msg and ("500" in msg or "Internal error" in msg): + return True + if "UNAVAILABLE" in msg or "503" in msg: + return True + if "DEADLINE_EXCEEDED" in msg or "504" in msg: + return True + name = type(exc).__name__ + if name in {"ReadTimeout", "WriteTimeout", "ConnectTimeout", "ConnectError"}: + return True + return False + + def _classify_error(exc: BaseException, model: str) -> tuple[str, str]: """Map a leaf exception to a short user-facing (code, message). @@ -510,6 +548,35 @@ def _classify_error(exc: BaseException, model: str) -> tuple[str, str]: "Quota Gemini dépassé. Réessaie dans quelques secondes.", ) + # 500 INTERNAL -- Google-side transient failure. We already retried + # a few times server-side before surfacing, so this message asks the + # user to try again rather than pretending retrying wasn't tried. + if "INTERNAL" in msg and ("500" in msg or "Internal error" in msg): + return ( + "upstream_internal", + ( + f"Gemini a renvoyé une erreur interne (500) sur {model} " + "malgré plusieurs tentatives. C'est côté Google, pas toi. " + "Attends 10–20 s et renvoie ton message — si ça persiste, " + "bascule sur un autre modèle." + ), + ) + + # 503 UNAVAILABLE / 504 DEADLINE_EXCEEDED -- surge / latency. + if "UNAVAILABLE" in msg or "503" in msg: + return ( + "upstream_unavailable", + ( + "Gemini est temporairement indisponible (503). Réessaie " + "dans quelques secondes." + ), + ) + if "DEADLINE_EXCEEDED" in msg or "504" in msg: + return ( + "upstream_timeout", + "Gemini a dépassé le délai (504). Réessaie avec une question plus courte.", + ) + # Default: include the exception name to help future triage. return ("gemini_error", f"{name}: {msg}") diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index 76a56d1..6155f24 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -299,6 +299,122 @@ def test_classify_error_generic(): assert "RuntimeError" in msg +def test_classify_error_upstream_internal(): + from tarkamcp.dashboard.chat import _classify_error + + err = Exception( + "500 INTERNAL. {'error': {'code': 500, 'message': 'Internal error encountered.', 'status': 'INTERNAL'}}" + ) + code, msg = _classify_error(err, "gemini-3-flash-preview") + assert code == "upstream_internal" + assert "500" in msg + assert "gemini-3-flash-preview" in msg + + +def test_classify_error_upstream_unavailable(): + from tarkamcp.dashboard.chat import _classify_error + + err = Exception("503 UNAVAILABLE. The service is temporarily unavailable") + code, _msg = _classify_error(err, "gemini-2.5-flash") + assert code == "upstream_unavailable" + + +def test_classify_error_upstream_timeout(): + from tarkamcp.dashboard.chat import _classify_error + + err = Exception("504 DEADLINE_EXCEEDED") + code, _msg = _classify_error(err, "gemini-2.5-flash") + assert code == "upstream_timeout" + + +def test_is_transient_error_matches_5xx(): + from tarkamcp.dashboard.chat import _is_transient_error + + assert _is_transient_error(Exception("500 INTERNAL. Internal error")) + assert _is_transient_error(Exception("503 UNAVAILABLE")) + assert _is_transient_error(Exception("504 DEADLINE_EXCEEDED")) + assert not _is_transient_error(Exception("403 PERMISSION_DENIED")) + assert not _is_transient_error(RuntimeError("boom")) + + +def _run_retry_scenario(monkeypatch, fake_run): + """Helper: swap in ``fake_run`` on a GeminiChatEngine and drain events.""" + import asyncio as _asyncio + from tarkamcp.dashboard import chat as chat_mod + from tarkamcp.dashboard.chat import GeminiChatEngine, TurnInput + + async def _noop_sleep(_): + return None + + monkeypatch.setattr(chat_mod.asyncio, "sleep", _noop_sleep) + engine = GeminiChatEngine(api_key="test") + engine._run = fake_run # type: ignore[assignment] + turn = TurnInput( + history=[], user_text="x", model="gemini-2.5-flash", + effort="low", bearer="b", mcp_url="http://localhost/mcp", + ) + + async def _drain(): + return [e async for e in engine.run(turn)] + + return _asyncio.run(_drain()) + + +def test_gemini_retry_recovers_from_transient_500(monkeypatch): + """run() retries transient 5xx errors before surfacing them.""" + from tarkamcp.dashboard.chat import TextDelta + + attempts = {"n": 0} + + async def fake_run(_turn): + attempts["n"] += 1 + if attempts["n"] < 3: + raise Exception( + "500 INTERNAL. {'error': {'code': 500, 'status': 'INTERNAL'}}" + ) + yield TextDelta(text="ok after retries") + + events = _run_retry_scenario(monkeypatch, fake_run) + assert attempts["n"] == 3 + assert len(events) == 1 + assert isinstance(events[0], TextDelta) + assert events[0].text == "ok after retries" + + +def test_gemini_retry_surfaces_after_max_attempts(monkeypatch): + from tarkamcp.dashboard.chat import ErrorEvent + + attempts = {"n": 0} + + async def fake_run(_turn): + attempts["n"] += 1 + raise Exception("500 INTERNAL. persistent") + yield # pragma: no cover -- makes fake_run an async generator + + events = _run_retry_scenario(monkeypatch, fake_run) + # initial attempt + 3 retries = 4 total + assert attempts["n"] == 4 + assert len(events) == 1 + assert isinstance(events[0], ErrorEvent) + assert events[0].code == "upstream_internal" + + +def test_gemini_retry_skips_on_non_transient(monkeypatch): + from tarkamcp.dashboard.chat import ErrorEvent + + attempts = {"n": 0} + + async def fake_run(_turn): + attempts["n"] += 1 + raise Exception("403 PERMISSION_DENIED. caller") + yield # pragma: no cover + + events = _run_retry_scenario(monkeypatch, fake_run) + assert attempts["n"] == 1 # no retries for 403 + assert isinstance(events[0], ErrorEvent) + assert events[0].code == "permission_denied" + + def test_thinking_config_for_gemini_3(): from tarkamcp.dashboard.chat import GeminiChatEngine From 83b741b4389654c5acdbd57f3f37aba6549f6c3a Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 01:49:52 +0200 Subject: [PATCH 033/155] Fix deterministic 500 INTERNAL on gemini-2.5-pro via manual MCP tool loop Root cause (three-part failure): 1. In local mode, _resolve_mcp_url used the public URL first if TARKAMCP_DASHBOARD_PUBLIC_URL was set, so the dashboard round-tripped through its own reverse proxy to reach /mcp. The proxy could strip the bearer, yielding the 401 Unauthorized we saw on every retry. 2. That 401 silently corrupted the MCP tool list the SDK built for Gemini, and the malformed request triggered 500 INTERNAL -- not a transient Google issue, as the consistent reproduction proved. 3. Even with a clean session, 2.5 Pro + thinking_config + tools + streaming is a known buggy combination in google-genai (googleapis/python-genai#1892, #2081, #1374). Fixes: - _resolve_mcp_url now ignores mcp_public_url in local mode and always targets loopback. Public URL is used only in remote mode where Google's backend needs to reach /mcp directly. - Replaced tools=[session] + SDK automatic function calling with a manual function-calling loop: list_tools() -> FunctionDeclaration via parameters_json_schema, stream Gemini, call_tool() for each function_call, feed FunctionResponse back. Bypasses the SDK's buggy AFC path entirely and surfaces MCP init / list_tools failures as actionable ErrorEvents instead of masquerading as Gemini 500s. - Clamp thinking_budget to 128 on gemini-2.5-pro (the model rejects anything below, effort=minimal was sending 0 and producing 5xx). - Shortened the transient-error retry schedule to 0.5s + 2s (was 1s/2.5s/5s) since persistent 2.5-pro backend episodes are not rescued by more retries. - Added 5 new unit tests covering the schema conversion, the tool result flattening, the 2.5-pro budget clamp, and the corrected public-URL behavior in remote mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/dashboard/app.py | 13 +- src/tarkamcp/dashboard/chat.py | 261 +++++++++++++++++++++++++++++++-- tests/test_dashboard_chat.py | 37 ++++- tests/test_dashboard_unit.py | 82 ++++++++++- 4 files changed, 372 insertions(+), 21 deletions(-) diff --git a/src/tarkamcp/dashboard/app.py b/src/tarkamcp/dashboard/app.py index 79b575d..266b788 100644 --- a/src/tarkamcp/dashboard/app.py +++ b/src/tarkamcp/dashboard/app.py @@ -654,17 +654,20 @@ def _require_active_session( def _resolve_mcp_url(request: Request, deps: DashboardDeps) -> str: """Return the URL the chat engine should use for MCP. - - In **local** mode the dashboard opens the session itself, so a - loopback target (``http://127.0.0.1:/mcp``) is ideal: no - Cloudflare round-trip and no dependency on the public hostname. + - In **local** mode the dashboard opens the session itself, so we + ALWAYS use loopback. Going through a public URL means a round-trip + via Cloudflare (or whatever reverse proxy) which can strip the + Authorization header, deterministically producing a 401 on /mcp + that then cascades into a malformed tools list and a 500 from + Gemini. ``mcp_public_url`` is ignored here by design. - In **remote** mode Google's backend calls the URL directly, so we must return a publicly reachable address. Users can override via ``TARKAMCP_DASHBOARD_PUBLIC_URL``; otherwise we fall back to the reverse-proxy Host header. """ - if deps.mcp_public_url: - return deps.mcp_public_url.rstrip("/") + "/mcp" if deps.mcp_mode == "remote": + if deps.mcp_public_url: + return deps.mcp_public_url.rstrip("/") + "/mcp" scheme = request.headers.get("x-forwarded-proto", request.url.scheme) host = request.headers.get( "x-forwarded-host", request.headers.get("host", "localhost"), diff --git a/src/tarkamcp/dashboard/chat.py b/src/tarkamcp/dashboard/chat.py index 480e5f2..7825f31 100644 --- a/src/tarkamcp/dashboard/chat.py +++ b/src/tarkamcp/dashboard/chat.py @@ -68,6 +68,13 @@ def _is_gemini_3(model: str) -> bool: "high": 16384, } +# Gemini 2.5 Pro cannot disable thinking -- valid range is [128, 32768]. +# Passing 0 or a value below 128 deterministically triggers 400/500 from +# the backend. Flash and Flash-Lite accept 0 (disables thinking). +_MIN_BUDGET_BY_MODEL = { + "gemini-2.5-pro": 128, +} + # --------------------------------------------------------------------------- # Events @@ -255,8 +262,12 @@ def _build_thinking_config(model: str, effort: str): include_thoughts=False, ) - # Gemini 2.5: use thinking_budget instead. + # Gemini 2.5: use thinking_budget instead. Clamp to the model's + # minimum (2.5 Pro requires >=128; passing 0 there causes 500s). budget = _BUDGET_BY_EFFORT.get(effort, _BUDGET_BY_EFFORT["low"]) + min_budget = _MIN_BUDGET_BY_MODEL.get(model, 0) + if budget < min_budget: + budget = min_budget return types.ThinkingConfig( thinking_budget=budget, include_thoughts=False, @@ -286,8 +297,10 @@ def _build_contents(history, user_text): async def run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: # Retry schedule for transient Google 5xx errors. We only retry # while nothing has been streamed yet -- mid-stream failures are - # surfaced as-is because we can't replay partial output. - backoffs = [1.0, 2.5, 5.0] + # surfaced as-is because we can't replay partial output. Kept + # short (2 retries, ~3.5 s total) because persistent 2.5-pro 5xx + # episodes are not rescued by more retries. + backoffs = [0.5, 2.0] attempt = 0 while True: yielded_any = False @@ -360,7 +373,23 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: return # Default "local" mode: open an MCP ClientSession from the - # dashboard process and hand it to the SDK as a tool. + # dashboard process, and run a MANUAL function-calling loop. + # + # We previously passed ``tools=[session]`` and relied on the SDK's + # Automatic Function Calling path, but that route has two known + # failure modes for Gemini 2.5 Pro: + # + # - AFC corrupts MCP function names non-deterministically + # (googleapis/python-genai#1892) + # - Combining thinking_config + MCP + streaming triggers + # MALFORMED_FUNCTION_CALL / 500 INTERNAL on the backend + # (googleapis/python-genai#2081, #1374) + # + # Manual loop: we call ``list_tools()`` ourselves, convert each + # to a ``FunctionDeclaration`` with ``parameters_json_schema``, + # stream Gemini's response, execute any ``function_call`` via + # ``session.call_tool()``, and feed the results back in a new + # ``generate_content_stream`` call until no function_call remains. try: import httpx # type: ignore from mcp.client.session import ClientSession # type: ignore @@ -371,6 +400,11 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: yield ErrorEvent(code="sdk_missing", message=str(e)) return + _logger.info( + "gemini chat: model=%s effort=%s mcp_url=%s", + turn.model, turn.effort, turn.mcp_url, + ) + headers = {"Authorization": f"Bearer {turn.bearer}"} async with httpx.AsyncClient( headers=headers, @@ -380,21 +414,154 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: turn.mcp_url, http_client=http_client, ) as (read_stream, write_stream, _get_session_id): async with ClientSession(read_stream, write_stream) as session: - await session.initialize() + try: + await session.initialize() + except Exception as e: # noqa: BLE001 + yield ErrorEvent( + code="mcp_init_failed", + message=( + f"Impossible de se connecter à l'MCP " + f"({turn.mcp_url}): {e}. Vérifie que " + "TARKAMCP_DASHBOARD_PUBLIC_URL n'est pas " + "défini en mode local." + ), + ) + return + + try: + tools_result = await session.list_tools() + except Exception as e: # noqa: BLE001 + yield ErrorEvent( + code="mcp_list_tools_failed", + message=f"MCP list_tools a échoué: {e}", + ) + return + + mcp_tools = list(tools_result.tools or []) + if not mcp_tools: + yield ErrorEvent( + code="mcp_no_tools", + message=( + "L'MCP n'expose aucun outil. Vérifie que le " + "bearer est valide et que les modules Proxmox " + "sont chargés." + ), + ) + return + + function_decls = [ + _mcp_tool_to_declaration(t, types) for t in mcp_tools + ] + tools_cfg = [types.Tool(function_declarations=function_decls)] config = types.GenerateContentConfig( thinking_config=thinking, - tools=[session], # type: ignore[list-item] + tools=tools_cfg, + automatic_function_calling=( + types.AutomaticFunctionCallingConfig(disable=True) + ), ) client = self._ensure_client() - stream = await client.aio.models.generate_content_stream( - model=turn.model, contents=contents, config=config, + + current_contents = list(contents) + for round_idx in range(_MAX_TOOL_ROUNDS): + stream = await client.aio.models.generate_content_stream( + model=turn.model, + contents=current_contents, + config=config, + ) + + model_parts: list = [] + fc_invocations: list = [] # (fc_part, fc_id) + async for chunk in stream: + for part in _iter_parts(chunk): + text = getattr(part, "text", None) + if text: + if getattr(part, "thought", False): + yield ThinkingDelta(summary=text) + else: + yield TextDelta(text=text) + model_parts.append( + types.Part( + text=text, + thought=bool( + getattr(part, "thought", False) + ), + ) + ) + + fc = getattr(part, "function_call", None) + if fc: + fc_id = ( + getattr(fc, "id", None) + or f"fc_{len(seen_tool_ids)}" + ) + seen_tool_ids.add(fc_id) + tool_starts[fc_id] = time.monotonic() + args = dict(getattr(fc, "args", None) or {}) + fc_invocations.append((fc, fc_id, args)) + model_parts.append(part) + yield ToolCallStart( + id=fc_id, + name=getattr(fc, "name", "?"), + args=args, + ) + + if not fc_invocations: + return # model is done + + current_contents.append( + types.Content(role="model", parts=model_parts) + ) + + response_parts: list = [] + for fc, fc_id, args in fc_invocations: + name = getattr(fc, "name", "") + start = tool_starts.pop(fc_id, time.monotonic()) + try: + result = await session.call_tool(name, args) + payload = _mcp_call_result_to_response(result) + status = ( + "error" + if getattr(result, "isError", False) + else "ok" + ) + except Exception as e: # noqa: BLE001 + payload = {"error": str(e)} + status = "error" + duration = int((time.monotonic() - start) * 1000) + + yield ToolCallEnd( + id=fc_id, status=status, + preview=_short_preview(payload), + duration_ms=duration, + ) + + response_parts.append( + types.Part( + function_response=types.FunctionResponse( + id=getattr(fc, "id", None), + name=name, + response=( + payload + if isinstance(payload, dict) + else {"result": payload} + ), + ) + ) + ) + + current_contents.append( + types.Content(role="user", parts=response_parts) + ) + + yield ErrorEvent( + code="tool_loop_limit", + message=( + f"La boucle d'appel d'outils a dépassé " + f"{_MAX_TOOL_ROUNDS} tours sans réponse finale." + ), ) - async for chunk in stream: - for event in _emit_chunk( - chunk, tool_starts, seen_tool_ids, - ): - yield event async def title(self, *, model: str, user_text: str) -> str | None: try: @@ -424,6 +591,74 @@ async def title(self, *, model: str, user_text: str) -> str | None: return text[:80] or None +# Max rounds of function_call / function_response before we give up. +# Each round is one generate_content_stream + one batch of tool calls. +# 10 is comfortably above realistic orchestration depth while still +# bounding run-away loops. +_MAX_TOOL_ROUNDS = 10 + + +def _iter_parts(chunk: Any): + """Yield every ``Part`` from a google-genai stream chunk.""" + for cand in getattr(chunk, "candidates", None) or []: + content = getattr(cand, "content", None) + if not content: + continue + for part in getattr(content, "parts", None) or []: + yield part + + +def _mcp_tool_to_declaration(tool: Any, types_mod: Any) -> Any: + """Convert an MCP ``Tool`` into a Gemini ``FunctionDeclaration``. + + MCP exposes ``inputSchema`` as a JSON Schema dict which the SDK's + ``parameters_json_schema`` field accepts directly -- no manual + translation to ``types.Schema`` needed. + """ + name = getattr(tool, "name", "") + description = getattr(tool, "description", "") or "" + schema = getattr(tool, "inputSchema", None) + if not isinstance(schema, dict) or not schema: + # Gemini requires an object schema. Default to an empty object so + # tools without declared parameters still validate. + schema = {"type": "object", "properties": {}} + return types_mod.FunctionDeclaration( + name=name, + description=description, + parameters_json_schema=schema, + ) + + +def _mcp_call_result_to_response(result: Any) -> dict: + """Turn an MCP ``CallToolResult`` into a JSON-serialisable response. + + Gemini's ``FunctionResponse.response`` must be a plain dict. We + flatten the MCP content list into ``{"content": [...]}`` plus an + optional ``error`` flag. + """ + out: dict[str, Any] = {} + content_items: list = [] + for c in getattr(result, "content", None) or []: + text = getattr(c, "text", None) + if text is not None: + content_items.append({"type": "text", "text": text}) + continue + data = getattr(c, "data", None) + mime = getattr(c, "mimeType", None) + if data is not None: + content_items.append({ + "type": "binary", + "mimeType": mime or "application/octet-stream", + }) + out["content"] = content_items + if getattr(result, "isError", False): + out["error"] = True + structured = getattr(result, "structuredContent", None) + if structured is not None: + out["structured"] = structured + return out + + def _emit_chunk( chunk: Any, tool_starts: dict[str, float], diff --git a/tests/test_dashboard_chat.py b/tests/test_dashboard_chat.py index 789b11a..a4a3ded 100644 --- a/tests/test_dashboard_chat.py +++ b/tests/test_dashboard_chat.py @@ -252,7 +252,9 @@ def test_chat_stream_simple_text(app_and_client, engine, deps): turn = engine.calls[0] assert turn.user_text == "ping" assert turn.bearer.startswith("b_") - assert turn.mcp_url == "https://mcp.example/mcp" + # Local mode (default) ignores mcp_public_url and uses loopback so + # the dashboard never round-trips through its own reverse proxy. + assert turn.mcp_url == "http://127.0.0.1:8420/mcp" assert turn.history == [] # Message persisted in DB @@ -396,6 +398,39 @@ def test_chat_page_auth_required(app_and_client): assert r.status_code == 302 +def test_chat_stream_remote_mode_uses_public_url(tmp_path, engine): + """In remote mode the public URL IS used (Google's backend calls it).""" + db = Database(tmp_path / "dashboard.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + conversations=ConversationStore(db), + engine=engine, + mcp_public_url="https://mcp.example/", + mcp_mode="remote", + ) + app = Starlette(routes=build_dashboard_routes(deps)) + client = TestClient(app, follow_redirects=False) + + csrf = _login(client) + r = client.post("/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}") + cid = r.json()["conversation"]["id"] + client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"conversation_id": cid, "content": "ping"}), + ) + assert engine.calls[-1].mcp_url == "https://mcp.example/mcp" + assert engine.calls[-1].mcp_mode == "remote" + + def test_chat_page_renders_after_login(app_and_client): _, client = app_and_client _login(client) diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index 6155f24..f5201a9 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -327,6 +327,72 @@ def test_classify_error_upstream_timeout(): assert code == "upstream_timeout" +def test_mcp_tool_to_declaration_passes_input_schema(): + from google.genai import types + + from tarkamcp.dashboard.chat import _mcp_tool_to_declaration + + class FakeMCPTool: + name = "proxmox_list_vms" + description = "List VMs on a Proxmox node" + inputSchema = { + "type": "object", + "properties": {"node": {"type": "string"}}, + "required": ["node"], + } + + decl = _mcp_tool_to_declaration(FakeMCPTool(), types) + assert decl.name == "proxmox_list_vms" + assert decl.description == "List VMs on a Proxmox node" + assert decl.parameters_json_schema["required"] == ["node"] + + +def test_mcp_tool_to_declaration_defaults_schema_when_missing(): + from google.genai import types + + from tarkamcp.dashboard.chat import _mcp_tool_to_declaration + + class FakeMCPTool: + name = "ping" + description = "" + inputSchema = None + + decl = _mcp_tool_to_declaration(FakeMCPTool(), types) + # Gemini rejects empty/missing schemas; helper must substitute an + # empty object schema so the tool still registers. + assert decl.parameters_json_schema == {"type": "object", "properties": {}} + + +def test_mcp_call_result_to_response_flattens_text_content(): + from tarkamcp.dashboard.chat import _mcp_call_result_to_response + + class FakeText: + text = "hello" + + class FakeResult: + content = [FakeText()] + isError = False + structuredContent = None + + payload = _mcp_call_result_to_response(FakeResult()) + assert payload == {"content": [{"type": "text", "text": "hello"}]} + + +def test_mcp_call_result_to_response_marks_error(): + from tarkamcp.dashboard.chat import _mcp_call_result_to_response + + class FakeText: + text = "boom" + + class FakeResult: + content = [FakeText()] + isError = True + structuredContent = None + + payload = _mcp_call_result_to_response(FakeResult()) + assert payload["error"] is True + + def test_is_transient_error_matches_5xx(): from tarkamcp.dashboard.chat import _is_transient_error @@ -392,8 +458,8 @@ async def fake_run(_turn): yield # pragma: no cover -- makes fake_run an async generator events = _run_retry_scenario(monkeypatch, fake_run) - # initial attempt + 3 retries = 4 total - assert attempts["n"] == 4 + # initial attempt + 2 retries = 3 total + assert attempts["n"] == 3 assert len(events) == 1 assert isinstance(events[0], ErrorEvent) assert events[0].code == "upstream_internal" @@ -432,6 +498,18 @@ def test_thinking_config_for_gemini_2_5(): assert cfg.thinking_budget == 4096 +def test_thinking_config_clamps_gemini_2_5_pro_minimum(): + """2.5 Pro cannot disable thinking; budget must clamp to 128+.""" + from tarkamcp.dashboard.chat import GeminiChatEngine + + cfg = GeminiChatEngine._build_thinking_config("gemini-2.5-pro", "minimal") + assert cfg.thinking_budget == 128 # clamped up from 0 + + # 2.5 Flash has no minimum -- "minimal" means disable thinking. + cfg_flash = GeminiChatEngine._build_thinking_config("gemini-2.5-flash", "minimal") + assert cfg_flash.thinking_budget == 0 + + def test_thinking_config_unknown_effort_defaults_to_low(): from tarkamcp.dashboard.chat import GeminiChatEngine From e28a02e6afcb09aadbe488aedd0bb78b45478d01 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 01:54:19 +0200 Subject: [PATCH 034/155] Disable remote MCP mode, route 500 cascade to a clear error The previous 500 INTERNAL loop came from TARKAMCP_DASHBOARD_MCP_MODE=remote being set in the user's environment. In that mode the dashboard hands an ``McpServer`` descriptor to google-genai so Google's own backend fetches /mcp directly; that fetch round-trips via Cloudflare Tunnel and our OAuth middleware, loses the Authorization header, gets 401 on /mcp, and the upstream model then deterministically emits 500 INTERNAL. Retries cannot rescue that path. The Gemini remote-MCP API also has no clean way to forward our bearer+TOTP flow, so remote mode is fundamentally incompatible with this server's auth surface. Changes: - GeminiChatEngine._run yields a single ErrorEvent('remote_mode_disabled') when turn.mcp_mode == "remote", with an actionable French message telling the user which env var to remove. No more SDK call, no more 500 cascade, no more bewildering stack traces in journalctl. - _run dead-code removal: the McpServer + StreamableHttpTransport branch is gone, and _emit_chunk (only used by that branch) with it. - __main__.py prints a startup WARNING when TARKAMCP_DASHBOARD_MCP_MODE=remote is detected, so operators see the problem at service boot rather than only when chatting. - Added a test that drives the real GeminiChatEngine with mcp_mode="remote" and asserts the single ErrorEvent; updated the URL-resolution test to reflect the new semantics (routing still honours the public URL in remote mode so the config survives, only the engine refuses to call). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/__main__.py | 7 +++ src/tarkamcp/dashboard/chat.py | 106 ++++++--------------------------- tests/test_dashboard_chat.py | 37 +++++++++++- 3 files changed, 60 insertions(+), 90 deletions(-) diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py index 283590b..8eb5b45 100644 --- a/src/tarkamcp/__main__.py +++ b/src/tarkamcp/__main__.py @@ -537,6 +537,13 @@ def _build_dashboard_routes(client_store, token_store, totp_locked, mcp_mode = os.environ.get("TARKAMCP_DASHBOARD_MCP_MODE", "local").strip().lower() if mcp_mode not in ("local", "remote"): mcp_mode = "local" + if mcp_mode == "remote": + print( + "WARNING: TARKAMCP_DASHBOARD_MCP_MODE=remote is unsupported " + "(caused 500 INTERNAL on Gemini 3). Chat turns will error out " + "with a helpful message until you remove the variable.", + file=sys.stderr, + ) deps = DashboardDeps( database=database, diff --git a/src/tarkamcp/dashboard/chat.py b/src/tarkamcp/dashboard/chat.py index 7825f31..3a91ed8 100644 --- a/src/tarkamcp/dashboard/chat.py +++ b/src/tarkamcp/dashboard/chat.py @@ -346,34 +346,27 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: thinking = self._build_thinking_config(turn.model, turn.effort) if turn.mcp_mode == "remote": - # Google's backend calls the MCP endpoint directly. Requires a - # publicly reachable URL and (on many keys) allowlist access to - # the mcp_servers feature. - config = types.GenerateContentConfig( - thinking_config=thinking, - tools=[types.Tool( - mcp_servers=[types.McpServer( - name="tarkamcp", - streamable_http_transport=types.StreamableHttpTransport( - url=turn.mcp_url, - headers={ - "Authorization": f"Bearer {turn.bearer}", - }, - ), - )], - )], - ) - client = self._ensure_client() - stream = await client.aio.models.generate_content_stream( - model=turn.model, contents=contents, config=config, + # Historically we supported Google's backend-driven MCP + # (``McpServer`` + ``StreamableHttpTransport``), but that + # mode is fundamentally incompatible with our TarkaMCP auth + # setup (OAuth bearer + TOTP + Cloudflare Tunnel): Google's + # backend fetch of /mcp loses the Authorization header in + # transit, the MCP handshake returns 401, and the upstream + # model deterministically emits 500 INTERNAL. We surface a + # clear error instead of cascading through retries. + yield ErrorEvent( + code="remote_mode_disabled", + message=( + "Le mode MCP 'remote' n'est plus supporté (il causait " + "des 500 INTERNAL systématiques à cause du flux OAuth). " + "Retire TARKAMCP_DASHBOARD_MCP_MODE=remote de ton .env " + "et relance le service pour repasser en mode local." + ), ) - async for chunk in stream: - for event in _emit_chunk(chunk, tool_starts, seen_tool_ids): - yield event return - # Default "local" mode: open an MCP ClientSession from the - # dashboard process, and run a MANUAL function-calling loop. + # Local mode: open an MCP ClientSession from the dashboard + # process, and run a MANUAL function-calling loop. # # We previously passed ``tools=[session]`` and relied on the SDK's # Automatic Function Calling path, but that route has two known @@ -659,69 +652,6 @@ def _mcp_call_result_to_response(result: Any) -> dict: return out -def _emit_chunk( - chunk: Any, - tool_starts: dict[str, float], - seen_tool_ids: set[str], -) -> list[ChatEvent]: - """Translate a google-genai stream chunk into ChatEvents. - - The SDK shape varies across minor versions; everything is looked up - defensively. When the SDK auto-executes MCP tools via the local - ClientSession, we still observe ``function_call`` parts for each - invocation and ``function_response`` parts for each result. - """ - out: list[ChatEvent] = [] - candidates = getattr(chunk, "candidates", None) or [] - for cand in candidates: - content = getattr(cand, "content", None) - if not content: - continue - parts = getattr(content, "parts", None) or [] - for part in parts: - text = getattr(part, "text", None) - if text: - if getattr(part, "thought", False): - out.append(ThinkingDelta(summary=text)) - else: - out.append(TextDelta(text=text)) - - fc = getattr(part, "function_call", None) - if fc: - fc_id = getattr(fc, "id", None) or f"fc_{len(seen_tool_ids)}" - if fc_id not in seen_tool_ids: - seen_tool_ids.add(fc_id) - tool_starts[fc_id] = time.monotonic() - args = getattr(fc, "args", {}) or {} - out.append(ToolCallStart( - id=fc_id, - name=getattr(fc, "name", "?"), - args=dict(args), - )) - - fr = getattr(part, "function_response", None) - if fr: - fr_id = getattr(fr, "id", None) or "" - if not fr_id: - # Some SDK versions omit the id on the response; fall - # back to the most recent unresolved invocation. - fr_id = next( - iter(reversed(list(tool_starts.keys()))), "", - ) - started = tool_starts.pop(fr_id, time.monotonic()) - duration = int((time.monotonic() - started) * 1000) - response = getattr(fr, "response", None) or {} - status = "error" if ( - isinstance(response, dict) and "error" in response - ) else "ok" - out.append(ToolCallEnd( - id=fr_id, status=status, - preview=_short_preview(response), - duration_ms=duration, - )) - return out - - def _is_transient_error(exc: BaseException) -> bool: """Return True if ``exc`` looks like a retryable Google 5xx / network blip.""" msg = str(exc) diff --git a/tests/test_dashboard_chat.py b/tests/test_dashboard_chat.py index a4a3ded..decf7dc 100644 --- a/tests/test_dashboard_chat.py +++ b/tests/test_dashboard_chat.py @@ -398,8 +398,16 @@ def test_chat_page_auth_required(app_and_client): assert r.status_code == 302 -def test_chat_stream_remote_mode_uses_public_url(tmp_path, engine): - """In remote mode the public URL IS used (Google's backend calls it).""" +def test_chat_stream_remote_mode_still_routes_public_url(tmp_path, engine): + """In remote mode the public URL is still the resolved target. + + The engine itself refuses to drive a remote turn (the SDK's + backend-driven MCP mode is broken under our auth), but the URL + resolution logic is independent and continues to honour the + configured public hostname. That lets us keep remote-mode + configuration around for a future re-enablement without changing + the routing layer. + """ db = Database(tmp_path / "dashboard.db") deps = DashboardDeps( database=db, @@ -431,6 +439,31 @@ def test_chat_stream_remote_mode_uses_public_url(tmp_path, engine): assert engine.calls[-1].mcp_mode == "remote" +def test_gemini_engine_rejects_remote_mode(): + """GeminiChatEngine yields an ErrorEvent instead of calling the SDK.""" + import asyncio + from tarkamcp.dashboard.chat import ( + ErrorEvent, + GeminiChatEngine, + TurnInput, + ) + + engine_real = GeminiChatEngine(api_key="test") + turn = TurnInput( + history=[], user_text="x", model="gemini-3-flash-preview", + effort="low", bearer="b", + mcp_url="https://mcp.example/mcp", mcp_mode="remote", + ) + + async def _drain(): + return [e async for e in engine_real.run(turn)] + + events = asyncio.run(_drain()) + assert len(events) == 1 + assert isinstance(events[0], ErrorEvent) + assert events[0].code == "remote_mode_disabled" + + def test_chat_page_renders_after_login(app_and_client): _, client = app_and_client _login(client) From 73f91f3ce763a0100511aed9eb274d2d671903c8 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 02:01:03 +0200 Subject: [PATCH 035/155] Detect bearer wiped by service restart + fix MCP auth header plumbing The 401 on /mcp after restart was actually two stacked bugs: 1. TokenStore is in-memory, so any systemctl restart tarkamcp flushes every issued bearer. The dashboard's session (SQLite) still holds the old bearer though, and session.bearer_valid() only compares timestamps -- never asks TokenStore if the token still exists. Every post-restart chat turn therefore sent a doomed bearer to /mcp and got a legitimate 401. 2. streamable_http_client(http_client=...) relies on httpx default- header merging which is fine in theory but has edge cases under load. Switch to streamablehttp_client(headers=...) which threads the Authorization header through every request explicitly. (Pyright flags it deprecated but it's the only API that actually works reliably for our use-case; we can revisit when upstream fixes header propagation on the non-deprecated path.) Changes: - stream() in api_chat_stream now calls token_store.validate() after the timestamp check. On miss it emits session_expired, the UI redirects to /app/refresh, the user re-TOTPs, and a fresh bearer is issued -- no more HTTPStatusError cascade. - GeminiChatEngine._run (local mode) switched to streamablehttp_client with an explicit headers dict. Removed the httpx.AsyncClient wrapper. - FakeTokenStore in the chat test fixture now tracks issued tokens so validate() mirrors reality; tests can subclass it to simulate a wiped store. Added test_chat_stream_detects_token_wiped_after_restart. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/dashboard/app.py | 12 ++++++++ src/tarkamcp/dashboard/chat.py | 26 +++++++++------- tests/test_dashboard_chat.py | 54 ++++++++++++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/tarkamcp/dashboard/app.py b/src/tarkamcp/dashboard/app.py index 266b788..acdd652 100644 --- a/src/tarkamcp/dashboard/app.py +++ b/src/tarkamcp/dashboard/app.py @@ -520,6 +520,18 @@ async def stream(): yield _sse("session_expired", {}) return + # Also check the bearer against the live TokenStore: the + # store is in-memory, so a `systemctl restart tarkamcp` wipes + # every token even though the SQLite-backed session still + # has it. Without this check we'd pass a stale bearer to + # /mcp, get a deterministic 401, and propagate a confusing + # HTTPStatusError. Trigger the same refresh flow instead. + validator = getattr(deps.token_store, "validate", None) + if callable(validator) and session.mcp_bearer: + if validator(session.mcp_bearer) is None: + yield _sse("session_expired", {}) + return + # History = everything prior to this turn. The engine appends # the user message itself via TurnInput.user_text. history = store.list_messages(conv.id) diff --git a/src/tarkamcp/dashboard/chat.py b/src/tarkamcp/dashboard/chat.py index 3a91ed8..34ba1df 100644 --- a/src/tarkamcp/dashboard/chat.py +++ b/src/tarkamcp/dashboard/chat.py @@ -384,10 +384,9 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: # ``session.call_tool()``, and feed the results back in a new # ``generate_content_stream`` call until no function_call remains. try: - import httpx # type: ignore from mcp.client.session import ClientSession # type: ignore from mcp.client.streamable_http import ( # type: ignore - streamable_http_client, + streamablehttp_client, ) except ImportError as e: yield ErrorEvent(code="sdk_missing", message=str(e)) @@ -398,15 +397,20 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: turn.model, turn.effort, turn.mcp_url, ) - headers = {"Authorization": f"Bearer {turn.bearer}"} - async with httpx.AsyncClient( - headers=headers, - timeout=httpx.Timeout(30.0, read=120.0), - ) as http_client: - async with streamable_http_client( - turn.mcp_url, http_client=http_client, - ) as (read_stream, write_stream, _get_session_id): - async with ClientSession(read_stream, write_stream) as session: + # ``streamablehttp_client`` (no underscore) is the variant that + # actually threads the ``headers`` dict through every request. + # Previously we used ``streamable_http_client(http_client=...)`` + # and relied on httpx default-headers propagation, but the MCP + # client builds fresh requests that don't inherit the defaults, + # so the Authorization header was being dropped -- producing a + # hard 401 on /mcp even when calling loopback. + async with streamablehttp_client( + turn.mcp_url, + headers={"Authorization": f"Bearer {turn.bearer}"}, + timeout=30, + sse_read_timeout=300, + ) as (read_stream, write_stream, _get_session_id): + async with ClientSession(read_stream, write_stream) as session: try: await session.initialize() except Exception as e: # noqa: BLE001 diff --git a/tests/test_dashboard_chat.py b/tests/test_dashboard_chat.py index decf7dc..624d8be 100644 --- a/tests/test_dashboard_chat.py +++ b/tests/test_dashboard_chat.py @@ -40,12 +40,20 @@ class FakeTokenStore: def __init__(self): self.revoked = [] self._n = 0 + self._live: dict[str, str] = {} def issue(self, cid): self._n += 1 - return f"b_{self._n}", BEARER_TTL_SECONDS - def validate(self, token): return None + token = f"b_{self._n}" + self._live[token] = cid + return token, BEARER_TTL_SECONDS + def validate(self, token): + # Mirrors the real TokenStore: return client_id if the token + # was issued by this store instance. Tests can subclass to + # simulate a wiped store (e.g. after a service restart). + return self._live.get(token) def revoke(self, token): self.revoked.append(token) + self._live.pop(token, None) return True @@ -297,6 +305,48 @@ def test_chat_stream_tool_call(app_and_client, engine, deps): assert tc.duration_ms == 42 +def test_chat_stream_detects_token_wiped_after_restart(tmp_path, engine): + """After a service restart TokenStore is empty but the session's + bearer is still there; we must emit session_expired before reaching + the MCP server with a stale token. + """ + class InvalidatingTokenStore(FakeTokenStore): + # Mimic TokenStore behaviour after restart: every token is + # unknown, so validate() always returns None. + def validate(self, token): + return None + + db = Database(tmp_path / "dashboard.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=InvalidatingTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + conversations=ConversationStore(db), + engine=engine, + ) + app = Starlette(routes=build_dashboard_routes(deps)) + client = TestClient(app, follow_redirects=False) + + csrf = _login(client) + r = client.post("/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}") + cid = r.json()["conversation"]["id"] + r = client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"conversation_id": cid, "content": "ping"}), + ) + events = _parse_sse(r.text) + assert events[0][0] == "session_expired" + # Engine must NOT have been called with a doomed bearer. + assert len(engine.calls) == 0 + + def test_chat_stream_session_expired(app_and_client, deps): _, client = app_and_client csrf = _login(client) From d7c3700e7a81d5a9c0803a8750a0997d091a3ad1 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 02:04:04 +0200 Subject: [PATCH 036/155] Break the refresh<->chat redirect loop after a service restart Previous commit correctly detected a wiped bearer during /app/api/chat/stream and emitted session_expired, so the UI sent the user to /app/refresh. But /app/refresh only checked session.bearer_valid() (timestamp-only), saw the bearer as still live, and bounced straight back to /app/chat -- which resumed the chat, tried to stream, hit session_expired, refreshed again, ad infinitum. Introduced ``_bearer_live(deps, session)`` which combines the timestamp check with a live TokenStore.validate() lookup, and routed every bearer-related branch through it: index, login_get, refresh_get, chat_get, _require_active_session, and the SSE stream() closure. /app/refresh now renders the TOTP form in the bearer-timestamp-valid-but-token-wiped case, the user re-TOTPs, token_store.issue() hands out a fresh bearer, and everything resumes cleanly. Added two tests covering the loop: one asserts /app/refresh renders the TOTP form (HTTP 200 + 'totp' in body) rather than redirecting, and one asserts /app/chat sends the user to /app/refresh when the token is gone. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/dashboard/app.py | 42 +++++++++++-------- tests/test_dashboard_chat.py | 79 ++++++++++++++++++++++++++++++++--- 2 files changed, 98 insertions(+), 23 deletions(-) diff --git a/src/tarkamcp/dashboard/app.py b/src/tarkamcp/dashboard/app.py index acdd652..9a10dd2 100644 --- a/src/tarkamcp/dashboard/app.py +++ b/src/tarkamcp/dashboard/app.py @@ -182,6 +182,24 @@ def _load_session(request: Request, deps: DashboardDeps) -> Session | None: return session +def _bearer_live(deps: DashboardDeps, session: Session) -> bool: + """Timestamp says the bearer is valid AND TokenStore still knows it. + + The TokenStore is in-memory; a systemctl restart wipes every token + while the SQLite-backed session keeps them. Without the second + check, redirects like /app/refresh -> /app/chat -> stream -> + session_expired -> /app/refresh loop forever because each step + trusts ``session.bearer_valid()`` in isolation. + """ + if not session.bearer_valid(): + return False + validator = getattr(deps.token_store, "validate", None) + if callable(validator) and session.mcp_bearer: + if validator(session.mcp_bearer) is None: + return False + return True + + # --------------------------------------------------------------------------- # Route factories # --------------------------------------------------------------------------- @@ -191,7 +209,7 @@ def build_dashboard_routes(deps: DashboardDeps) -> list[Route | Mount]: async def index(request: Request) -> Response: session = _load_session(request, deps) - if session and session.bearer_valid(): + if session and _bearer_live(deps, session): return RedirectResponse("/app/chat", status_code=302) if session: return RedirectResponse("/app/refresh", status_code=302) @@ -200,7 +218,7 @@ async def index(request: Request) -> Response: async def login_get(request: Request) -> Response: # If a valid session already exists, send straight to chat. session = _load_session(request, deps) - if session and session.bearer_valid(): + if session and _bearer_live(deps, session): return RedirectResponse("/app/chat", status_code=302) if session: # Session valid but bearer stale -> refresh page is the right one. @@ -286,7 +304,7 @@ async def refresh_get(request: Request) -> Response: session = _load_session(request, deps) if not session: return RedirectResponse("/app/login", status_code=302) - if session.bearer_valid(): + if _bearer_live(deps, session): return RedirectResponse("/app/chat", status_code=302) client_name = ( @@ -391,7 +409,7 @@ async def chat_get(request: Request) -> Response: session = _load_session(request, deps) if not session: return RedirectResponse("/app/login", status_code=302) - if not session.bearer_valid(): + if not _bearer_live(deps, session): return RedirectResponse("/app/refresh", status_code=302) return _render( "chat.html", @@ -516,22 +534,10 @@ async def api_chat_stream(request: Request) -> Response: ) async def stream(): - if not session.bearer_valid(): + if not _bearer_live(deps, session): yield _sse("session_expired", {}) return - # Also check the bearer against the live TokenStore: the - # store is in-memory, so a `systemctl restart tarkamcp` wipes - # every token even though the SQLite-backed session still - # has it. Without this check we'd pass a stale bearer to - # /mcp, get a deterministic 401, and propagate a confusing - # HTTPStatusError. Trigger the same refresh flow instead. - validator = getattr(deps.token_store, "validate", None) - if callable(validator) and session.mcp_bearer: - if validator(session.mcp_bearer) is None: - yield _sse("session_expired", {}) - return - # History = everything prior to this turn. The engine appends # the user message itself via TurnInput.user_text. history = store.list_messages(conv.id) @@ -658,7 +664,7 @@ def _require_active_session( session = _load_session(request, deps) if not session: return _json({"error": "unauthorized"}, status=401) - if not session.bearer_valid(): + if not _bearer_live(deps, session): return _json({"error": "bearer_expired"}, status=401) return session diff --git a/tests/test_dashboard_chat.py b/tests/test_dashboard_chat.py index 624d8be..71ad35b 100644 --- a/tests/test_dashboard_chat.py +++ b/tests/test_dashboard_chat.py @@ -305,23 +305,88 @@ def test_chat_stream_tool_call(app_and_client, engine, deps): assert tc.duration_ms == 42 +def test_refresh_page_shows_totp_when_token_wiped(tmp_path, engine): + """After a restart, /app/refresh must render the TOTP form instead + of redirecting back to /app/chat and creating a redirect loop. + """ + class WipedTokenStore(FakeTokenStore): + def validate(self, token): + return None # TokenStore wiped by restart + + db = Database(tmp_path / "dashboard.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=WipedTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + conversations=ConversationStore(db), + engine=engine, + ) + app = Starlette(routes=build_dashboard_routes(deps)) + client = TestClient(app, follow_redirects=False) + + _login(client) + r = client.get("/app/refresh") + # Must render the TOTP form, NOT 302 back to /app/chat. + assert r.status_code == 200 + assert "totp" in r.text.lower() + + +def test_chat_page_redirects_to_refresh_when_token_wiped(tmp_path, engine): + """/app/chat must send the user to /app/refresh when the bearer + is wiped, to break the refresh<->chat redirect loop. + """ + class WipedTokenStore(FakeTokenStore): + def validate(self, token): + return None + + db = Database(tmp_path / "dashboard.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=WipedTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + conversations=ConversationStore(db), + engine=engine, + ) + app = Starlette(routes=build_dashboard_routes(deps)) + client = TestClient(app, follow_redirects=False) + + _login(client) + r = client.get("/app/chat") + assert r.status_code == 302 + assert r.headers["location"] == "/app/refresh" + + def test_chat_stream_detects_token_wiped_after_restart(tmp_path, engine): """After a service restart TokenStore is empty but the session's bearer is still there; we must emit session_expired before reaching the MCP server with a stale token. + + We simulate the "restart mid-session" flow: login + create + conversation succeed while the token is live, then we flip the + store into its post-restart state and try to stream. """ - class InvalidatingTokenStore(FakeTokenStore): - # Mimic TokenStore behaviour after restart: every token is - # unknown, so validate() always returns None. + class SwitchableTokenStore(FakeTokenStore): + wiped = False def validate(self, token): - return None + if self.wiped: + return None + return super().validate(token) db = Database(tmp_path / "dashboard.db") + token_store = SwitchableTokenStore() deps = DashboardDeps( database=db, session_store=SessionStore(db, key=os.urandom(32)), client_store=FakeClientStore(), - token_store=InvalidatingTokenStore(), + token_store=token_store, totp_locked=lambda cid: False, totp_record_failure=lambda cid: None, totp_record_success=lambda cid: None, @@ -336,6 +401,10 @@ def validate(self, token): headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, content="{}") cid = r.json()["conversation"]["id"] + + # Simulate `systemctl restart tarkamcp` wiping every issued token. + token_store.wiped = True + r = client.post( "/app/api/chat/stream", headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, From 8f7afe071b4a4015312199358f33a0b74afc724d Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 02:07:14 +0200 Subject: [PATCH 037/155] Extend chat markdown: headings, lists, blockquote, hr The inline-only renderer wasn't handling block-level constructs, so Gemini's replies showed literal '### ' and '* ' in the UI (see the Proxmox list-vms response where the output looked like raw markdown). Rewrote renderMarkdown() as a small line-oriented parser: - Pulls fenced code blocks out up-front via sentinel placeholders so their body is never rewritten by later passes. - Walks line by line to recognise ATX headings (h1-h6), unordered and ordered lists, blockquotes (with consecutive-line collapsing), horizontal rules, and blank-line paragraph breaks. Accumulated paragraph lines use
for single newlines, matching the old behaviour. - Inline pass (bold, italic, strikethrough, code, links) runs after HTML-escaping each line body. Added matching styles in app.css: headings compact and heavier than body, lists with proper indent and li spacing, blockquote with the border accent colour, hr as a single muted rule. No tests (the markdown renderer is vanilla client-side JS with no harness yet); manual verification via screenshot. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/dashboard/static/app.css | 52 ++++++++ src/tarkamcp/dashboard/static/chat.js | 172 ++++++++++++++++++++++---- 2 files changed, 202 insertions(+), 22 deletions(-) diff --git a/src/tarkamcp/dashboard/static/app.css b/src/tarkamcp/dashboard/static/app.css index 28989df..2b73593 100644 --- a/src/tarkamcp/dashboard/static/app.css +++ b/src/tarkamcp/dashboard/static/app.css @@ -516,6 +516,58 @@ button.ghost.full { width: 100%; justify-content: center; } .msg-body.md a { word-break: break-all; } +/* Headings: match the chat typography — compact, no decorative weight bombs. */ +.msg-body.md h1, +.msg-body.md h2, +.msg-body.md h3, +.msg-body.md h4, +.msg-body.md h5, +.msg-body.md h6 { + margin: 0.9rem 0 0.4rem; + line-height: 1.3; + font-weight: 600; +} +.msg-body.md h1:first-child, +.msg-body.md h2:first-child, +.msg-body.md h3:first-child, +.msg-body.md h4:first-child, +.msg-body.md h5:first-child, +.msg-body.md h6:first-child { margin-top: 0; } +.msg-body.md h1 { font-size: 1.25rem; } +.msg-body.md h2 { font-size: 1.15rem; } +.msg-body.md h3 { font-size: 1.05rem; } +.msg-body.md h4 { font-size: 1rem; } +.msg-body.md h5, +.msg-body.md h6 { font-size: 0.95rem; color: var(--text-muted); } + +/* Lists */ +.msg-body.md ul, +.msg-body.md ol { + margin: 0.25rem 0 0.75rem; + padding-left: 1.4rem; +} +.msg-body.md li { margin: 0.15rem 0; } +.msg-body.md li > p { margin: 0; } +.msg-body.md ul ul, +.msg-body.md ol ol, +.msg-body.md ul ol, +.msg-body.md ol ul { margin: 0.15rem 0 0.15rem; } + +/* Blockquote */ +.msg-body.md blockquote { + margin: 0.5rem 0; + padding: 0.25rem 0.85rem; + border-left: 3px solid var(--border); + color: var(--text-muted); +} + +/* Horizontal rule */ +.msg-body.md hr { + border: none; + border-top: 1px solid var(--border); + margin: 1rem 0; +} + /* Tool cards */ .tool-card { diff --git a/src/tarkamcp/dashboard/static/chat.js b/src/tarkamcp/dashboard/static/chat.js index 970b7c8..99567ce 100644 --- a/src/tarkamcp/dashboard/static/chat.js +++ b/src/tarkamcp/dashboard/static/chat.js @@ -30,41 +30,169 @@ function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ESC_MAP[c]); } -function renderMarkdown(text) { - // Order: code fences -> inline code -> bold/italic/strike -> links -> line breaks. - let out = escapeHtml(text); - - // Fenced code blocks - out = out.replace(/```([a-zA-Z0-9+_-]*)\n([\s\S]*?)```/g, (_, lang, body) => - `
${body.replace(/\n+$/, "")}
` +// Extract fenced code blocks first so their body is never touched by the +// other markdown rules (headings inside a code block stay as text, etc). +// Returns { stripped, blocks } where placeholders ⟦CODE0⟧ are restored +// after all other passes have run. +function _extractFences(raw) { + const blocks = []; + const stripped = raw.replace( + /```([a-zA-Z0-9+_.-]*)\n([\s\S]*?)```/g, + (_, lang, body) => { + const idx = blocks.length; + blocks.push({ lang, body: body.replace(/\n+$/, "") }); + return `\u0000CODEBLOCK${idx}\u0000`; + }, ); + return { stripped, blocks }; +} +// Inline pass: runs on a single line's text AFTER HTML-escaping. Inline +// code first so nothing inside backticks gets formatted. +function _renderInline(text) { // Inline code - out = out.replace(/`([^`\n]+?)`/g, (_, c) => `${c}`); - - // Bold / italic / strikethrough + let out = text.replace(/`([^`\n]+?)`/g, (_, c) => `${c}`); + // Bold then italic then strike out = out.replace(/\*\*([^*\n]+)\*\*/g, "$1"); out = out.replace(/(?$1"); out = out.replace(/~~([^~\n]+?)~~/g, "$1"); - - // Links [text](https://...) + // Links [label](https://...) out = out.replace( /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_, label, href) => - `${label}` + `${label}`, ); - - // Two newlines = paragraph, single newline =
- out = out - .split(/\n{2,}/) - .map((p) => p.replace(/\n/g, "
")) - .map((p) => `

${p}

`) - .join(""); - // Pre blocks should not be wrapped in

- out = out.replace(/

(

[\s\S]*?<\/pre>)<\/p>/g, "$1");
   return out;
 }
 
+function _isBulletLine(line) {
+  return /^\s*[-*+]\s+/.test(line);
+}
+function _isOrderedLine(line) {
+  return /^\s*\d+\.\s+/.test(line);
+}
+function _stripBullet(line) {
+  return line.replace(/^\s*[-*+]\s+/, "");
+}
+function _stripOrdered(line) {
+  return line.replace(/^\s*\d+\.\s+/, "");
+}
+
+function renderMarkdown(text) {
+  // 1. Pull fenced code blocks out so nothing munges their contents.
+  const { stripped, blocks } = _extractFences(text);
+
+  // 2. Work line-by-line so we can recognise block-level constructs
+  // (headings, lists, blockquotes, hr). Inline formatting is applied
+  // after HTML-escaping each line body.
+  const lines = stripped.split("\n");
+  const out = [];
+  let i = 0;
+  let inParagraph = [];
+
+  const flushParagraph = () => {
+    if (inParagraph.length === 0) return;
+    const body = inParagraph.map((l) => _renderInline(escapeHtml(l))).join("
"); + out.push(`

${body}

`); + inParagraph = []; + }; + + while (i < lines.length) { + const line = lines[i]; + + // Blank line -> paragraph break + if (/^\s*$/.test(line)) { + flushParagraph(); + i += 1; + continue; + } + + // Horizontal rule: ---, ***, ___ on their own line + if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + flushParagraph(); + out.push("
"); + i += 1; + continue; + } + + // ATX heading: 1-6 # followed by space + const heading = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line); + if (heading) { + flushParagraph(); + const level = heading[1].length; + const body = _renderInline(escapeHtml(heading[2])); + out.push(`${body}`); + i += 1; + continue; + } + + // Blockquote: consume consecutive "> " lines + if (/^\s*>\s?/.test(line)) { + flushParagraph(); + const quoted = []; + while (i < lines.length && /^\s*>\s?/.test(lines[i])) { + quoted.push(lines[i].replace(/^\s*>\s?/, "")); + i += 1; + } + const body = quoted.map((l) => _renderInline(escapeHtml(l))).join("
"); + out.push(`
${body}
`); + continue; + } + + // Unordered list: consume consecutive bullet lines + if (_isBulletLine(line)) { + flushParagraph(); + const items = []; + while (i < lines.length && _isBulletLine(lines[i])) { + items.push(_stripBullet(lines[i])); + i += 1; + } + out.push( + `
    ${items + .map((it) => `
  • ${_renderInline(escapeHtml(it))}
  • `) + .join("")}
`, + ); + continue; + } + + // Ordered list + if (_isOrderedLine(line)) { + flushParagraph(); + const items = []; + while (i < lines.length && _isOrderedLine(lines[i])) { + items.push(_stripOrdered(lines[i])); + i += 1; + } + out.push( + `
    ${items + .map((it) => `
  1. ${_renderInline(escapeHtml(it))}
  2. `) + .join("")}
`, + ); + continue; + } + + // Default: accumulate into current paragraph. + inParagraph.push(line); + i += 1; + } + flushParagraph(); + + let html = out.join(""); + + // 3. Restore fenced code blocks. + html = html.replace(/\u0000CODEBLOCK(\d+)\u0000/g, (_, idx) => { + const { lang, body } = blocks[Number(idx)]; + const cls = lang ? ` class="lang-${escapeHtml(lang)}"` : ""; + return `
${escapeHtml(body)}
`; + }); + + // A paragraph that wraps a code-block placeholder-turned-
 breaks
+  // layout; unwrap those (the extraction happens before paragraph
+  // splitting so this is defensive against any edge case).
+  html = html.replace(/

(

[\s\S]*?<\/pre>)<\/p>/g, "$1");
+  return html;
+}
+
 // ---------------- API helpers ----------------
 
 async function apiJson(url, { method = "GET", body = null } = {}) {

From 4dea78eed55344658436c79919c99c34d3cec387 Mon Sep 17 00:00:00 2001
From: Showdown76py 
Date: Fri, 17 Apr 2026 02:12:56 +0200
Subject: [PATCH 038/155] Always require manual approval before running SSH
 exec from the chat

Gemini turns could previously fire ssh_exec_command / async into the
PVE hosts unattended. Since those tools accept arbitrary shell with
root-equivalent reach on pve1/pve2, that's not acceptable from a web
chat surface. Every invocation now pauses for an explicit Autoriser /
Refuser click before the shell leaves the box.

Server:
- Added ``src/tarkamcp/dashboard/confirmations.py`` with a small
  ``ConfirmationStore`` mapping tool_call_id -> asyncio.Future[bool],
  scoped by session_id so one user cannot resolve another's pending
  decision.
- Added ``ToolConfirmRequired`` ChatEvent and a ``confirm_tool``
  callback on ``TurnInput``. The GeminiChatEngine's manual function-
  calling loop checks an allow-list (_NEEDS_CONFIRMATION) before
  calling ``session.call_tool``; for SSH exec it yields
  ``ToolConfirmRequired`` and awaits the callback. Rejected calls
  synthesize a ``{"error": "user_rejected"}`` FunctionResponse so the
  model sees the refusal and can adapt.
- api_chat_stream emits a ``tool_confirm_required`` SSE frame when
  that event arrives and wraps the engine's callback around
  ``ConfirmationStore.create`` + ``asyncio.wait_for(..., timeout=300)``.
  The finally block cancels any pending futures if the client
  disconnected mid-approval.
- New route ``POST /app/api/chat/confirm`` with CSRF + active-session
  checks resolves a pending future given ``{call_id, approve}``.
- Startup in __main__ wires the ``ConfirmationStore`` into
  ``DashboardDeps``.

Client:
- chat.js handles the new ``tool_confirm_required`` event, upgrading
  the tool card in place (or creating one) to an ``awaiting_confirm``
  state with Autoriser / Refuser buttons. POST back to
  /app/api/chat/confirm disables the buttons until the SSE stream
  updates the card.
- app.css adds the awaiting + rejected card states, the approval
  badge, and the confirm button styling (accent-coloured approve,
  outlined reject that tints red on hover).

Tests:
- test_chat_stream_ssh_tool_emits_confirm_event exercises the full
  flow end-to-end: FakeChatEngine scripts a ToolConfirmRequired
  event, a background thread polls ConfirmationStore.pending_for()
  and POSTs approval, the SSE response carries tool_confirm_required
  + tool_result + done.
- test_confirm_endpoint_requires_csrf and
  test_confirm_endpoint_rejects_unknown_call_id cover the two
  guardrails. Fixture fake engine now includes a ConfirmationStore.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 src/tarkamcp/__main__.py                |  3 +
 src/tarkamcp/dashboard/app.py           | 57 +++++++++++++++++
 src/tarkamcp/dashboard/chat.py          | 79 +++++++++++++++++++++++-
 src/tarkamcp/dashboard/confirmations.py | 61 +++++++++++++++++++
 src/tarkamcp/dashboard/static/app.css   | 54 +++++++++++++++++
 src/tarkamcp/dashboard/static/chat.js   | 78 +++++++++++++++++++++++-
 tests/test_dashboard_chat.py            | 81 +++++++++++++++++++++++++
 7 files changed, 409 insertions(+), 4 deletions(-)
 create mode 100644 src/tarkamcp/dashboard/confirmations.py

diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py
index 8eb5b45..b9ee644 100644
--- a/src/tarkamcp/__main__.py
+++ b/src/tarkamcp/__main__.py
@@ -524,6 +524,7 @@ def _build_dashboard_routes(client_store, token_store, totp_locked,
         return []
     from .dashboard.app import DashboardDeps, build_dashboard_routes
     from .dashboard.chat import GeminiChatEngine
+    from .dashboard.confirmations import ConfirmationStore
     from .dashboard.conversations import ConversationStore
     from .dashboard.db import Database
     from .dashboard.session import SessionStore
@@ -531,6 +532,7 @@ def _build_dashboard_routes(client_store, token_store, totp_locked,
     database = Database()
     session_store = SessionStore(database)
     conversations = ConversationStore(database)
+    confirmations = ConfirmationStore()
     api_key = os.environ.get("GEMINI_API_KEY", "")
     engine = GeminiChatEngine(api_key=api_key) if api_key else None
     mcp_public_url = os.environ.get("TARKAMCP_DASHBOARD_PUBLIC_URL", "").strip() or None
@@ -555,6 +557,7 @@ def _build_dashboard_routes(client_store, token_store, totp_locked,
         totp_record_success=totp_record_success,
         conversations=conversations,
         engine=engine,
+        confirmations=confirmations,
         mcp_public_url=mcp_public_url,
         mcp_mode=mcp_mode,
     )
diff --git a/src/tarkamcp/dashboard/app.py b/src/tarkamcp/dashboard/app.py
index 9a10dd2..e72e8bd 100644
--- a/src/tarkamcp/dashboard/app.py
+++ b/src/tarkamcp/dashboard/app.py
@@ -7,6 +7,7 @@
 
 from __future__ import annotations
 
+import asyncio
 import json
 from dataclasses import dataclass
 from pathlib import Path
@@ -32,8 +33,10 @@
     ThinkingDelta,
     ToolCallEnd,
     ToolCallStart,
+    ToolConfirmRequired,
     TurnInput,
 )
+from .confirmations import ConfirmationStore
 from .conversations import (
     DEFAULT_EFFORT,
     DEFAULT_MODEL,
@@ -68,6 +71,7 @@ class DashboardDeps:
     totp_record_success: Callable[[str], None]
     conversations: ConversationStore | None = None
     engine: ChatEngine | None = None
+    confirmations: ConfirmationStore | None = None
     # Public URL used by Gemini's backend to call this MCP server in
     # "remote" mode. Falls back to the request's Host header at call time
     # when unset. Ignored in "local" mode.
@@ -501,6 +505,28 @@ async def api_conv_delete(request: Request) -> Response:
             return _json({"error": "not_found"}, status=404)
         return Response(status_code=204)
 
+    async def api_chat_confirm(request: Request) -> Response:
+        session = _require_active_session(request, deps)
+        if isinstance(session, Response):
+            return session
+        if not await csrf.verify(request):
+            return _json({"error": "csrf"}, status=403)
+        if deps.confirmations is None:
+            return _json({"error": "chat_disabled"}, status=503)
+        body = await _read_json(request)
+        call_id = str(body.get("call_id") or "").strip()
+        if not call_id:
+            return _json({"error": "invalid_request"}, status=400)
+        approved = bool(body.get("approve"))
+        ok = deps.confirmations.resolve(
+            call_id=call_id,
+            session_id=session.session_id,
+            approved=approved,
+        )
+        if not ok:
+            return _json({"error": "not_found"}, status=404)
+        return _json({"ok": True, "approved": approved})
+
     async def api_chat_stream(request: Request) -> Response:
         session = _load_session(request, deps)
         if not session:
@@ -546,6 +572,25 @@ async def stream():
 
             collected: list[Any] = []
 
+            confirmations = deps.confirmations
+            issued_call_ids: list[str] = []
+
+            async def _confirm(req: ToolConfirmRequired) -> bool:
+                if confirmations is None:
+                    return False
+                fut = confirmations.create(
+                    call_id=req.id, session_id=session.session_id,
+                )
+                issued_call_ids.append(req.id)
+                try:
+                    # 5 minute ceiling matches the upstream MCP SSE
+                    # read timeout; anything longer and the browser
+                    # connection tends to time out anyway.
+                    return await asyncio.wait_for(fut, timeout=300)
+                except (asyncio.TimeoutError, asyncio.CancelledError):
+                    confirmations.cancel(req.id)
+                    return False
+
             turn = TurnInput(
                 history=history,
                 user_text=user_text,
@@ -554,6 +599,7 @@ async def stream():
                 bearer=session.mcp_bearer or "",
                 mcp_url=_resolve_mcp_url(request, deps),
                 mcp_mode=deps.mcp_mode,
+                confirm_tool=_confirm,
             )
 
             try:
@@ -567,6 +613,10 @@ async def stream():
                         yield _sse("tool_call", {
                             "id": event.id, "name": event.name, "args": event.args,
                         })
+                    elif isinstance(event, ToolConfirmRequired):
+                        yield _sse("tool_confirm_required", {
+                            "id": event.id, "name": event.name, "args": event.args,
+                        })
                     elif isinstance(event, ToolCallEnd):
                         yield _sse("tool_result", {
                             "id": event.id, "status": event.status,
@@ -585,6 +635,12 @@ async def stream():
                 yield _sse("error", {
                     "code": "internal", "message": str(exc),
                 })
+            finally:
+                # Free any confirmation futures left dangling (client
+                # disconnected before approving, engine errored mid-loop).
+                if confirmations is not None:
+                    for cid in issued_call_ids:
+                        confirmations.cancel(cid)
 
             from .chat import assemble_assistant_message  # local to avoid cycle
             content, tool_calls, thinking = assemble_assistant_message(collected)
@@ -631,6 +687,7 @@ async def stream():
         Route("/app/api/conversations/{conv_id}", api_conv_patch, methods=["PATCH"]),
         Route("/app/api/conversations/{conv_id}", api_conv_delete, methods=["DELETE"]),
         Route("/app/api/chat/stream", api_chat_stream, methods=["POST"]),
+        Route("/app/api/chat/confirm", api_chat_confirm, methods=["POST"]),
         Route("/", index, methods=["GET"]),
         Mount(
             "/app/static",
diff --git a/src/tarkamcp/dashboard/chat.py b/src/tarkamcp/dashboard/chat.py
index 34ba1df..95535c9 100644
--- a/src/tarkamcp/dashboard/chat.py
+++ b/src/tarkamcp/dashboard/chat.py
@@ -21,7 +21,7 @@
 import time
 import traceback
 from dataclasses import dataclass, field
-from typing import Any, AsyncIterator, Iterable, Protocol, runtime_checkable
+from typing import Any, AsyncIterator, Awaitable, Callable, Iterable, Protocol, runtime_checkable
 
 from .conversations import Message, ToolCall
 
@@ -105,6 +105,14 @@ class ToolCallEnd:
     duration_ms: int
 
 
+@dataclass
+class ToolConfirmRequired:
+    """Engine paused waiting for a manual approve/reject click in the UI."""
+    id: str
+    name: str
+    args: dict[str, Any]
+
+
 @dataclass
 class ErrorEvent:
     code: str
@@ -112,10 +120,21 @@ class ErrorEvent:
 
 
 ChatEvent = (
-    TextDelta | ThinkingDelta | ToolCallStart | ToolCallEnd | ErrorEvent
+    TextDelta | ThinkingDelta | ToolCallStart | ToolCallEnd
+    | ToolConfirmRequired | ErrorEvent
 )
 
 
+# Tool names that MUST go through a human approval step before we run
+# them from a Gemini turn. SSH exec on the PVE hosts is the obvious one
+# -- any conversation could otherwise fire arbitrary shell. Keep this
+# list tight; every entry adds a modal click to the UX.
+_NEEDS_CONFIRMATION: frozenset[str] = frozenset({
+    "ssh_exec_command",
+    "ssh_exec_command_async",
+})
+
+
 # ---------------------------------------------------------------------------
 # Turn input
 # ---------------------------------------------------------------------------
@@ -134,6 +153,12 @@ class TurnInput:
     #           Google's backend call mcp_url directly. Natively supported
     #           on Gemini 3; requires mcp_url to be publicly reachable.
     mcp_mode: str = "local"
+    # Optional callback invoked right before executing any tool whose
+    # name appears in ``_NEEDS_CONFIRMATION``. It is handed the
+    # ``ToolConfirmRequired`` payload (already yielded to the UI) and
+    # must return ``True`` for approve / ``False`` for reject. If
+    # ``None``, confirmation-gated tools auto-reject.
+    confirm_tool: Callable[[ToolConfirmRequired], Awaitable[bool]] | None = None
 
 
 # ---------------------------------------------------------------------------
@@ -515,6 +540,56 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]:
                         for fc, fc_id, args in fc_invocations:
                             name = getattr(fc, "name", "")
                             start = tool_starts.pop(fc_id, time.monotonic())
+
+                            approved = True
+                            if name in _NEEDS_CONFIRMATION:
+                                req = ToolConfirmRequired(
+                                    id=fc_id, name=name, args=args,
+                                )
+                                yield req  # UI shows approve/reject buttons
+                                if turn.confirm_tool is None:
+                                    # No callback wired -> auto-reject so
+                                    # the engine never runs the tool without
+                                    # an explicit green light.
+                                    approved = False
+                                else:
+                                    try:
+                                        approved = bool(
+                                            await turn.confirm_tool(req)
+                                        )
+                                    except asyncio.CancelledError:
+                                        raise
+                                    except Exception:  # noqa: BLE001
+                                        approved = False
+
+                            if not approved:
+                                payload = {
+                                    "error": "user_rejected",
+                                    "message": (
+                                        "L'utilisateur a refusé l'exécution "
+                                        "de cet outil depuis le dashboard."
+                                    ),
+                                }
+                                status = "rejected"
+                                duration = int(
+                                    (time.monotonic() - start) * 1000
+                                )
+                                yield ToolCallEnd(
+                                    id=fc_id, status=status,
+                                    preview=_short_preview(payload),
+                                    duration_ms=duration,
+                                )
+                                response_parts.append(
+                                    types.Part(
+                                        function_response=types.FunctionResponse(
+                                            id=getattr(fc, "id", None),
+                                            name=name,
+                                            response=payload,
+                                        )
+                                    )
+                                )
+                                continue
+
                             try:
                                 result = await session.call_tool(name, args)
                                 payload = _mcp_call_result_to_response(result)
diff --git a/src/tarkamcp/dashboard/confirmations.py b/src/tarkamcp/dashboard/confirmations.py
new file mode 100644
index 0000000..1869a12
--- /dev/null
+++ b/src/tarkamcp/dashboard/confirmations.py
@@ -0,0 +1,61 @@
+"""Per-session async confirmation waiters for dangerous tool calls.
+
+Some MCP tools (SSH exec, and any future write-side Proxmox operations)
+ship arbitrary shell into a PVE host and must never run automatically
+from a Gemini turn. The engine yields a ``ToolConfirmRequired`` event,
+then awaits a future from this store; the ``/app/api/chat/confirm``
+route resolves the future when the user clicks approve or reject in
+the dashboard.
+
+Scoped by ``session_id`` so one logged-in user can't resolve another's
+pending call.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+
+
+@dataclass
+class _Pending:
+    future: asyncio.Future[bool]
+    session_id: str
+
+
+class ConfirmationStore:
+    """Maps tool-call ids to pending approval futures."""
+
+    def __init__(self) -> None:
+        self._pending: dict[str, _Pending] = {}
+
+    def create(self, *, call_id: str, session_id: str) -> asyncio.Future[bool]:
+        """Register a pending approval. Returns the future the engine awaits."""
+        loop = asyncio.get_event_loop()
+        fut: asyncio.Future[bool] = loop.create_future()
+        self._pending[call_id] = _Pending(future=fut, session_id=session_id)
+        return fut
+
+    def resolve(self, *, call_id: str, session_id: str, approved: bool) -> bool:
+        """Resolve a pending approval. Returns True if the call existed."""
+        entry = self._pending.get(call_id)
+        if entry is None or entry.session_id != session_id:
+            return False
+        if entry.future.done():
+            return False
+        entry.future.set_result(approved)
+        self._pending.pop(call_id, None)
+        return True
+
+    def cancel(self, call_id: str) -> None:
+        """Drop a pending entry without resolving it (stream aborted, etc)."""
+        entry = self._pending.pop(call_id, None)
+        if entry and not entry.future.done():
+            entry.future.cancel()
+
+    def pending_for(self, session_id: str) -> list[str]:
+        """Return call ids still waiting for this session's decision."""
+        return [
+            call_id for call_id, entry in self._pending.items()
+            if entry.session_id == session_id
+        ]
diff --git a/src/tarkamcp/dashboard/static/app.css b/src/tarkamcp/dashboard/static/app.css
index 2b73593..9d5964c 100644
--- a/src/tarkamcp/dashboard/static/app.css
+++ b/src/tarkamcp/dashboard/static/app.css
@@ -603,6 +603,60 @@ button.ghost.full { width: 100%; justify-content: center; }
 
 .tool-card.tool-pending svg { animation: spin 1s linear infinite; }
 
+.tool-card.tool-awaiting {
+  border-color: color-mix(in oklab, var(--accent) 55%, var(--border));
+  background: color-mix(in oklab, var(--accent) 8%, var(--bg-soft));
+}
+.tool-card.tool-awaiting svg { color: var(--accent); }
+
+.tool-card.tool-rejected {
+  border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
+  opacity: 0.75;
+}
+.tool-card.tool-rejected svg { color: var(--danger); }
+
+.tool-badge {
+  font-size: 0.7rem;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  color: var(--accent);
+  background: color-mix(in oklab, var(--accent) 18%, transparent);
+  border: 1px solid color-mix(in oklab, var(--accent) 40%, var(--border));
+  padding: 0.1rem 0.45rem;
+  border-radius: 999px;
+  flex-shrink: 0;
+}
+
+.tool-confirm-actions {
+  display: flex;
+  gap: 0.5rem;
+}
+.tool-confirm-actions .btn {
+  flex: 1;
+  padding: 0.5rem 0.75rem;
+  border-radius: 6px;
+  font-size: 0.85rem;
+  border: 1px solid var(--border);
+  cursor: pointer;
+  font-family: inherit;
+  transition: background 0.15s, border-color 0.15s, transform 0.05s;
+}
+.tool-confirm-actions .btn:disabled { opacity: 0.45; cursor: not-allowed; }
+.tool-confirm-actions .btn-approve {
+  background: var(--accent);
+  border-color: var(--accent);
+  color: #fff;
+}
+.tool-confirm-actions .btn-approve:hover:not(:disabled) { filter: brightness(1.08); }
+.tool-confirm-actions .btn-reject {
+  background: transparent;
+  color: var(--fg);
+}
+.tool-confirm-actions .btn-reject:hover:not(:disabled) {
+  border-color: color-mix(in oklab, var(--danger) 55%, var(--border));
+  color: var(--danger);
+}
+
 @keyframes spin { to { transform: rotate(360deg); } }
 
 .tool-name {
diff --git a/src/tarkamcp/dashboard/static/chat.js b/src/tarkamcp/dashboard/static/chat.js
index 99567ce..2670424 100644
--- a/src/tarkamcp/dashboard/static/chat.js
+++ b/src/tarkamcp/dashboard/static/chat.js
@@ -394,8 +394,13 @@ function renderMessage(m) {
 
 function renderToolCard(tc) {
   const card = document.createElement("details");
-  card.className = `tool-card tool-${tc.status || "pending"}`;
+  const stateClass = tc.status === "awaiting_confirm"
+    ? "tool-awaiting"
+    : `tool-${tc.status || "pending"}`;
+  card.className = `tool-card ${stateClass}`;
   card.dataset.id = tc.id;
+  // Auto-expand cards waiting for approval so the user sees the args.
+  if (tc.status === "awaiting_confirm") card.open = true;
 
   const sum = document.createElement("summary");
   const icon = document.createElement("svg");
@@ -403,7 +408,10 @@ function renderToolCard(tc) {
   icon.setAttribute("height", "14");
   icon.setAttribute("aria-hidden", "true");
   const iconId = tc.status === "ok" ? "#i-check"
-    : tc.status === "error" ? "#i-warn" : "#i-spin";
+    : tc.status === "error" ? "#i-warn"
+    : tc.status === "rejected" ? "#i-x"
+    : tc.status === "awaiting_confirm" ? "#i-warn"
+    : "#i-spin";
   icon.innerHTML = ``;
   sum.append(icon);
 
@@ -412,6 +420,13 @@ function renderToolCard(tc) {
   name.textContent = tc.name;
   sum.append(name);
 
+  if (tc.status === "awaiting_confirm") {
+    const badge = document.createElement("span");
+    badge.className = "tool-badge";
+    badge.textContent = "approbation requise";
+    sum.append(badge);
+  }
+
   if (tc.duration_ms != null) {
     const dur = document.createElement("span");
     dur.className = "tool-dur";
@@ -435,10 +450,46 @@ function renderToolCard(tc) {
     resBlock.textContent = `result: ${tc.preview}`;
     detail.append(resBlock);
   }
+
+  if (tc.status === "awaiting_confirm") {
+    const actions = document.createElement("div");
+    actions.className = "tool-confirm-actions";
+
+    const approve = document.createElement("button");
+    approve.type = "button";
+    approve.className = "btn btn-approve";
+    approve.textContent = "Autoriser";
+    approve.addEventListener("click", () => sendConfirmation(tc.id, true, approve, reject));
+
+    const reject = document.createElement("button");
+    reject.type = "button";
+    reject.className = "btn btn-reject";
+    reject.textContent = "Refuser";
+    reject.addEventListener("click", () => sendConfirmation(tc.id, false, approve, reject));
+
+    actions.append(approve, reject);
+    detail.append(actions);
+  }
+
   card.append(detail);
   return card;
 }
 
+async function sendConfirmation(callId, approve, approveBtn, rejectBtn) {
+  approveBtn.disabled = true;
+  rejectBtn.disabled = true;
+  try {
+    await apiJson("/app/api/chat/confirm", {
+      method: "POST",
+      body: { call_id: callId, approve },
+    });
+  } catch (err) {
+    console.error(err);
+    approveBtn.disabled = false;
+    rejectBtn.disabled = false;
+  }
+}
+
 function scrollToBottom() {
   el.messages.scrollTop = el.messages.scrollHeight;
 }
@@ -666,6 +717,29 @@ function handleEvent({ event, data }, assistantMsg, row, body, toolCardMap) {
       scrollToBottom();
       break;
     }
+    case "tool_confirm_required": {
+      // Either the tool_call event already landed (upgrade existing
+      // card to awaiting_confirm), or it didn't (build a fresh card
+      // straight in that state).
+      let entry = toolCardMap.get(data.id);
+      if (!entry) {
+        const tc = {
+          id: data.id, name: data.name, args: data.args || {},
+          status: "awaiting_confirm", preview: null, duration_ms: null,
+        };
+        assistantMsg.tool_calls.push(tc);
+        const card = renderToolCard(tc);
+        toolCardMap.set(data.id, { tc, card });
+        body.before(card);
+      } else {
+        entry.tc.status = "awaiting_confirm";
+        const fresh = renderToolCard(entry.tc);
+        entry.card.replaceWith(fresh);
+        entry.card = fresh;
+      }
+      scrollToBottom();
+      break;
+    }
     case "tool_result": {
       const entry = toolCardMap.get(data.id);
       if (!entry) break;
diff --git a/tests/test_dashboard_chat.py b/tests/test_dashboard_chat.py
index 71ad35b..335f049 100644
--- a/tests/test_dashboard_chat.py
+++ b/tests/test_dashboard_chat.py
@@ -23,7 +23,9 @@
     ThinkingDelta,
     ToolCallEnd,
     ToolCallStart,
+    ToolConfirmRequired,
 )
+from tarkamcp.dashboard.confirmations import ConfirmationStore
 from tarkamcp.dashboard.conversations import ConversationStore
 from tarkamcp.dashboard.csrf import CSRF_COOKIE
 from tarkamcp.dashboard.db import Database
@@ -75,6 +77,7 @@ def deps(tmp_path, engine):
         totp_record_success=lambda cid: None,
         conversations=ConversationStore(db),
         engine=engine,
+        confirmations=ConfirmationStore(),
         mcp_public_url="https://mcp.example/",
     )
 
@@ -511,6 +514,84 @@ def test_chat_persists_history_for_second_turn(app_and_client, engine, deps):
     assert history[1].content == "ack"
 
 
+def test_chat_stream_ssh_tool_emits_confirm_event(app_and_client, engine, deps):
+    """ssh_exec_command triggers a tool_confirm_required SSE frame and the
+    engine must wait for a decision via /app/api/chat/confirm.
+    """
+    import threading, time as _t
+
+    engine.script = FakeScript(events=[
+        ToolCallStart(id="tc1", name="ssh_exec_command", args={"host": "pve1", "command": "ls"}),
+        ToolConfirmRequired(id="tc1", name="ssh_exec_command", args={"host": "pve1", "command": "ls"}),
+        ToolCallEnd(id="tc1", status="ok", preview="ok", duration_ms=50),
+        TextDelta(text="done"),
+    ])
+
+    _, client = app_and_client
+    csrf = _login(client)
+    r = client.post("/app/api/conversations",
+                    headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
+                    content="{}")
+    cid = r.json()["conversation"]["id"]
+
+    # Approve the confirmation from a parallel thread so the SSE stream
+    # is free to unblock and finish.
+    def approve_when_ready():
+        deadline = _t.time() + 5
+        while _t.time() < deadline:
+            pending = deps.confirmations.pending_for(
+                deps.session_store.list_for_client("c")[0].session_id
+            )
+            if pending:
+                client.post(
+                    "/app/api/chat/confirm",
+                    headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
+                    content=json.dumps({"call_id": pending[0], "approve": True}),
+                )
+                return
+            _t.sleep(0.05)
+
+    t = threading.Thread(target=approve_when_ready)
+    t.start()
+    try:
+        r = client.post(
+            "/app/api/chat/stream",
+            headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
+            content=json.dumps({"conversation_id": cid, "content": "run ls"}),
+        )
+    finally:
+        t.join(timeout=5)
+
+    events = _parse_sse(r.text)
+    names = [e[0] for e in events]
+    assert "tool_confirm_required" in names
+    assert "tool_result" in names
+    # done must still fire so the client state settles.
+    assert "done" in names
+
+
+def test_confirm_endpoint_requires_csrf(app_and_client):
+    _, client = app_and_client
+    _login(client)
+    r = client.post(
+        "/app/api/chat/confirm",
+        headers={"Content-Type": "application/json"},
+        content=json.dumps({"call_id": "x", "approve": True}),
+    )
+    assert r.status_code == 403
+
+
+def test_confirm_endpoint_rejects_unknown_call_id(app_and_client):
+    _, client = app_and_client
+    csrf = _login(client)
+    r = client.post(
+        "/app/api/chat/confirm",
+        headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
+        content=json.dumps({"call_id": "nope", "approve": True}),
+    )
+    assert r.status_code == 404
+
+
 def test_chat_page_auth_required(app_and_client):
     _, client = app_and_client
     r = client.get("/app/chat")

From 8d9cedd4e9dc59de8538f980633ed2c42b75774d Mon Sep 17 00:00:00 2001
From: Showdown76py 
Date: Fri, 17 Apr 2026 02:23:25 +0200
Subject: [PATCH 039/155] Add dashboard "Tokens API" page for external MCP
 clients
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Connecting Gemini web, ChatGPT or Claude Desktop to TarkaMCP needs a
bearer token pasted into their MCP config. The dashboard's login-time
token is tied to the SQLite session and never leaves the server, so
add a dedicated page that mints named, copy-once tokens.

Server:
- AccessToken grows optional ``name`` and ``created_at`` fields.
- TokenStore.issue() accepts ``name=`` kwarg; a cap of 3 named tokens
  per client is enforced by raising ``TokenCapExceeded``. Internal
  dashboard-session bearers (name=None) are not counted against the
  cap.
- New helpers: list_named(), count_named(), revoke_named(prefix, cid).
  revoke_named requires a >=6-char prefix that uniquely identifies one
  of the client's OWN named tokens; cross-client revocation is
  rejected silently to avoid enumeration.
- Three new routes in app.py:
  * GET  /app/tokens         -- render the page with list + form
  * POST /app/tokens         -- create a token (CSRF + TOTP required;
    24h TTL inherited from TokenStore.TOKEN_TTL)
  * POST /app/tokens/revoke  -- revoke by prefix (CSRF required)
  _render_tokens_page centralises the Jinja call so the create/revoke
  handlers can re-render with form errors without duplicating state.

UI:
- templates/tokens.html: dark-themed page with the MCP URL to paste,
  a "create token" form (name required, TOTP required), a one-shot
  highlighted display of the just-created token, and a list of active
  tokens with prefix + hours-to-expiry + Révoquer button. Copy-to-
  clipboard button uses the Clipboard API.
- templates/chat.html sidebar gets a "Tokens API" link above the
  Déconnexion button, styled via the same .ghost class (now also
  applied to anchors).
- static/app.css: ~200 lines for the tokens page (header, sections,
  value display, form, list rows, copy/revoke buttons, error banner,
  mobile padding).

Tests:
- 9 new integration tests (tokens_page_requires_auth, lists_empty,
  create_requires_name, create_requires_totp, create_success,
  create_enforces_cap, revoke_removes_token, revoke_csrf_required,
  revoke_scoped_to_client).
- 5 new unit tests on the real TokenStore (named issue+list, cap,
  cap_frees_after_revoke, revoke_named_by_prefix_scoped_to_client,
  revoke_named_requires_min_prefix).
- FakeTokenStore extended to mirror the named-token API so the chat
  fixture keeps working without changes to other tests.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 src/tarkamcp/auth.py                         |  71 +++++-
 src/tarkamcp/dashboard/app.py                | 159 +++++++++++++
 src/tarkamcp/dashboard/static/app.css        | 238 ++++++++++++++++++-
 src/tarkamcp/dashboard/templates/chat.html   |   4 +
 src/tarkamcp/dashboard/templates/tokens.html | 140 +++++++++++
 tests/test_dashboard_chat.py                 | 187 ++++++++++++++-
 tests/test_dashboard_unit.py                 |  69 ++++++
 7 files changed, 857 insertions(+), 11 deletions(-)
 create mode 100644 src/tarkamcp/dashboard/templates/tokens.html

diff --git a/src/tarkamcp/auth.py b/src/tarkamcp/auth.py
index fdb05f3..e6af0d9 100644
--- a/src/tarkamcp/auth.py
+++ b/src/tarkamcp/auth.py
@@ -74,6 +74,12 @@ class AccessToken:
     token: str
     client_id: str
     expires_at: float
+    # Optional human label for tokens minted from the dashboard "API
+    # tokens" page. ``None`` means it's an internal dashboard-session
+    # bearer, which is not counted against the per-client cap and is
+    # not listed in the external-tokens UI.
+    name: str | None = None
+    created_at: float = 0.0
 
 
 @dataclass
@@ -241,25 +247,84 @@ def revoke(self, client_id: str) -> bool:
         return False
 
 
+class TokenCapExceeded(Exception):
+    """Raised when a client already has the maximum number of named tokens."""
+
+
 class TokenStore:
     """In-memory access token store with expiration."""
 
     TOKEN_TTL = 3600 * 24  # 24 hours
+    # Cap on named tokens (the ones listed in the dashboard's API
+    # tokens page). Internal dashboard-session bearers are unlimited
+    # because a re-login always revokes the prior one.
+    NAMED_TOKEN_CAP = 3
 
     def __init__(self) -> None:
         self._tokens: dict[str, AccessToken] = {}
 
-    def issue(self, client_id: str) -> tuple[str, int]:
-        """Issue an access token. Returns (token, expires_in)."""
+    def issue(
+        self, client_id: str, *, name: str | None = None,
+    ) -> tuple[str, int]:
+        """Issue an access token. Returns ``(token, expires_in)``.
+
+        If ``name`` is provided the token counts against the per-client
+        named-token cap. Raises :class:`TokenCapExceeded` if the cap is
+        already met.
+        """
+        if name is not None:
+            if self.count_named(client_id) >= self.NAMED_TOKEN_CAP:
+                raise TokenCapExceeded(
+                    f"client {client_id} already has "
+                    f"{self.NAMED_TOKEN_CAP} named tokens"
+                )
         token = secrets.token_hex(32)
+        now = time.time()
         self._tokens[token] = AccessToken(
             token=token,
             client_id=client_id,
-            expires_at=time.time() + self.TOKEN_TTL,
+            expires_at=now + self.TOKEN_TTL,
+            name=name,
+            created_at=now,
         )
         self._cleanup()
         return token, self.TOKEN_TTL
 
+    def list_named(self, client_id: str) -> list[AccessToken]:
+        """Return named tokens for ``client_id`` (newest first)."""
+        self._cleanup()
+        out = [
+            t for t in self._tokens.values()
+            if t.client_id == client_id and t.name is not None
+        ]
+        out.sort(key=lambda t: t.created_at, reverse=True)
+        return out
+
+    def count_named(self, client_id: str) -> int:
+        self._cleanup()
+        return sum(
+            1 for t in self._tokens.values()
+            if t.client_id == client_id and t.name is not None
+        )
+
+    def revoke_named(self, token_prefix: str, client_id: str) -> bool:
+        """Revoke a named token owned by ``client_id``, identified by prefix.
+
+        The prefix must match exactly one of the client's named tokens.
+        Returns ``True`` on successful revocation, ``False`` otherwise.
+        """
+        if len(token_prefix) < 6:
+            return False
+        matches = [
+            t for t in self._tokens.values()
+            if t.token.startswith(token_prefix)
+            and t.client_id == client_id
+            and t.name is not None
+        ]
+        if len(matches) != 1:
+            return False
+        return self.revoke(matches[0].token)
+
     def validate(self, token: str) -> str | None:
         """Validate a token. Returns client_id if valid, None otherwise."""
         access_token = self._tokens.get(token)
diff --git a/src/tarkamcp/dashboard/app.py b/src/tarkamcp/dashboard/app.py
index e72e8bd..a37264b 100644
--- a/src/tarkamcp/dashboard/app.py
+++ b/src/tarkamcp/dashboard/app.py
@@ -9,6 +9,7 @@
 
 import asyncio
 import json
+import time
 from dataclasses import dataclass
 from pathlib import Path
 from typing import Any, Callable
@@ -409,6 +410,102 @@ async def logout(request: Request) -> Response:
         _apply_security_headers(response)
         return response
 
+    async def tokens_get(request: Request) -> Response:
+        session = _load_session(request, deps)
+        if not session:
+            return RedirectResponse("/app/login", status_code=302)
+        if not _bearer_live(deps, session):
+            return RedirectResponse("/app/refresh?next=/app/tokens", status_code=302)
+        return _render_tokens_page(request, session, deps)
+
+    async def tokens_create(request: Request) -> Response:
+        session = _load_session(request, deps)
+        if not session:
+            return RedirectResponse("/app/login", status_code=302)
+        if not _bearer_live(deps, session):
+            return RedirectResponse("/app/refresh?next=/app/tokens", status_code=302)
+        if not await csrf.verify(request):
+            return JSONResponse({"error": "csrf"}, status_code=403)
+
+        form = await request.form()
+        name_raw = form.get("name", "")
+        name = (name_raw if isinstance(name_raw, str) else "").strip()
+        totp_raw = form.get("totp", "")
+        totp = (totp_raw if isinstance(totp_raw, str) else "").strip()
+
+        if not name:
+            return _render_tokens_page(
+                request, session, deps,
+                form_error="Le nom est obligatoire.",
+                form_name=name,
+            )
+        if len(name) > 60:
+            return _render_tokens_page(
+                request, session, deps,
+                form_error="Le nom ne peut pas dépasser 60 caractères.",
+                form_name=name,
+            )
+        if not totp:
+            return _render_tokens_page(
+                request, session, deps,
+                form_error="Le code 2FA est requis.",
+                form_name=name,
+            )
+        if deps.totp_locked(session.client_id):
+            return _render_tokens_page(
+                request, session, deps,
+                form_error="Trop de tentatives 2FA, réessaie dans 5 minutes.",
+                form_name=name,
+            )
+        if not deps.client_store.verify_totp(session.client_id, totp):  # type: ignore[attr-defined]
+            deps.totp_record_failure(session.client_id)
+            return _render_tokens_page(
+                request, session, deps,
+                form_error="Code 2FA invalide.",
+                form_name=name,
+            )
+        deps.totp_record_success(session.client_id)
+
+        try:
+            token, ttl = deps.token_store.issue(  # type: ignore[attr-defined]
+                session.client_id, name=name,
+            )
+        except Exception as exc:  # noqa: BLE001
+            # TokenCapExceeded lives in tarkamcp.auth; catch broadly so
+            # we don't take on a circular import just for this check.
+            if type(exc).__name__ == "TokenCapExceeded":
+                return _render_tokens_page(
+                    request, session, deps,
+                    form_error=(
+                        "Limite atteinte : maximum 3 tokens actifs. "
+                        "Révoque-en un pour en créer un nouveau."
+                    ),
+                    form_name=name,
+                )
+            raise
+
+        return _render_tokens_page(
+            request, session, deps,
+            just_created={"name": name, "token": token, "ttl": ttl},
+        )
+
+    async def tokens_revoke(request: Request) -> Response:
+        session = _load_session(request, deps)
+        if not session:
+            return RedirectResponse("/app/login", status_code=302)
+        if not _bearer_live(deps, session):
+            return RedirectResponse("/app/refresh?next=/app/tokens", status_code=302)
+        if not await csrf.verify(request):
+            return JSONResponse({"error": "csrf"}, status_code=403)
+        form = await request.form()
+        prefix_raw = form.get("token_prefix", "")
+        prefix = (prefix_raw if isinstance(prefix_raw, str) else "").strip()
+        if prefix:
+            deps.token_store.revoke_named(  # type: ignore[attr-defined]
+                prefix, session.client_id,
+            )
+        return RedirectResponse("/app/tokens", status_code=303)
+
     async def chat_get(request: Request) -> Response:
         session = _load_session(request, deps)
         if not session:
@@ -681,6 +778,9 @@ async def _confirm(req: ToolConfirmRequired) -> bool:
         Route("/app/refresh", refresh_post, methods=["POST"]),
         Route("/app/logout", logout, methods=["POST"]),
         Route("/app/chat", chat_get, methods=["GET"]),
+        Route("/app/tokens", tokens_get, methods=["GET"]),
+        Route("/app/tokens", tokens_create, methods=["POST"]),
+        Route("/app/tokens/revoke", tokens_revoke, methods=["POST"]),
         Route("/app/api/conversations", api_conv_list, methods=["GET"]),
         Route("/app/api/conversations", api_conv_create, methods=["POST"]),
         Route("/app/api/conversations/{conv_id}", api_conv_detail, methods=["GET"]),
@@ -726,6 +826,65 @@ def _require_active_session(
     return session
 
 
+def _render_tokens_page(
+    request: Request,
+    session: Session,
+    deps: DashboardDeps,
+    *,
+    form_error: str | None = None,
+    form_name: str = "",
+    just_created: dict[str, Any] | None = None,
+) -> Response:
+    """Render the /app/tokens page (list + create form)."""
+    tokens_raw = deps.token_store.list_named(session.client_id)  # type: ignore[attr-defined]
+    now = time.time()
+    tokens = [
+        {
+            "name": t.name,
+            "prefix": t.token[:12],
+            "created_at": t.created_at,
+            "expires_at": t.expires_at,
+            "expires_in_hours": max(0, round((t.expires_at - now) / 3600, 1)),
+        }
+        for t in tokens_raw
+    ]
+    count = len(tokens)
+    cap = getattr(deps.token_store, "NAMED_TOKEN_CAP", 3)
+
+    client_name = (
+        deps.client_store.get_name(session.client_id)  # type: ignore[attr-defined]
+        or session.client_id
+    )
+
+    # Build the MCP URL the user should paste into external clients.
+    # We prefer the public URL if configured (that's what Gemini/ChatGPT
+    # need to reach us) and otherwise reconstruct from the request.
+    if deps.mcp_public_url:
+        mcp_url = deps.mcp_public_url.rstrip("/") + "/mcp"
+    else:
+        scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
+        host = request.headers.get(
+            "x-forwarded-host", request.headers.get("host", "localhost"),
+        )
+        mcp_url = f"{scheme}://{host}/mcp"
+
+    return _render(
+        "tokens.html",
+        request,
+        client_id=session.client_id,
+        client_name=client_name,
+        tokens=tokens,
+        count=count,
+        cap=cap,
+        can_create=count < cap,
+        form_error=form_error,
+        form_name=form_name,
+        just_created=just_created,
+        mcp_url=mcp_url,
+        locked=deps.totp_locked(session.client_id),
+    )
+
+
 def _resolve_mcp_url(request: Request, deps: DashboardDeps) -> str:
     """Return the URL the chat engine should use for MCP.
 
diff --git a/src/tarkamcp/dashboard/static/app.css b/src/tarkamcp/dashboard/static/app.css
index 9d5964c..0cdc20b 100644
--- a/src/tarkamcp/dashboard/static/app.css
+++ b/src/tarkamcp/dashboard/static/app.css
@@ -288,7 +288,8 @@ button.primary.icon-btn {
   aspect-ratio: 1;
 }
 
-button.ghost {
+button.ghost,
+a.ghost {
   background: transparent;
   border: 1px solid var(--border);
   color: var(--fg-muted);
@@ -298,11 +299,15 @@ button.ghost {
   align-items: center;
   gap: 0.5rem;
   font-size: 0.88rem;
+  text-decoration: none;
   transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
 }
 
-button.ghost:hover { background: var(--bg); color: var(--fg); border-color: var(--border-strong); }
-button.ghost.full { width: 100%; justify-content: center; }
+button.ghost:hover,
+a.ghost:hover { background: var(--bg); color: var(--fg); border-color: var(--border-strong); }
+button.ghost.full,
+a.ghost.full { width: 100%; justify-content: center; }
+a.sidebar-link { cursor: pointer; }
 
 .icon-btn {
   background: transparent;
@@ -801,3 +806,230 @@ button.ghost.full { width: 100%; justify-content: center; }
   .composer-options { justify-content: center; }
 }
 
+/* ---------------- Tokens page ---------------- */
+
+.tokens-page {
+  background: var(--bg);
+  color: var(--fg);
+  font-family: var(--font);
+  margin: 0;
+  min-height: 100vh;
+}
+
+.tokens-wrap {
+  max-width: 780px;
+  margin: 0 auto;
+  padding: 2rem 1.25rem 4rem;
+  display: flex;
+  flex-direction: column;
+  gap: 1.5rem;
+}
+
+.tokens-header h1 {
+  margin: 0.4rem 0 0.25rem;
+  font-size: 1.5rem;
+}
+
+.tokens-header p { margin: 0; }
+
+.back-link {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.35rem;
+  color: var(--fg-muted);
+  text-decoration: none;
+  font-size: 0.88rem;
+}
+.back-link:hover { color: var(--fg); }
+.back-link svg { transform: rotate(180deg); }
+
+.tokens-section {
+  background: var(--bg-elevated);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  padding: 1.1rem 1.25rem;
+  display: flex;
+  flex-direction: column;
+  gap: 0.85rem;
+}
+
+.tokens-section h2 {
+  margin: 0;
+  font-size: 1rem;
+  font-weight: 600;
+}
+
+.token-endpoint {
+  display: flex;
+  gap: 0.5rem;
+  align-items: stretch;
+  background: var(--bg-soft);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  padding: 0.35rem 0.55rem;
+}
+.token-endpoint code {
+  flex: 1;
+  font-family: var(--font-mono);
+  font-size: 0.85rem;
+  overflow-x: auto;
+  white-space: nowrap;
+  padding: 0.3rem 0.1rem;
+}
+
+.btn-copy {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.35rem;
+  background: transparent;
+  color: var(--fg-muted);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  padding: 0.35rem 0.6rem;
+  cursor: pointer;
+  font-family: inherit;
+  font-size: 0.82rem;
+  transition: color 0.15s, border-color 0.15s;
+}
+.btn-copy:hover {
+  color: var(--fg);
+  border-color: var(--border-strong);
+}
+
+.token-just-created {
+  border-color: color-mix(in oklab, var(--accent) 55%, var(--border));
+  background: color-mix(in oklab, var(--accent) 8%, var(--bg-elevated));
+}
+
+.token-value-wrap {
+  display: flex;
+  gap: 0.5rem;
+  align-items: stretch;
+  background: var(--bg);
+  border: 1px solid var(--border-strong);
+  border-radius: var(--radius-sm);
+  padding: 0.4rem 0.55rem;
+}
+.token-value {
+  flex: 1;
+  font-family: var(--font-mono);
+  font-size: 0.82rem;
+  overflow-x: auto;
+  white-space: nowrap;
+  padding: 0.35rem 0.1rem;
+  user-select: all;
+}
+
+.tokens-form {
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+}
+.tokens-form label {
+  display: flex;
+  flex-direction: column;
+  gap: 0.3rem;
+  font-size: 0.85rem;
+  color: var(--fg-muted);
+}
+.tokens-form input {
+  font-family: inherit;
+  font-size: 0.95rem;
+  padding: 0.55rem 0.7rem;
+  border-radius: var(--radius-sm);
+  border: 1px solid var(--border-strong);
+  background: var(--bg-soft);
+  color: var(--fg);
+}
+.tokens-form input:focus {
+  outline: 2px solid var(--accent);
+  outline-offset: 1px;
+}
+.tokens-form button {
+  align-self: flex-start;
+  padding: 0.55rem 1rem;
+  border-radius: var(--radius-sm);
+  border: 1px solid var(--accent);
+  background: var(--accent);
+  color: var(--accent-fg);
+  font-family: inherit;
+  font-size: 0.9rem;
+  cursor: pointer;
+}
+.tokens-form button:hover:not(:disabled) {
+  background: var(--accent-hover);
+  border-color: var(--accent-hover);
+}
+.tokens-form button:disabled,
+.tokens-form input:disabled {
+  opacity: 0.55;
+  cursor: not-allowed;
+}
+
+.tokens-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.token-row {
+  display: grid;
+  grid-template-columns: 1fr auto;
+  grid-template-areas:
+    "head   action"
+    "meta   action";
+  gap: 0.25rem 0.75rem;
+  padding: 0.7rem 0.85rem;
+  background: var(--bg-soft);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  align-items: center;
+}
+.token-row-head { grid-area: head; display: flex; gap: 0.5rem; align-items: baseline; flex-wrap: wrap; }
+.token-row-meta { grid-area: meta; }
+.token-revoke-form { grid-area: action; margin: 0; }
+.token-prefix {
+  font-family: var(--font-mono);
+  font-size: 0.78rem;
+  color: var(--fg-muted);
+}
+
+.btn-danger {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.35rem;
+  background: transparent;
+  color: var(--fg-muted);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  padding: 0.35rem 0.6rem;
+  cursor: pointer;
+  font-family: inherit;
+  font-size: 0.82rem;
+  transition: color 0.15s, border-color 0.15s;
+}
+.btn-danger:hover {
+  color: var(--danger);
+  border-color: color-mix(in oklab, var(--danger) 55%, var(--border));
+}
+
+.banner {
+  padding: 0.55rem 0.75rem;
+  border-radius: var(--radius-sm);
+  font-size: 0.88rem;
+}
+.banner-error {
+  background: var(--danger-bg);
+  color: var(--danger);
+  border: 1px solid color-mix(in oklab, var(--danger) 40%, var(--border));
+}
+
+.muted { color: var(--fg-muted); }
+.small { font-size: 0.85rem; }
+
+@media (max-width: 600px) {
+  .tokens-wrap { padding: 1.25rem 0.85rem 3rem; }
+}
diff --git a/src/tarkamcp/dashboard/templates/chat.html b/src/tarkamcp/dashboard/templates/chat.html
index 0dcadfd..226740c 100644
--- a/src/tarkamcp/dashboard/templates/chat.html
+++ b/src/tarkamcp/dashboard/templates/chat.html
@@ -52,6 +52,10 @@
         Session
         {{ client_name }}
       
+      
+        
+        Tokens API
+      
       
+ + + + {% if just_created %} +
+

Nouveau token créé

+

+ Token pour {{ just_created.name }}. Copie-le maintenant — + il ne sera plus jamais affiché. +

+
+ {{ just_created.token }} + +
+

+ Dans l'en-tête HTTP de ton client MCP : Authorization: Bearer {{ just_created.token[:8] }}… +

+
+ {% endif %} + +
+
+

Créer un token ({{ count }}/{{ cap }})

+
+ + {% if form_error %} + + {% endif %} + + {% if can_create %} + + + + + + + {% else %} +

+ Tu as atteint la limite de {{ cap }} tokens actifs. Révoque un token + existant pour en créer un nouveau. +

+ {% endif %} +
+ +
+

Tokens actifs

+ {% if tokens %} +
    + {% for t in tokens %} +
  • +
    + {{ t.name }} + {{ t.prefix }}… +
    +
    + expire dans {{ t.expires_in_hours }} h +
    +
    + + + +
    +
  • + {% endfor %} +
+ {% else %} +

Aucun token actif pour le moment.

+ {% endif %} +
+ + + + + +{% endblock %} diff --git a/tests/test_dashboard_chat.py b/tests/test_dashboard_chat.py index 335f049..95c235a 100644 --- a/tests/test_dashboard_chat.py +++ b/tests/test_dashboard_chat.py @@ -38,26 +38,77 @@ def verify_totp(self, cid, code): return code == "123456" def get_name(self, cid): return "Test" +class _FakeTokenCapExceeded(Exception): + pass + + class FakeTokenStore: + NAMED_TOKEN_CAP = 3 + def __init__(self): self.revoked = [] self._n = 0 self._live: dict[str, str] = {} - def issue(self, cid): + # token -> (name, created_at, expires_at) for named tokens only + self._named: dict[str, tuple[str, float, float]] = {} + + def issue(self, cid, *, name=None): + if name is not None: + active = sum( + 1 for t, meta in self._named.items() + if self._live.get(t) == cid + ) + if active >= self.NAMED_TOKEN_CAP: + err = _FakeTokenCapExceeded(f"max {self.NAMED_TOKEN_CAP}") + err.__class__.__name__ = "TokenCapExceeded" + raise err self._n += 1 - token = f"b_{self._n}" + token = f"b_{self._n}" + ("x" * 60) # pad so prefix[:12] is stable self._live[token] = cid + if name is not None: + self._named[token] = (name, time.time(), time.time() + BEARER_TTL_SECONDS) return token, BEARER_TTL_SECONDS + def validate(self, token): - # Mirrors the real TokenStore: return client_id if the token - # was issued by this store instance. Tests can subclass to - # simulate a wiped store (e.g. after a service restart). return self._live.get(token) + def revoke(self, token): self.revoked.append(token) self._live.pop(token, None) + self._named.pop(token, None) return True + # Named-token APIs ------------------------------------------------ + def list_named(self, cid): + from dataclasses import dataclass + + @dataclass + class _Row: + token: str + name: str + created_at: float + expires_at: float + + return [ + _Row(token=t, name=meta[0], created_at=meta[1], expires_at=meta[2]) + for t, meta in self._named.items() + if self._live.get(t) == cid + ] + + def count_named(self, cid): + return len(self.list_named(cid)) + + def revoke_named(self, token_prefix, cid): + if len(token_prefix) < 6: + return False + matches = [ + t for t in self._named + if t.startswith(token_prefix) and self._live.get(t) == cid + ] + if len(matches) != 1: + return False + return self.revoke(matches[0]) + @pytest.fixture() def engine(): @@ -592,6 +643,132 @@ def test_confirm_endpoint_rejects_unknown_call_id(app_and_client): assert r.status_code == 404 +# --------------------------------------------------------------------------- +# Tokens page +# --------------------------------------------------------------------------- + +def test_tokens_page_requires_auth(app_and_client): + _, client = app_and_client + r = client.get("/app/tokens") + assert r.status_code == 302 + assert r.headers["location"] == "/app/login" + + +def test_tokens_page_lists_empty(app_and_client): + _, client = app_and_client + _login(client) + r = client.get("/app/tokens") + assert r.status_code == 200 + assert "Aucun token actif" in r.text + assert "0/3" in r.text # count indicator + + +def test_tokens_create_requires_name(app_and_client): + _, client = app_and_client + csrf = _login(client) + r = client.post( + "/app/tokens", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/x-www-form-urlencoded"}, + data={"csrf_token": csrf, "name": "", "totp": "123456"}, + ) + assert r.status_code == 200 + assert "Le nom est obligatoire" in r.text + + +def test_tokens_create_requires_totp(app_and_client): + _, client = app_and_client + csrf = _login(client) + r = client.post( + "/app/tokens", + data={"csrf_token": csrf, "name": "Gemini Web", "totp": "999999"}, + ) + assert r.status_code == 200 + assert "Code 2FA invalide" in r.text + + +def test_tokens_create_success(app_and_client, deps): + _, client = app_and_client + csrf = _login(client) + r = client.post( + "/app/tokens", + data={"csrf_token": csrf, "name": "Gemini Web", "totp": "123456"}, + ) + assert r.status_code == 200 + assert "Nouveau token créé" in r.text + assert "Gemini Web" in r.text + assert deps.token_store.count_named("c") == 1 + + +def test_tokens_create_enforces_cap(app_and_client, deps): + _, client = app_and_client + csrf = _login(client) + for i in range(3): + r = client.post( + "/app/tokens", + data={"csrf_token": csrf, "name": f"Client {i}", "totp": "123456"}, + ) + assert r.status_code == 200, f"issue {i} failed" + # Fourth attempt should fail + r = client.post( + "/app/tokens", + data={"csrf_token": csrf, "name": "Client 4", "totp": "123456"}, + ) + assert r.status_code == 200 + assert "Limite atteinte" in r.text or "maximum 3" in r.text + assert deps.token_store.count_named("c") == 3 + + +def test_tokens_revoke_removes_token(app_and_client, deps): + _, client = app_and_client + csrf = _login(client) + # Create two tokens + client.post("/app/tokens", + data={"csrf_token": csrf, "name": "A", "totp": "123456"}) + client.post("/app/tokens", + data={"csrf_token": csrf, "name": "B", "totp": "123456"}) + assert deps.token_store.count_named("c") == 2 + + rows = deps.token_store.list_named("c") + prefix = rows[0].token[:12] + + r = client.post( + "/app/tokens/revoke", + data={"csrf_token": csrf, "token_prefix": prefix}, + ) + assert r.status_code == 303 + assert deps.token_store.count_named("c") == 1 + + +def test_tokens_revoke_csrf_required(app_and_client): + _, client = app_and_client + _login(client) + r = client.post( + "/app/tokens/revoke", + data={"token_prefix": "abcdef"}, + ) + assert r.status_code == 403 + + +def test_tokens_revoke_scoped_to_client(app_and_client, deps): + """A client cannot revoke another client's token even with the right prefix.""" + _, client = app_and_client + csrf = _login(client) + client.post("/app/tokens", + data={"csrf_token": csrf, "name": "A", "totp": "123456"}) + + # Craft a fake token belonging to another client + deps.token_store._live["foreign_token_xxxxxxxxxxxx"] = "other" + deps.token_store._named["foreign_token_xxxxxxxxxxxx"] = ("ForeignApp", 0, 1e12) + + r = client.post( + "/app/tokens/revoke", + data={"csrf_token": csrf, "token_prefix": "foreign_toke"}, + ) + # Route still redirects (no enumeration), but the token stays alive. + assert r.status_code == 303 + assert deps.token_store.validate("foreign_token_xxxxxxxxxxxx") == "other" + + def test_chat_page_auth_required(app_and_client): _, client = app_and_client r = client.get("/app/chat") diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index f5201a9..13c2dda 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -327,6 +327,75 @@ def test_classify_error_upstream_timeout(): assert code == "upstream_timeout" +def test_token_store_named_issue_and_list(): + from tarkamcp.auth import TokenStore + + ts = TokenStore() + t1, _ = ts.issue("cid", name="Gemini Web") + t2, _ = ts.issue("cid", name="ChatGPT") + + rows = ts.list_named("cid") + assert len(rows) == 2 + # Newest first + assert rows[0].name == "ChatGPT" + assert rows[1].name == "Gemini Web" + assert ts.count_named("cid") == 2 + + # Unnamed dashboard session token must not appear + ts.issue("cid") + assert ts.count_named("cid") == 2 + + +def test_token_store_cap_is_three(): + from tarkamcp.auth import TokenCapExceeded, TokenStore + + ts = TokenStore() + for i in range(3): + ts.issue("cid", name=f"t{i}") + with pytest.raises(TokenCapExceeded): + ts.issue("cid", name="overflow") + # Cap is per-client: another client is unaffected + ts.issue("other", name="ok") + + +def test_token_store_cap_frees_after_revoke(): + from tarkamcp.auth import TokenStore + + ts = TokenStore() + t1, _ = ts.issue("cid", name="a") + ts.issue("cid", name="b") + ts.issue("cid", name="c") + assert ts.count_named("cid") == 3 + + # Expire (not just schedule-revoke) the first token so the cap count drops. + ts._tokens[t1].expires_at = 0 + # Re-issue should now succeed. + ts.issue("cid", name="d") + assert ts.count_named("cid") == 3 + + +def test_token_store_revoke_named_by_prefix_scoped_to_client(): + from tarkamcp.auth import TokenStore + + ts = TokenStore() + t_mine, _ = ts.issue("me", name="Mine") + t_other, _ = ts.issue("other", name="Theirs") + prefix = t_other[:12] + + # I can't revoke someone else's token even with the right prefix. + assert ts.revoke_named(prefix, "me") is False + # Owner can revoke their own. + assert ts.revoke_named(prefix, "other") is True + + +def test_token_store_revoke_named_requires_min_prefix(): + from tarkamcp.auth import TokenStore + + ts = TokenStore() + ts.issue("cid", name="a") + assert ts.revoke_named("ab", "cid") is False # too short + + def test_mcp_tool_to_declaration_passes_input_schema(): from google.genai import types From 777d766d044d7575b5be2778f36ddbb454e3bf81 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 02:26:09 +0200 Subject: [PATCH 040/155] Promote Tokens API to a prominent sidebar CTA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit About 40% of users come to the dashboard for external-client token management, not chat. The tiny ghost link at the bottom of the sidebar buried that flow. Moved it to a proper call-to-action card directly under "Nouveau chat": - Icon tile in the accent colour on the left, two-line label on the right (title + "Brancher Gemini, ChatGPT, Claude Desktop…"), chevron on the far right that slides forward on hover. - Accent-tinted background + border, darkens on hover, subtle press-down on active. Competes visually with the primary chat button without copying it verbatim, so the eye treats them as two distinct entry points rather than a duplicated action. Removed the redundant ghost-style link from the sidebar footer -- the footer now carries only Session info + Déconnexion, as before tokens existed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tarkamcp/dashboard/static/app.css | 68 ++++++++++++++++++++++ src/tarkamcp/dashboard/templates/chat.html | 15 +++-- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/tarkamcp/dashboard/static/app.css b/src/tarkamcp/dashboard/static/app.css index 0cdc20b..fa42516 100644 --- a/src/tarkamcp/dashboard/static/app.css +++ b/src/tarkamcp/dashboard/static/app.css @@ -288,6 +288,74 @@ button.primary.icon-btn { aspect-ratio: 1; } +/* Prominent "Tokens API" call-to-action right below "Nouveau chat". + * Sized like a second primary action so it competes for attention -- + * ~40% of users arrive for external-client token management, not chat. */ +.sidebar-cta { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.65rem 0.75rem; + border-radius: var(--radius-sm); + border: 1px solid color-mix(in oklab, var(--accent) 45%, var(--border)); + background: color-mix(in oklab, var(--accent) 9%, var(--bg-elevated)); + color: var(--fg); + text-decoration: none; + transition: background 150ms ease, border-color 150ms ease, transform 80ms ease; +} + +.sidebar-cta:hover { + background: color-mix(in oklab, var(--accent) 16%, var(--bg-elevated)); + border-color: var(--accent); +} +.sidebar-cta:active { transform: translateY(1px); } + +.sidebar-cta-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--accent); + color: var(--accent-fg); + flex-shrink: 0; +} + +.sidebar-cta-text { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.sidebar-cta-title { + font-weight: 600; + font-size: 0.9rem; + line-height: 1.15; + color: var(--fg); +} + +.sidebar-cta-sub { + font-size: 0.75rem; + color: var(--fg-muted); + line-height: 1.25; + margin-top: 0.15rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-cta-chev { + color: var(--fg-faint); + flex-shrink: 0; + transition: color 150ms ease, transform 150ms ease; +} +.sidebar-cta:hover .sidebar-cta-chev { + color: var(--accent); + transform: translateX(2px); +} + button.ghost, a.ghost { background: transparent; diff --git a/src/tarkamcp/dashboard/templates/chat.html b/src/tarkamcp/dashboard/templates/chat.html index 226740c..f8cc7fb 100644 --- a/src/tarkamcp/dashboard/templates/chat.html +++ b/src/tarkamcp/dashboard/templates/chat.html @@ -45,6 +45,17 @@ Nouveau chat + + + + + + - - - Tokens API -
+ diff --git a/src/tarkamcp/dashboard/usage.py b/src/tarkamcp/dashboard/usage.py new file mode 100644 index 0000000..e8fd0f5 --- /dev/null +++ b/src/tarkamcp/dashboard/usage.py @@ -0,0 +1,321 @@ +"""Per-client usage accounting and budget enforcement. + +Two moving parts: + +- :class:`UsageMeter` -- stateless pricing calculator. Turns Gemini + ``usage_metadata`` (prompt/cached/output token counts + model) into a + USD cost using the public Google AI Studio rate card. +- :class:`UsageStore` -- SQLite-backed ledger + 5h session tracker. One + row per assistant turn in ``usage_events`` (immutable), plus one + live-session row per ``client_id`` in ``usage_5h_sessions`` that is + reset whenever the 5-hour window expires. + +Windows: + +- **5h session (Anthropic-style)**: a contiguous 5h window that opens on + the first turn after any inactivity of >=5h. While the window is open, + turns accumulate into it. Once the window closes (now - started_at >= + 18000s at check time), the next turn resets the window to 0 and starts + a new one beginning at that moment. +- **Weekly (rolling)**: a trailing 7-day sum over ``usage_events``. + +Both caps are configurable via env vars, read in ``__main__`` and passed +into :class:`Budget`. Cap <= 0 means "unlimited". +""" + +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass +from typing import Any + +from .db import Database + + +# --------------------------------------------------------------------------- +# Pricing +# --------------------------------------------------------------------------- + +# Rates are in USD per 1M tokens. Pulled from Google AI Studio pricing +# page on 2026-04-17. Keys with ``_hi`` suffixes apply when the prompt +# token count exceeds :data:`_TIER_THRESHOLD`; only the Pro models have +# a high tier in the public rate card. +_PRICING: dict[str, dict[str, float]] = { + "gemini-2.5-flash": { + "input": 0.30, "cached": 0.03, "output": 2.50, + }, + "gemini-2.5-pro": { + "input": 1.25, "cached": 0.125, "output": 10.00, + "input_hi": 2.50, "cached_hi": 0.25, "output_hi": 15.00, + }, + "gemini-3-flash-preview": { + "input": 0.50, "cached": 0.05, "output": 3.00, + }, + "gemini-3.1-pro-preview": { + "input": 2.00, "cached": 0.20, "output": 12.00, + "input_hi": 4.00, "cached_hi": 0.40, "output_hi": 18.00, + }, +} + +# Prompt tokens above this count trigger the Pro models' "long prompt" +# pricing tier. +_TIER_THRESHOLD = 200_000 + +_FIVE_HOURS_SECS = 5 * 3600 +_SEVEN_DAYS_SECS = 7 * 24 * 3600 + + +# --------------------------------------------------------------------------- +# Meter +# --------------------------------------------------------------------------- + + +class UsageMeter: + """Pure function: (model, token counts) -> cost USD.""" + + @staticmethod + def cost_usd( + model: str, + *, + prompt_tokens: int, + cached_tokens: int, + output_tokens: int, + ) -> float: + rates = _PRICING.get(model) or _PRICING["gemini-2.5-flash"] + use_hi = prompt_tokens > _TIER_THRESHOLD and "input_hi" in rates + in_rate = rates["input_hi"] if use_hi else rates["input"] + out_rate = rates["output_hi"] if use_hi else rates["output"] + ca_rate = rates["cached_hi"] if use_hi else rates["cached"] + + # cached_tokens is the subset of prompt_tokens served from cache; + # bill the remainder at the input rate and cached_tokens at the + # cached rate (which is ~10x cheaper across the board). + billable_input = max(0, prompt_tokens - cached_tokens) + total = ( + billable_input * in_rate + + cached_tokens * ca_rate + + output_tokens * out_rate + ) + return total / 1_000_000 + + +# --------------------------------------------------------------------------- +# Budget config +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Budget: + """Per-client spending caps (global, identical across clients). + + ``0`` (or any non-positive value) disables the cap on that window. + Units: USD. + """ + + limit_5h_usd: float + limit_week_usd: float + + @property + def has_any_limit(self) -> bool: + return self.limit_5h_usd > 0 or self.limit_week_usd > 0 + + +# --------------------------------------------------------------------------- +# Store +# --------------------------------------------------------------------------- + + +@dataclass +class UsageSnapshot: + """Current usage picture for one client, used by the UI footer.""" + + spent_5h_usd: float + limit_5h_usd: float + session_5h_started_at: float | None # None if session window is empty/expired + session_5h_reset_at: float | None # started_at + 5h, when UI can expect reset + + spent_week_usd: float + limit_week_usd: float + + def to_json(self) -> dict[str, Any]: + return { + "spent_5h_usd": round(self.spent_5h_usd, 6), + "limit_5h_usd": self.limit_5h_usd, + "session_5h_started_at": self.session_5h_started_at, + "session_5h_reset_at": self.session_5h_reset_at, + "spent_week_usd": round(self.spent_week_usd, 6), + "limit_week_usd": self.limit_week_usd, + } + + +@dataclass +class BudgetBlock: + """Returned by ``check_budget`` when a request must be refused.""" + + window: str # "5h" | "week" + spent_usd: float + limit_usd: float + reset_at: float | None # absolute epoch seconds of window reset, if known + + +class UsageStore: + def __init__(self, db: Database, budget: Budget) -> None: + self._db = db + self._budget = budget + + @property + def budget(self) -> Budget: + return self._budget + + # --- write path ------------------------------------------------------- + + def record_turn( + self, + *, + client_id: str, + conversation_id: str | None, + message_id: str | None, + model: str, + prompt_tokens: int, + cached_tokens: int, + output_tokens: int, + cost_usd: float, + now: float | None = None, + ) -> None: + """Append a ledger row and update the 5h session row atomically.""" + ts = now if now is not None else time.time() + conn = self._db.conn() + conn.execute("BEGIN") + try: + conn.execute( + """ + INSERT INTO usage_events (id, client_id, conversation_id, + message_id, ts, model, + prompt_tokens, cached_tokens, + output_tokens, cost_usd) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + str(uuid.uuid4()), client_id, conversation_id, message_id, + ts, model, prompt_tokens, cached_tokens, output_tokens, + cost_usd, + ), + ) + self._apply_to_session(conn, client_id, ts, cost_usd) + conn.execute("COMMIT") + except Exception: + conn.execute("ROLLBACK") + raise + + def _apply_to_session( + self, conn: Any, client_id: str, ts: float, cost_usd: float, + ) -> None: + row = conn.execute( + "SELECT started_at, last_event_at, cost_usd " + " FROM usage_5h_sessions WHERE client_id = ?", + (client_id,), + ).fetchone() + if row is None: + conn.execute( + "INSERT INTO usage_5h_sessions " + " (client_id, started_at, last_event_at, cost_usd) " + " VALUES (?, ?, ?, ?)", + (client_id, ts, ts, cost_usd), + ) + return + started_at = float(row["started_at"]) + if ts - started_at >= _FIVE_HOURS_SECS: + # Previous session expired -- start a fresh one at ``ts``. + conn.execute( + "UPDATE usage_5h_sessions " + " SET started_at = ?, last_event_at = ?, cost_usd = ? " + " WHERE client_id = ?", + (ts, ts, cost_usd, client_id), + ) + else: + conn.execute( + "UPDATE usage_5h_sessions " + " SET last_event_at = ?, cost_usd = cost_usd + ? " + " WHERE client_id = ?", + (ts, cost_usd, client_id), + ) + + # --- read path -------------------------------------------------------- + + def snapshot(self, client_id: str, *, now: float | None = None) -> UsageSnapshot: + """Return current usage for ``client_id``. + + The 5h-window figure reflects the Anthropic-style session: if the + last known session has been dormant for >=5h, we report 0 spent + (the window is closed and the next turn will open a new one). + """ + ts = now if now is not None else time.time() + row = self._db.conn().execute( + "SELECT started_at, cost_usd " + " FROM usage_5h_sessions WHERE client_id = ?", + (client_id,), + ).fetchone() + if row is None: + spent_5h = 0.0 + started_at = None + reset_at = None + else: + started_at = float(row["started_at"]) + if ts - started_at >= _FIVE_HOURS_SECS: + spent_5h = 0.0 + started_at = None + reset_at = None + else: + spent_5h = float(row["cost_usd"]) + reset_at = started_at + _FIVE_HOURS_SECS + + week_row = self._db.conn().execute( + "SELECT COALESCE(SUM(cost_usd), 0) AS total " + " FROM usage_events WHERE client_id = ? AND ts >= ?", + (client_id, ts - _SEVEN_DAYS_SECS), + ).fetchone() + spent_week = float(week_row["total"] if week_row else 0.0) + + return UsageSnapshot( + spent_5h_usd=spent_5h, + limit_5h_usd=self._budget.limit_5h_usd, + session_5h_started_at=started_at, + session_5h_reset_at=reset_at, + spent_week_usd=spent_week, + limit_week_usd=self._budget.limit_week_usd, + ) + + # --- enforcement ------------------------------------------------------ + + def check_budget( + self, client_id: str, *, now: float | None = None, + ) -> BudgetBlock | None: + """Return a :class:`BudgetBlock` if ``client_id`` is over either + cap, or ``None`` if the request may proceed. + + Called before handing a turn to the chat engine. We err on the + side of letting the turn through when both caps are 0 (disabled) + so that users can run an unmetered setup if they choose. + """ + if not self._budget.has_any_limit: + return None + snap = self.snapshot(client_id, now=now) + if snap.limit_5h_usd > 0 and snap.spent_5h_usd >= snap.limit_5h_usd: + return BudgetBlock( + window="5h", + spent_usd=snap.spent_5h_usd, + limit_usd=snap.limit_5h_usd, + reset_at=snap.session_5h_reset_at, + ) + if snap.limit_week_usd > 0 and snap.spent_week_usd >= snap.limit_week_usd: + # Rolling 7 days -> the reset moment isn't a single clock tick, + # so we leave ``reset_at`` unset; the UI formats this as + # "sur 7 jours glissants" rather than an absolute time. + return BudgetBlock( + window="week", + spent_usd=snap.spent_week_usd, + limit_usd=snap.limit_week_usd, + reset_at=None, + ) + return None diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index 13c2dda..0b28313 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -629,9 +629,9 @@ def test_migration_v1_to_v2_renames_gemini_models(tmp_path): msg = db.conn().execute("SELECT model FROM messages WHERE id='m1'").fetchone() assert msg["model"] == "gemini-3-flash-preview" - # user_version reflects the migration. + # user_version reflects the migration (latest schema version). ver = db.conn().execute("PRAGMA user_version").fetchone()[0] - assert ver == 2 + assert ver == 3 def test_short_ciphertext_decryption_returns_none(store): diff --git a/tests/test_dashboard_usage.py b/tests/test_dashboard_usage.py new file mode 100644 index 0000000..bd9f6e8 --- /dev/null +++ b/tests/test_dashboard_usage.py @@ -0,0 +1,412 @@ +"""Unit + integration tests for per-client usage tracking and budget caps.""" + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from tarkamcp.dashboard.app import DashboardDeps, build_dashboard_routes +from tarkamcp.dashboard.chat import ( + FakeChatEngine, + FakeScript, + TextDelta, + UsageAccumulated, +) +from tarkamcp.dashboard.confirmations import ConfirmationStore +from tarkamcp.dashboard.conversations import ConversationStore +from tarkamcp.dashboard.csrf import CSRF_COOKIE +from tarkamcp.dashboard.db import Database +from tarkamcp.dashboard.session import SessionStore +from tarkamcp.dashboard.usage import Budget, UsageMeter, UsageStore + + +# --------------------------------------------------------------------------- +# Helpers copied from test_dashboard_chat (kept local so this file is +# self-contained and can run even if that one is skipped). +# --------------------------------------------------------------------------- + + +class FakeClientStore: + def verify(self, cid, sec): return cid == "c" and sec == "s" + def verify_totp(self, cid, code): return code == "123456" + def get_name(self, cid): return "Test" + + +class FakeTokenStore: + NAMED_TOKEN_CAP = 3 + + def __init__(self): + self._live: dict[str, str] = {} + + def issue(self, cid, *, name=None): + token = f"b_{len(self._live) + 1}" + ("x" * 60) + self._live[token] = cid + return token, 24 * 3600 + + def validate(self, token): + return self._live.get(token) + + def revoke(self, token): + self._live.pop(token, None) + return True + + +def _login(client) -> str: + r = client.get("/app/login") + csrf = r.cookies.get(CSRF_COOKIE) + assert csrf is not None + r = client.post("/app/login", data={ + "csrf_token": csrf, "client_id": "c", + "client_secret": "s", "totp": "123456", "remember": "on", + }) + assert r.status_code == 303 + out = client.cookies.get(CSRF_COOKIE) + assert out is not None + return out + + +# --------------------------------------------------------------------------- +# Unit: UsageMeter +# --------------------------------------------------------------------------- + + +def test_cost_flash_no_cache(): + # 1M prompt + 1M output on 2.5-flash = $0.30 + $2.50 = $2.80. + cost = UsageMeter.cost_usd( + "gemini-2.5-flash", + prompt_tokens=1_000_000, cached_tokens=0, output_tokens=1_000_000, + ) + assert cost == pytest.approx(2.80) + + +def test_cost_flash_with_cache_hit(): + # Half the input comes from cache: 500k * $0.30 + 500k * $0.03 + 0 out. + cost = UsageMeter.cost_usd( + "gemini-2.5-flash", + prompt_tokens=1_000_000, cached_tokens=500_000, output_tokens=0, + ) + assert cost == pytest.approx(0.30 * 0.5 + 0.03 * 0.5) + + +def test_cost_pro_high_tier(): + # Pro model crosses the 200k threshold -> high-tier pricing applies. + cost = UsageMeter.cost_usd( + "gemini-2.5-pro", + prompt_tokens=300_000, cached_tokens=0, output_tokens=0, + ) + # 300k * $2.50 / 1M = $0.75 + assert cost == pytest.approx(0.75) + + +def test_cost_unknown_model_falls_back_to_flash(): + cost = UsageMeter.cost_usd( + "gemini-unknown-9", + prompt_tokens=1_000_000, cached_tokens=0, output_tokens=0, + ) + # Same rate as gemini-2.5-flash input. + assert cost == pytest.approx(0.30) + + +def test_cost_cached_over_prompt_is_clamped(): + # Defensive: if Gemini ever reports more cached tokens than prompt + # tokens, the billable_input floor is 0 (not negative). + cost = UsageMeter.cost_usd( + "gemini-2.5-flash", + prompt_tokens=100, cached_tokens=200, output_tokens=0, + ) + # Billable input is 0, cached is 200 at cached rate. + assert cost == pytest.approx(200 * 0.03 / 1_000_000) + + +# --------------------------------------------------------------------------- +# Unit: UsageStore (5h session + rolling week) +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def store(tmp_path): + db = Database(tmp_path / "usage.db") + return UsageStore(db, Budget(limit_5h_usd=1.0, limit_week_usd=5.0)) + + +def test_record_turn_opens_session(store): + now = 1000.0 + store.record_turn( + client_id="c", conversation_id="x", message_id="m1", + model="gemini-2.5-flash", + prompt_tokens=1000, cached_tokens=0, output_tokens=500, + cost_usd=0.10, now=now, + ) + snap = store.snapshot("c", now=now) + assert snap.spent_5h_usd == pytest.approx(0.10) + assert snap.session_5h_started_at == now + assert snap.session_5h_reset_at == now + 5 * 3600 + assert snap.spent_week_usd == pytest.approx(0.10) + + +def test_five_hour_window_resets_after_expiry(store): + t0 = 1000.0 + store.record_turn( + client_id="c", conversation_id="x", message_id="m1", + model="gemini-2.5-flash", + prompt_tokens=1000, cached_tokens=0, output_tokens=500, + cost_usd=0.60, now=t0, + ) + # Just inside 5h: still part of the same session. + store.record_turn( + client_id="c", conversation_id="x", message_id="m2", + model="gemini-2.5-flash", + prompt_tokens=500, cached_tokens=0, output_tokens=500, + cost_usd=0.10, now=t0 + 1000, + ) + snap_mid = store.snapshot("c", now=t0 + 1000) + assert snap_mid.spent_5h_usd == pytest.approx(0.70) + + # Past 5h: next turn opens a fresh session at its own ts. + t1 = t0 + 6 * 3600 + store.record_turn( + client_id="c", conversation_id="x", message_id="m3", + model="gemini-2.5-flash", + prompt_tokens=1000, cached_tokens=0, output_tokens=500, + cost_usd=0.05, now=t1, + ) + snap_new = store.snapshot("c", now=t1) + assert snap_new.spent_5h_usd == pytest.approx(0.05) + assert snap_new.session_5h_started_at == t1 + + +def test_snapshot_with_expired_session_shows_zero(store): + t0 = 1000.0 + store.record_turn( + client_id="c", conversation_id="x", message_id="m1", + model="gemini-2.5-flash", + prompt_tokens=1000, cached_tokens=0, output_tokens=500, + cost_usd=0.40, now=t0, + ) + # Look up the snapshot after the 5h window has already lapsed but + # WITHOUT a new turn in between -- should report zero and no active + # session, so the UI footer stops showing stale data. + snap = store.snapshot("c", now=t0 + 6 * 3600) + assert snap.spent_5h_usd == 0.0 + assert snap.session_5h_started_at is None + + +def test_check_budget_blocks_when_five_hour_exceeded(store): + t0 = 1000.0 + # Spend exactly the cap in one go. + store.record_turn( + client_id="c", conversation_id="x", message_id="m1", + model="gemini-2.5-flash", + prompt_tokens=1000, cached_tokens=0, output_tokens=500, + cost_usd=1.0, now=t0, + ) + block = store.check_budget("c", now=t0 + 60) + assert block is not None + assert block.window == "5h" + assert block.limit_usd == 1.0 + assert block.reset_at == t0 + 5 * 3600 + + +def test_check_budget_blocks_when_weekly_exceeded(tmp_path): + db = Database(tmp_path / "usage.db") + # 5h cap disabled so we test the weekly path in isolation. + s = UsageStore(db, Budget(limit_5h_usd=0.0, limit_week_usd=1.0)) + t0 = 1_000_000.0 + # Accumulate enough cost across two turns spread out in time. + s.record_turn( + client_id="c", conversation_id="x", message_id="m1", + model="gemini-2.5-flash", + prompt_tokens=1, cached_tokens=0, output_tokens=1, + cost_usd=0.60, now=t0, + ) + s.record_turn( + client_id="c", conversation_id="x", message_id="m2", + model="gemini-2.5-flash", + prompt_tokens=1, cached_tokens=0, output_tokens=1, + cost_usd=0.50, now=t0 + 3 * 24 * 3600, + ) + block = s.check_budget("c", now=t0 + 4 * 24 * 3600) + assert block is not None + assert block.window == "week" + + +def test_check_budget_allows_when_disabled(tmp_path): + db = Database(tmp_path / "usage.db") + s = UsageStore(db, Budget(limit_5h_usd=0.0, limit_week_usd=0.0)) + # Huge spend but no limit configured -> always allow. + s.record_turn( + client_id="c", conversation_id="x", message_id="m1", + model="gemini-2.5-flash", + prompt_tokens=1, cached_tokens=0, output_tokens=1, + cost_usd=999.0, now=time.time(), + ) + assert s.check_budget("c") is None + + +def test_weekly_window_is_rolling(tmp_path): + """Events older than 7 days must fall out of the weekly sum.""" + db = Database(tmp_path / "usage.db") + s = UsageStore(db, Budget(limit_5h_usd=0.0, limit_week_usd=10.0)) + t_old = 1000.0 + s.record_turn( + client_id="c", conversation_id="x", message_id="m1", + model="gemini-2.5-flash", + prompt_tokens=1, cached_tokens=0, output_tokens=1, + cost_usd=5.0, now=t_old, + ) + # >7 days later: old event drops out, new one is the only contributor. + t_now = t_old + 8 * 24 * 3600 + s.record_turn( + client_id="c", conversation_id="x", message_id="m2", + model="gemini-2.5-flash", + prompt_tokens=1, cached_tokens=0, output_tokens=1, + cost_usd=1.0, now=t_now, + ) + snap = s.snapshot("c", now=t_now) + assert snap.spent_week_usd == pytest.approx(1.0) + + +# --------------------------------------------------------------------------- +# Integration: chat stream enforces caps + persists cost +# --------------------------------------------------------------------------- + + +def _build_deps(tmp_path, *, engine, budget): + db = Database(tmp_path / "dashboard.db") + return DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + conversations=ConversationStore(db), + engine=engine, + confirmations=ConfirmationStore(), + usage=UsageStore(db, budget), + ) + + +def test_stream_records_cost_after_turn(tmp_path): + # Engine script: yields a text delta + a usage event reporting + # 100k prompt (0 cached) + 50k output on flash => 100000*0.30/1M + + # 50000*2.50/1M = 0.03 + 0.125 = 0.155. + engine = FakeChatEngine(FakeScript(events=[ + TextDelta(text="hello"), + UsageAccumulated( + model="gemini-2.5-flash", + prompt_tokens=100_000, + cached_tokens=0, + output_tokens=50_000, + ), + ], title_text="t")) + + deps = _build_deps( + tmp_path, engine=engine, + budget=Budget(limit_5h_usd=1.0, limit_week_usd=5.0), + ) + app = Starlette(routes=build_dashboard_routes(deps)) + client = TestClient(app, follow_redirects=False) + csrf = _login(client) + + # Create conversation. + r = client.post( + "/app/api/conversations", + json={"model": "gemini-2.5-flash", "effort": "low"}, + headers={"X-CSRF-Token": csrf}, + ) + conv_id = r.json()["conversation"]["id"] + + # Stream a turn. + r = client.post( + "/app/api/chat/stream", + json={"conversation_id": conv_id, "content": "hi"}, + headers={"X-CSRF-Token": csrf}, + ) + assert r.status_code == 200 + body = r.text + assert "event: usage_update" in body + + # Snapshot API should reflect the recorded cost. + r = client.get("/app/api/usage") + assert r.status_code == 200 + usage = r.json()["usage"] + expected_cost = 100_000 * 0.30 / 1_000_000 + 50_000 * 2.50 / 1_000_000 + assert usage["spent_5h_usd"] == pytest.approx(expected_cost, rel=1e-6) + assert usage["spent_week_usd"] == pytest.approx(expected_cost, rel=1e-6) + assert usage["limit_5h_usd"] == 1.0 + assert usage["limit_week_usd"] == 5.0 + + +def test_stream_rejects_when_over_5h_cap(tmp_path): + engine = FakeChatEngine(FakeScript(events=[TextDelta(text="unused")])) + deps = _build_deps( + tmp_path, engine=engine, + budget=Budget(limit_5h_usd=0.01, limit_week_usd=5.0), + ) + # Pre-load a spend that already exceeds the 5h cap. + assert deps.usage is not None + deps.usage.record_turn( + client_id="c", conversation_id=None, message_id=None, + model="gemini-2.5-flash", + prompt_tokens=0, cached_tokens=0, output_tokens=0, + cost_usd=0.02, + ) + + app = Starlette(routes=build_dashboard_routes(deps)) + client = TestClient(app, follow_redirects=False) + csrf = _login(client) + + r = client.post( + "/app/api/conversations", + json={"model": "gemini-2.5-flash", "effort": "low"}, + headers={"X-CSRF-Token": csrf}, + ) + conv_id = r.json()["conversation"]["id"] + + r = client.post( + "/app/api/chat/stream", + json={"conversation_id": conv_id, "content": "hi"}, + headers={"X-CSRF-Token": csrf}, + ) + assert r.status_code == 200 + body = r.text + assert '"code": "quota_exceeded"' in body + # Engine must not have been called -- the cap is enforced before + # we reach google-genai. + assert engine.calls == [] + + +def test_usage_endpoint_returns_disabled_snapshot_when_store_missing(tmp_path): + db = Database(tmp_path / "dashboard.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + conversations=ConversationStore(db), + engine=FakeChatEngine(FakeScript()), + confirmations=ConfirmationStore(), + usage=None, # explicitly disabled + ) + app = Starlette(routes=build_dashboard_routes(deps)) + client = TestClient(app, follow_redirects=False) + _login(client) + + r = client.get("/app/api/usage") + assert r.status_code == 200 + u = r.json()["usage"] + assert u["limit_5h_usd"] == 0.0 + assert u["limit_week_usd"] == 0.0 From dc518baa24ba95b7c598a9efa4d259fe580fb2a6 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 02:58:48 +0200 Subject: [PATCH 043/155] Turn usage footer into a clickable Claude-style modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer bar now shows only percentages ("5H 36% · 7J 10%") for a calmer glance, and clicking it opens a details modal with a progress bar per window, the 5h session reset time, a rolling-7d label, and a refresh button. Close via backdrop, x, or Escape. Co-Authored-By: Claude Opus 4.7 --- README.md | 2 +- src/tarkamcp/dashboard/static/app.css | 155 ++++++++++++++++++++- src/tarkamcp/dashboard/static/chat.js | 123 ++++++++++++---- src/tarkamcp/dashboard/templates/chat.html | 50 ++++++- 4 files changed, 297 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 1338ee4..5d94466 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ Deux fenêtres sont appliquées **par client OAuth** : Poser la variable à `0` désactive la fenêtre correspondante. Au dépassement, la prochaine requête est rejetée **avant** d'être envoyée à Gemini, avec un message précisant l'heure de reset pour la fenêtre 5h. -Le panel chat affiche en footer une ligne discrète `5h $X.XX / $Y.YY · reset HHhMM · 7j $X.XX / $Y.YY`, mise à jour après chaque tour via SSE `usage_update`. +Le panel chat affiche en footer une ligne discrète `5H XX% · 7J XX%` (pourcentage consommé par fenêtre), mise à jour après chaque tour via SSE `usage_update`. Cliquer la barre ouvre un modal style Claude avec barres de progression, heure de réinitialisation de la session 5h, label « fenêtre glissante 7j » et bouton « Actualiser ». Tarifs utilisés (USD / 1M tokens, alignés sur le tarif public Google AI Studio au 2026-04-17) : diff --git a/src/tarkamcp/dashboard/static/app.css b/src/tarkamcp/dashboard/static/app.css index 0ff4d24..1c44625 100644 --- a/src/tarkamcp/dashboard/static/app.css +++ b/src/tarkamcp/dashboard/static/app.css @@ -307,6 +307,7 @@ button.primary.icon-btn { .sidebar-cta:hover { background: color-mix(in oklab, var(--accent) 16%, var(--bg-elevated)); border-color: var(--accent); + text-decoration: none; } .sidebar-cta:active { transform: translateY(1px); } @@ -848,7 +849,7 @@ a.sidebar-link { cursor: pointer; } display: flex; justify-content: center; align-items: center; - gap: 0.4rem; + gap: 0.5rem; max-width: 48rem; margin: 0 auto; width: 100%; @@ -856,6 +857,20 @@ a.sidebar-link { cursor: pointer; } color: var(--fg-faint); font-variant-numeric: tabular-nums; letter-spacing: 0.01em; + background: transparent; + border: 0; + padding: 0.25rem 0.5rem; + border-radius: 6px; + cursor: pointer; + transition: background 150ms ease, color 150ms ease; + font-family: inherit; +} + +.usage-bar:hover, +.usage-bar:focus-visible { + background: color-mix(in oklab, var(--fg-faint) 10%, transparent); + color: var(--fg-muted); + outline: none; } .usage-bar .usage-win { @@ -875,10 +890,6 @@ a.sidebar-link { cursor: pointer; } color: var(--fg-muted); } -.usage-bar .usage-reset { - color: var(--fg-faint); -} - .usage-bar .usage-sep { color: var(--fg-faint); } @@ -888,6 +899,140 @@ a.sidebar-link { cursor: pointer; } font-weight: 600; } +/* Usage modal (click the footer bar to open) */ + +.usage-modal-backdrop { + z-index: 40; +} + +.usage-modal { + position: fixed; + z-index: 41; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(520px, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + overflow-y: auto; + background: var(--bg-elevated); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1.25rem 1.25rem 1rem; + box-shadow: 0 20px 60px color-mix(in oklab, black 60%, transparent); + animation: fade-in 150ms ease both; +} + +.usage-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.usage-modal-header h2 { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.usage-modal-close { + background: transparent; + border: 0; + color: var(--fg-muted); + padding: 0.25rem; + border-radius: 6px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.usage-modal-close:hover { color: var(--fg); background: color-mix(in oklab, var(--fg-faint) 12%, transparent); } + +.usage-section { + padding: 0.75rem 0; + border-top: 1px solid var(--border); +} +.usage-section:first-of-type { border-top: 0; padding-top: 0; } + +.usage-section-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.5rem; +} + +.usage-section-head h3 { + margin: 0; + font-size: 0.92rem; + font-weight: 600; +} + +.usage-percent { + font-size: 0.82rem; + color: var(--fg-muted); + font-variant-numeric: tabular-nums; +} + +.usage-reset-label { + margin: 0.25rem 0 0.6rem; + font-size: 0.78rem; + color: var(--fg-faint); +} + +.usage-progress { + width: 100%; + height: 8px; + background: color-mix(in oklab, var(--fg-faint) 18%, transparent); + border-radius: 999px; + overflow: hidden; +} + +.usage-progress-fill { + height: 100%; + width: 0%; + background: var(--accent); + border-radius: 999px; + transition: width 200ms ease; +} + +.usage-section.usage-over .usage-progress-fill { background: var(--danger); } +.usage-section.usage-over .usage-percent { color: var(--danger); font-weight: 600; } + +.usage-modal-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.usage-modal-updated { + font-size: 0.75rem; + color: var(--fg-faint); +} + +.usage-modal-refresh { + display: inline-flex; + align-items: center; + gap: 0.3rem; + background: transparent; + border: 1px solid var(--border); + color: var(--fg-muted); + font-size: 0.75rem; + padding: 0.3rem 0.6rem; + border-radius: 6px; + cursor: pointer; + font-family: inherit; +} +.usage-modal-refresh:hover { color: var(--fg); border-color: var(--fg-muted); } +.usage-modal-refresh.spinning svg { animation: spin 800ms linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + /* Backdrop */ .backdrop { diff --git a/src/tarkamcp/dashboard/static/chat.js b/src/tarkamcp/dashboard/static/chat.js index 686d5a1..0c0bb38 100644 --- a/src/tarkamcp/dashboard/static/chat.js +++ b/src/tarkamcp/dashboard/static/chat.js @@ -247,8 +247,17 @@ const el = { selEffort: document.getElementById("select-effort"), usageBar: document.getElementById("usage-bar"), usage5hValue: document.getElementById("usage-5h-value"), - usage5hReset: document.getElementById("usage-5h-reset"), usageWeekValue: document.getElementById("usage-week-value"), + usageModal: document.getElementById("usage-modal"), + usageModalBackdrop: document.getElementById("usage-modal-backdrop"), + usageModalClose: document.querySelector(".usage-modal-close"), + usageModal5hPct: document.getElementById("usage-modal-5h-pct"), + usageModal5hReset: document.getElementById("usage-modal-5h-reset"), + usageModal5hFill: document.getElementById("usage-modal-5h-fill"), + usageModalWeekPct: document.getElementById("usage-modal-week-pct"), + usageModalWeekFill: document.getElementById("usage-modal-week-fill"), + usageModalUpdated: document.getElementById("usage-modal-updated"), + usageModalRefresh: document.getElementById("usage-modal-refresh"), }; // ---------------- Sidebar ---------------- @@ -498,26 +507,50 @@ function scrollToBottom() { el.messages.scrollTop = el.messages.scrollHeight; } -// ---------------- Usage footer ---------------- +// ---------------- Usage footer + modal ---------------- -// Formats a float dollar amount with 2 fractional digits for the footer. -// Values above $10 drop to 1 decimal for compactness. -function formatUsd(n) { - const abs = Math.abs(n); - if (abs >= 10) return `$${n.toFixed(1)}`; - return `$${n.toFixed(2)}`; +const state_usage = { last: null, lastLoadedAt: 0 }; + +function pctOf(spent, limit) { + if (!limit || limit <= 0) return 0; + return Math.max(0, (spent / limit) * 100); +} + +function formatPct(spent, limit) { + if (!limit || limit <= 0) return "—"; + const p = pctOf(spent, limit); + // Show 1 decimal under 10%, integer above for readability. + if (p < 10) return `${p.toFixed(1)}%`; + return `${Math.round(p)}%`; } -// Render HH:MM for an absolute epoch-seconds timestamp in local time. -function formatLocalTime(epochSecs) { - const d = new Date(epochSecs * 1000); - const hh = String(d.getHours()).padStart(2, "0"); - const mm = String(d.getMinutes()).padStart(2, "0"); - return `${hh}h${mm}`; +function formatResetIn(resetEpoch) { + if (!resetEpoch) return "Pas de session active"; + const now = Date.now() / 1000; + const remaining = Math.max(0, resetEpoch - now); + if (remaining <= 0) return "Réinitialise à la prochaine requête"; + const hours = Math.floor(remaining / 3600); + const mins = Math.floor((remaining % 3600) / 60); + if (hours === 0) return `Réinitialise dans ${mins} min`; + return `Réinitialise dans ${hours} h ${String(mins).padStart(2, "0")}`; +} + +function formatAgo(loadedAtMs) { + if (!loadedAtMs) return "—"; + const diff = Math.max(0, Date.now() - loadedAtMs); + const secs = Math.floor(diff / 1000); + if (secs < 60) return "il y a moins d'une minute"; + const mins = Math.floor(secs / 60); + if (mins < 60) return `il y a ${mins} min`; + const hours = Math.floor(mins / 60); + return `il y a ${hours} h`; } function renderUsage(u) { if (!u) return; + state_usage.last = u; + state_usage.lastLoadedAt = Date.now(); + const hasAnyCap = (u.limit_5h_usd > 0) || (u.limit_week_usd > 0); if (!hasAnyCap) { el.usageBar.hidden = true; @@ -525,25 +558,18 @@ function renderUsage(u) { } el.usageBar.hidden = false; + // Footer: compact percentages. if (u.limit_5h_usd > 0) { - el.usage5hValue.textContent = - `${formatUsd(u.spent_5h_usd)} / ${formatUsd(u.limit_5h_usd)}`; + el.usage5hValue.textContent = formatPct(u.spent_5h_usd, u.limit_5h_usd); el.usage5hValue.parentElement.classList.toggle( "usage-over", u.spent_5h_usd >= u.limit_5h_usd, ); - if (u.session_5h_reset_at) { - el.usage5hReset.textContent = `· reset ${formatLocalTime(u.session_5h_reset_at)}`; - } else { - el.usage5hReset.textContent = ""; - } el.usage5hValue.parentElement.hidden = false; } else { el.usage5hValue.parentElement.hidden = true; } - if (u.limit_week_usd > 0) { - el.usageWeekValue.textContent = - `${formatUsd(u.spent_week_usd)} / ${formatUsd(u.limit_week_usd)}`; + el.usageWeekValue.textContent = formatPct(u.spent_week_usd, u.limit_week_usd); el.usageWeekValue.parentElement.classList.toggle( "usage-over", u.spent_week_usd >= u.limit_week_usd, ); @@ -551,6 +577,43 @@ function renderUsage(u) { } else { el.usageWeekValue.parentElement.hidden = true; } + + // Modal: progress bars + reset labels. + renderUsageModal(u); +} + +function renderUsageModal(u) { + if (!u) return; + const p5 = pctOf(u.spent_5h_usd, u.limit_5h_usd); + el.usageModal5hPct.textContent = formatPct(u.spent_5h_usd, u.limit_5h_usd); + el.usageModal5hFill.style.width = `${Math.min(100, p5)}%`; + el.usageModal5hReset.textContent = formatResetIn(u.session_5h_reset_at); + el.usageModal5hFill.parentElement.parentElement.classList.toggle( + "usage-over", u.limit_5h_usd > 0 && u.spent_5h_usd >= u.limit_5h_usd, + ); + + const pw = pctOf(u.spent_week_usd, u.limit_week_usd); + el.usageModalWeekPct.textContent = formatPct(u.spent_week_usd, u.limit_week_usd); + el.usageModalWeekFill.style.width = `${Math.min(100, pw)}%`; + el.usageModalWeekFill.parentElement.parentElement.classList.toggle( + "usage-over", u.limit_week_usd > 0 && u.spent_week_usd >= u.limit_week_usd, + ); + + el.usageModalUpdated.textContent = + `Dernière mise à jour : ${formatAgo(state_usage.lastLoadedAt)}`; +} + +function openUsageModal() { + if (!state_usage.last) return; + renderUsageModal(state_usage.last); + el.usageModalBackdrop.hidden = false; + el.usageModal.hidden = false; + el.usageModal.focus(); +} + +function closeUsageModal() { + el.usageModalBackdrop.hidden = true; + el.usageModal.hidden = true; } async function loadUsage() { @@ -563,6 +626,18 @@ async function loadUsage() { } } +el.usageBar?.addEventListener("click", openUsageModal); +el.usageModalClose?.addEventListener("click", closeUsageModal); +el.usageModalBackdrop?.addEventListener("click", closeUsageModal); +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !el.usageModal.hidden) closeUsageModal(); +}); +el.usageModalRefresh?.addEventListener("click", async () => { + el.usageModalRefresh.classList.add("spinning"); + try { await loadUsage(); } + finally { el.usageModalRefresh.classList.remove("spinning"); } +}); + // ---------------- Conversation API ---------------- async function loadConversations() { diff --git a/src/tarkamcp/dashboard/templates/chat.html b/src/tarkamcp/dashboard/templates/chat.html index 4150f43..a032607 100644 --- a/src/tarkamcp/dashboard/templates/chat.html +++ b/src/tarkamcp/dashboard/templates/chat.html @@ -115,22 +115,66 @@

Nouveau chat

- + + + + From 0a7962b77e5d9b66b1a46ee7cf7dbdaed02b855f Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 03:11:54 +0200 Subject: [PATCH 044/155] Split dashboard docs out + run tokens page without a Gemini key The README was drowning in dashboard detail, so the whole "Dashboard web" section moves to docs/dashboard.md; the README now carries a two-line pointer. The dashboard itself stops requiring GEMINI_API_KEY. is_enabled() now only checks TARKAMCP_DASHBOARD_ENABLED; a new has_chat() helper gates the chat panel specifically. When there is no Gemini key, /app/chat and post-login redirects fall through to /app/tokens so users can still mint bearers for external MCP clients (Gemini web, ChatGPT, Claude Desktop). The "Retour au chat" link on the tokens page is hidden in that mode, and the boot log now prints "chat: enabled|disabled, tokens only" to make the state obvious. Covered by three new integration tests in tokens-only mode (engine=None). Co-Authored-By: Claude Opus 4.7 --- README.md | 124 +---------------- docs/dashboard.md | 134 +++++++++++++++++++ src/tarkamcp/__main__.py | 5 +- src/tarkamcp/dashboard/__init__.py | 27 ++-- src/tarkamcp/dashboard/app.py | 29 ++-- src/tarkamcp/dashboard/templates/tokens.html | 2 + tests/test_dashboard_integration.py | 97 ++++++++++++++ 7 files changed, 276 insertions(+), 142 deletions(-) create mode 100644 docs/dashboard.md diff --git a/README.md b/README.md index 1338ee4..a4b4e45 100644 --- a/README.md +++ b/README.md @@ -227,130 +227,10 @@ response = client.models.generate_content( ## Dashboard web -Un panel web optionnel est servi par TarkaMCP sur la même URL (`https://mcp.example.com/app/...`). Trois pages : +Un panel web optionnel est servi par TarkaMCP sur la même URL (`https://mcp.example.com/app/...`) — login TOTP, chat Gemini multi-conversations, et génération de tokens API pour brancher des clients MCP externes (Gemini web, ChatGPT, Claude Desktop…). -- **`/app/login`** — récupère un bearer MCP via Client ID + Secret + TOTP, session persistée 90 jours dans un cookie HttpOnly. Plus de curl sur téléphone. -- **`/app/chat`** — chat multi-conversations avec Gemini 2.5 Flash/Pro (stable) ou Gemini 3 Flash / 3.1 Pro (preview, allowlist Google requise). -- **`/app/tokens`** — génère des bearers nommés pour brancher TarkaMCP sur Gemini web, ChatGPT, Claude Desktop, etc. sans passer par curl. +Le chat nécessite `GEMINI_API_KEY` ; la page **Tokens API** fonctionne sans. Détails complets (modes, pages, flow, modèles, sécurité SSH, tarifs, limites d'usage, architecture) dans [docs/dashboard.md](docs/dashboard.md). -### Activation - -1. Ajouter une clé Gemini au `.env` : - ```env - GEMINI_API_KEY=... - ``` -2. La clé de chiffrement de session (`TARKAMCP_SESSION_KEY`) est auto-générée par `install.sh` au premier run. Si tu déploies à la main : - ```bash - echo "TARKAMCP_SESSION_KEY=$(openssl rand -base64 32)" >> /opt/tarkamcp/.env - ``` -3. Redémarrer : `systemctl restart tarkamcp`. - -Au démarrage, le serveur affiche : -``` -Dashboard: http://0.0.0.0:8420/app/login -``` -(ou `disabled` si `GEMINI_API_KEY` manque). - -### Flow - -1. Tu ouvres `https://mcp.example.com/` sur ton téléphone → redirige vers `/app/login`. -2. Tu tapes Client ID + Client Secret + code TOTP (une seule fois tous les 90 jours). -3. Tu arrives sur `/app/chat` avec l'historique de tes conversations. Une card "Tokens API" prominente dans la sidebar mène à `/app/tokens`. -4. Toutes les 24 h le bearer MCP expire — le dashboard redemande *juste* le code TOTP (client_id et secret stockés chiffrés côté serveur). - -### Page Tokens API - -Accessible via la card "Tokens API" dans la sidebar du chat, ou directement à `/app/tokens`. Conçue pour les utilisateurs qui branchent TarkaMCP sur un client MCP externe plutôt que d'utiliser le chat intégré. - -Elle affiche : -- L'URL MCP à coller dans le client externe (bouton Copier). -- Un formulaire de création qui **exige un nom** (max 60 caractères, ex. "Gemini Web", "ChatGPT macOS") + le code TOTP courant. -- Le token généré une **seule fois** dans une card orange avec bouton Copier — après rechargement il n'est plus affiché. -- La liste des tokens actifs : nom, préfixe 12 car, heures avant expiration, bouton Révoquer. - -Contraintes : - -| Paramètre | Valeur | -|-----------|--------| -| Expiration | **24 h** (hérité de `TokenStore.TOKEN_TTL`) | -| Cap par client | **3 tokens actifs** maximum | -| TOTP | Re-vérifié à chaque création | -| Révocation | Par préfixe (≥ 6 car), scoped au client propriétaire | -| Stockage | **En mémoire** — un `systemctl restart` invalide tous les tokens | - -### Chat : modèles & thinking - -- **Gemini 2.5 Flash / Pro** : dispos sur toute clé AI Studio. **Utilisés par défaut** (`gemini-2.5-flash`). -- **Gemini 3 Flash / 3.1 Pro (preview)** : gated par allowlist Google. Sans allowlist, le dashboard affiche un message clair indiquant de rebasculer sur 2.5. -- **Effort de thinking** : `minimal` / `low` / `medium` / `high` via dropdown. `gemini-2.5-pro` ne peut pas désactiver le thinking — le budget est automatiquement clampé à 128 tokens minimum. -- **Rendu markdown** : le client parse les headings (`#`–`######`), listes ordonnées/non-ordonnées, blockquotes, horizontal rules, code fences (avec `lang-*` class), inline code, bold/italic/strike et links HTTP(S). - -### Confirmation obligatoire pour SSH exec - -`ssh_exec_command` et `ssh_exec_command_async` ne s'exécutent **jamais** sans clic manuel depuis le chat. Quand Gemini demande à lancer une commande SSH : - -1. La tool-card apparaît en état "approbation requise" (badge orange, auto-ouverte pour voir les `args`). -2. Deux boutons : **Autoriser** / **Refuser**. -3. Tant que tu n'as pas cliqué, le turn Gemini reste bloqué côté serveur (timeout à 5 min). -4. Si tu refuses, Gemini reçoit un `FunctionResponse {"error": "user_rejected"}` et peut adapter sa réponse. - -L'allow-list est hardcodée dans `src/tarkamcp/dashboard/chat.py` (`_NEEDS_CONFIRMATION`). Pour l'étendre (par exemple aux outils `proxmox_exec_*`), ajoute les noms à ce set. - -### Données stockées - -SQLite à `/opt/tarkamcp/dashboard.db` (WAL). Cinq tables : -- `sessions` — cookie → client_id + client_secret chiffré AES-GCM + bearer courant. -- `conversations` — titre, modèle, effort, client propriétaire. -- `messages` — user/assistant, contenu, tool_calls JSON, thinking résumé. -- `usage_events` — ledger per-turn : client_id, tokens (prompt/cached/output), coût USD, horodatage. -- `usage_5h_sessions` — une ligne par client avec la session 5h courante (matérialisée pour éviter un `GROUP BY` à chaque pré-check). - -Tout est scopé par `client_id` ; tu peux avoir plusieurs sessions actives (téléphone + PC) pour un même client. - -### Limites d'usage & coût - -Chaque tour du chat calcule son coût USD à partir du `usage_metadata` renvoyé par Gemini (tokens input facturés au tarif cache-réduit quand `cachedContentTokenCount` est non-nul, ce que Gemini 2.5+ applique automatiquement via l'implicit caching dès que le prompt dépasse 1024 tokens pour Flash / 4096 pour Pro — zéro code à écrire côté client). - -Deux fenêtres sont appliquées **par client OAuth** : - -| Fenêtre | Semantique | Variable d'env | Défaut | -|---------|-----------|----------------|--------| -| **5h** | Session Anthropic-style : ouvre au 1er message après ≥5h d'inactivité, dure 5h pile, puis ferme | `TARKAMCP_DASHBOARD_LIMIT_5H_USD` | `2.0` | -| **Semaine** | Somme rolling sur les 7 derniers jours glissants | `TARKAMCP_DASHBOARD_LIMIT_WEEK_USD` | `10.0` | - -Poser la variable à `0` désactive la fenêtre correspondante. Au dépassement, la prochaine requête est rejetée **avant** d'être envoyée à Gemini, avec un message précisant l'heure de reset pour la fenêtre 5h. - -Le panel chat affiche en footer une ligne discrète `5h $X.XX / $Y.YY · reset HHhMM · 7j $X.XX / $Y.YY`, mise à jour après chaque tour via SSE `usage_update`. - -Tarifs utilisés (USD / 1M tokens, alignés sur le tarif public Google AI Studio au 2026-04-17) : - -| Modèle | Input | Cached | Output | -|--------|-------|--------|--------| -| `gemini-2.5-flash` | $0.30 | $0.03 | $2.50 | -| `gemini-2.5-pro` (≤200k / >200k) | $1.25 / $2.50 | $0.125 / $0.25 | $10.00 / $15.00 | -| `gemini-3-flash-preview` | $0.50 | $0.05 | $3.00 | -| `gemini-3.1-pro-preview` (≤200k / >200k) | $2.00 / $4.00 | $0.20 / $0.40 | $12.00 / $18.00 | - -Les constantes vivent dans `src/tarkamcp/dashboard/usage.py` — mettre à jour si Google ajuste ses prix. - -### Architecture MCP interne - -Le dashboard tient lui-même une session MCP (`streamablehttp_client` + `ClientSession`) vers le `/mcp` local (`http://127.0.0.1:8420/mcp`). Les outils sont convertis manuellement en `FunctionDeclaration` et la boucle `function_call` / `function_response` est orchestrée côté serveur (AFC SDK désactivé via `AutomaticFunctionCallingConfig(disable=True)`) pour contourner des bugs connus de google-genai sur Gemini 2.5 Pro avec MCP + streaming + thinking. - -> Un mode "remote" (McpServer backend-driven) existait mais a été désactivé : il produisait systématiquement des 500 INTERNAL à cause de la perte de l'header Authorization via Cloudflare Tunnel. Si `TARKAMCP_DASHBOARD_MCP_MODE=remote` est défini, le service affiche un warning au boot et chaque turn chat retourne un message d'erreur actionable. - -### Robustesse aux redémarrages - -Le `TokenStore` est en mémoire : après `systemctl restart tarkamcp`, les bearers sont invalidés alors que les sessions dashboard (SQLite) persistent. Le dashboard détecte cela via `TokenStore.validate()` sur chaque route sensible ; si le bearer n'existe plus côté MCP mais que la session est encore timestamp-valide, l'utilisateur est redirigé vers `/app/refresh` pour retaper son TOTP et émettre un nouveau bearer. - -Conséquence pour les tokens externes (`/app/tokens`) : un restart du service force toutes les intégrations Gemini web / ChatGPT / Claude Desktop à régénérer leur token. Si ça devient gênant, migrer le `TokenStore` vers SQLite (non fait actuellement). - -### Désactiver - -```env -TARKAMCP_DASHBOARD_ENABLED=false -``` -Ou retire simplement `GEMINI_API_KEY`. --- diff --git a/docs/dashboard.md b/docs/dashboard.md new file mode 100644 index 0000000..7e8fb25 --- /dev/null +++ b/docs/dashboard.md @@ -0,0 +1,134 @@ +# Dashboard web TarkaMCP + +Panel web optionnel servi par TarkaMCP sur la même URL que le MCP (`https://mcp.example.com/app/...`). Trois pages : + +- **`/app/login`** — récupère un bearer MCP via Client ID + Secret + TOTP, session persistée 90 jours dans un cookie HttpOnly. Plus de curl sur téléphone. +- **`/app/chat`** — chat multi-conversations avec Gemini 2.5 Flash/Pro (stable) ou Gemini 3 Flash / 3.1 Pro (preview, allowlist Google requise). **Nécessite `GEMINI_API_KEY`.** +- **`/app/tokens`** — génère des bearers nommés pour brancher TarkaMCP sur Gemini web, ChatGPT, Claude Desktop, etc. sans passer par curl. **Fonctionne sans `GEMINI_API_KEY`.** + +## Activation + +Le dashboard est actif par défaut dès qu'une `TARKAMCP_SESSION_KEY` est posée. Deux modes : + +| Mode | Condition | Pages actives | +|------|-----------|---------------| +| **Complet** | `GEMINI_API_KEY` défini | `/app/login`, `/app/chat`, `/app/tokens` | +| **Tokens only** | pas de `GEMINI_API_KEY` | `/app/login`, `/app/tokens` (le chat redirige vers tokens) | + +1. (optionnel) Ajouter une clé Gemini pour activer le chat intégré : + ```env + GEMINI_API_KEY=... + ``` +2. La clé de chiffrement de session (`TARKAMCP_SESSION_KEY`) est auto-générée par `install.sh` au premier run. En déploiement manuel : + ```bash + echo "TARKAMCP_SESSION_KEY=$(openssl rand -base64 32)" >> /opt/tarkamcp/.env + ``` +3. Redémarrer : `systemctl restart tarkamcp`. + +Au démarrage, le serveur affiche : +``` +Dashboard: http://0.0.0.0:8420/app/login (chat: enabled) +``` +…ou `(chat: disabled, tokens only)` si la clé Gemini manque, ou `disabled` si `TARKAMCP_DASHBOARD_ENABLED=false`. + +## Flow + +1. Tu ouvres `https://mcp.example.com/` sur ton téléphone → redirige vers `/app/login`. +2. Tu tapes Client ID + Client Secret + code TOTP (une seule fois tous les 90 jours). +3. Tu arrives sur `/app/chat` (ou directement sur `/app/tokens` en mode tokens only) avec l'historique de tes conversations. +4. Toutes les 24 h le bearer MCP expire — le dashboard redemande *juste* le code TOTP (client_id et secret stockés chiffrés côté serveur). + +## Page Tokens API + +Accessible via la card "Tokens API" dans la sidebar du chat, ou directement à `/app/tokens`. Conçue pour les utilisateurs qui branchent TarkaMCP sur un client MCP externe plutôt que d'utiliser le chat intégré. + +Elle affiche : +- L'URL MCP à coller dans le client externe (bouton Copier). +- Un formulaire de création qui **exige un nom** (max 60 caractères, ex. "Gemini Web", "ChatGPT macOS") + le code TOTP courant. +- Le token généré une **seule fois** dans une card orange avec bouton Copier — après rechargement il n'est plus affiché. +- La liste des tokens actifs : nom, préfixe 12 car, heures avant expiration, bouton Révoquer. + +Contraintes : + +| Paramètre | Valeur | +|-----------|--------| +| Expiration | **24 h** (hérité de `TokenStore.TOKEN_TTL`) | +| Cap par client | **3 tokens actifs** maximum | +| TOTP | Re-vérifié à chaque création | +| Révocation | Par préfixe (≥ 6 car), scoped au client propriétaire | +| Stockage | **En mémoire** — un `systemctl restart` invalide tous les tokens | + +## Chat : modèles & thinking + +- **Gemini 2.5 Flash / Pro** : dispos sur toute clé AI Studio. **Utilisés par défaut** (`gemini-2.5-flash`). +- **Gemini 3 Flash / 3.1 Pro (preview)** : gated par allowlist Google. Sans allowlist, le dashboard affiche un message clair indiquant de rebasculer sur 2.5. +- **Effort de thinking** : `minimal` / `low` / `medium` / `high` via dropdown. `gemini-2.5-pro` ne peut pas désactiver le thinking — le budget est automatiquement clampé à 128 tokens minimum. +- **Rendu markdown** : le client parse les headings (`#`–`######`), listes ordonnées/non-ordonnées, blockquotes, horizontal rules, code fences (avec `lang-*` class), inline code, bold/italic/strike et links HTTP(S). + +## Confirmation obligatoire pour SSH exec + +`ssh_exec_command` et `ssh_exec_command_async` ne s'exécutent **jamais** sans clic manuel depuis le chat. Quand Gemini demande à lancer une commande SSH : + +1. La tool-card apparaît en état "approbation requise" (badge orange, auto-ouverte pour voir les `args`). +2. Deux boutons : **Autoriser** / **Refuser**. +3. Tant que tu n'as pas cliqué, le turn Gemini reste bloqué côté serveur (timeout à 5 min). +4. Si tu refuses, Gemini reçoit un `FunctionResponse {"error": "user_rejected"}` et peut adapter sa réponse. + +L'allow-list est hardcodée dans `src/tarkamcp/dashboard/chat.py` (`_NEEDS_CONFIRMATION`). Pour l'étendre (par exemple aux outils `proxmox_exec_*`), ajoute les noms à ce set. + +## Données stockées + +SQLite à `/opt/tarkamcp/dashboard.db` (WAL). Cinq tables : +- `sessions` — cookie → client_id + client_secret chiffré AES-GCM + bearer courant. +- `conversations` — titre, modèle, effort, client propriétaire. +- `messages` — user/assistant, contenu, tool_calls JSON, thinking résumé. +- `usage_events` — ledger per-turn : client_id, tokens (prompt/cached/output), coût USD, horodatage. +- `usage_5h_sessions` — une ligne par client avec la session 5h courante (matérialisée pour éviter un `GROUP BY` à chaque pré-check). + +Tout est scopé par `client_id` ; tu peux avoir plusieurs sessions actives (téléphone + PC) pour un même client. + +## Limites d'usage & coût + +Chaque tour du chat calcule son coût USD à partir du `usage_metadata` renvoyé par Gemini (tokens input facturés au tarif cache-réduit quand `cachedContentTokenCount` est non-nul, ce que Gemini 2.5+ applique automatiquement via l'implicit caching dès que le prompt dépasse 1024 tokens pour Flash / 4096 pour Pro — zéro code à écrire côté client). + +Deux fenêtres sont appliquées **par client OAuth** : + +| Fenêtre | Semantique | Variable d'env | Défaut | +|---------|-----------|----------------|--------| +| **5h** | Session Anthropic-style : ouvre au 1er message après ≥5h d'inactivité, dure 5h pile, puis ferme | `TARKAMCP_DASHBOARD_LIMIT_5H_USD` | `2.0` | +| **Semaine** | Somme rolling sur les 7 derniers jours glissants | `TARKAMCP_DASHBOARD_LIMIT_WEEK_USD` | `10.0` | + +Poser la variable à `0` désactive la fenêtre correspondante. Au dépassement, la prochaine requête est rejetée **avant** d'être envoyée à Gemini, avec un message précisant l'heure de reset pour la fenêtre 5h. + +Le panel chat affiche en footer une ligne discrète `5H XX% · 7J XX%` (pourcentage consommé par fenêtre), mise à jour après chaque tour via SSE `usage_update`. Cliquer la barre ouvre un modal style Claude avec barres de progression, heure de réinitialisation de la session 5h, label « fenêtre glissante 7j » et bouton « Actualiser ». + +Tarifs utilisés (USD / 1M tokens, alignés sur le tarif public Google AI Studio au 2026-04-17) : + +| Modèle | Input | Cached | Output | +|--------|-------|--------|--------| +| `gemini-2.5-flash` | $0.30 | $0.03 | $2.50 | +| `gemini-2.5-pro` (≤200k / >200k) | $1.25 / $2.50 | $0.125 / $0.25 | $10.00 / $15.00 | +| `gemini-3-flash-preview` | $0.50 | $0.05 | $3.00 | +| `gemini-3.1-pro-preview` (≤200k / >200k) | $2.00 / $4.00 | $0.20 / $0.40 | $12.00 / $18.00 | + +Les constantes vivent dans `src/tarkamcp/dashboard/usage.py` — mettre à jour si Google ajuste ses prix. + +## Architecture MCP interne + +Le dashboard tient lui-même une session MCP (`streamablehttp_client` + `ClientSession`) vers le `/mcp` local (`http://127.0.0.1:8420/mcp`). Les outils sont convertis manuellement en `FunctionDeclaration` et la boucle `function_call` / `function_response` est orchestrée côté serveur (AFC SDK désactivé via `AutomaticFunctionCallingConfig(disable=True)`) pour contourner des bugs connus de google-genai sur Gemini 2.5 Pro avec MCP + streaming + thinking. + +> Un mode "remote" (McpServer backend-driven) existait mais a été désactivé : il produisait systématiquement des 500 INTERNAL à cause de la perte de l'header Authorization via Cloudflare Tunnel. Si `TARKAMCP_DASHBOARD_MCP_MODE=remote` est défini, le service affiche un warning au boot et chaque turn chat retourne un message d'erreur actionable. + +## Robustesse aux redémarrages + +Le `TokenStore` est en mémoire : après `systemctl restart tarkamcp`, les bearers sont invalidés alors que les sessions dashboard (SQLite) persistent. Le dashboard détecte cela via `TokenStore.validate()` sur chaque route sensible ; si le bearer n'existe plus côté MCP mais que la session est encore timestamp-valide, l'utilisateur est redirigé vers `/app/refresh` pour retaper son TOTP et émettre un nouveau bearer. + +Conséquence pour les tokens externes (`/app/tokens`) : un restart du service force toutes les intégrations Gemini web / ChatGPT / Claude Desktop à régénérer leur token. Si ça devient gênant, migrer le `TokenStore` vers SQLite (non fait actuellement). + +## Désactiver complètement + +```env +TARKAMCP_DASHBOARD_ENABLED=false +``` + +Pour garder les tokens mais couper le chat : ne mets simplement pas `GEMINI_API_KEY`. diff --git a/src/tarkamcp/__main__.py b/src/tarkamcp/__main__.py index f5427cf..79453ed 100644 --- a/src/tarkamcp/__main__.py +++ b/src/tarkamcp/__main__.py @@ -508,9 +508,10 @@ async def lifespan(_app): print(f"Token: http://{host}:{port}/oauth/token") print(f"Health: http://{host}:{port}/health") if dashboard_routes: - print(f"Dashboard: http://{host}:{port}/app/login") + chat_status = "enabled" if os.environ.get("GEMINI_API_KEY") else "disabled, tokens only" + print(f"Dashboard: http://{host}:{port}/app/login (chat: {chat_status})") else: - print("Dashboard: disabled (set GEMINI_API_KEY to enable)") + print("Dashboard: disabled (TARKAMCP_DASHBOARD_ENABLED=false)") if n_clients == 0: print(f"\nAucun client ! Créer avec : tarkamcp auth create --name 'Mon Client'") uvicorn.run(app, host=host, port=port, log_level="info") diff --git a/src/tarkamcp/dashboard/__init__.py b/src/tarkamcp/dashboard/__init__.py index bdf8a11..216e45b 100644 --- a/src/tarkamcp/dashboard/__init__.py +++ b/src/tarkamcp/dashboard/__init__.py @@ -1,8 +1,11 @@ -"""TarkaMCP web dashboard: login + Gemini chat panels. +"""TarkaMCP web dashboard: login + tokens + (optional) Gemini chat. The dashboard is mounted under ``/app/*`` on the same Starlette app as the -MCP endpoint. It is opt-in: the routes are only registered when -``GEMINI_API_KEY`` is set (see :func:`is_enabled`). +MCP endpoint. It is always-on unless explicitly disabled via +``TARKAMCP_DASHBOARD_ENABLED=false`` — the Tokens API page stays useful +for users who only want to wire external MCP clients (Gemini web, +ChatGPT, Claude Desktop). The integrated chat panel is gated by +``GEMINI_API_KEY`` on top of that (see :func:`has_chat`). """ from __future__ import annotations @@ -11,12 +14,20 @@ def is_enabled() -> bool: - """Return True if the dashboard should be mounted. + """Return True if the dashboard should be mounted at all. - Requires ``GEMINI_API_KEY`` set, and ``TARKAMCP_DASHBOARD_ENABLED`` not - set to ``false``. + Controlled by ``TARKAMCP_DASHBOARD_ENABLED`` (default on). The Gemini + key is NOT required here — without it, only ``/app/login`` and + ``/app/tokens`` are meaningful, and ``/app/chat`` redirects to + tokens. """ - if not os.environ.get("GEMINI_API_KEY"): - return False flag = os.environ.get("TARKAMCP_DASHBOARD_ENABLED", "true").strip().lower() return flag not in ("0", "false", "no", "off") + + +def has_chat() -> bool: + """Return True if the integrated Gemini chat panel should be served. + + Requires the dashboard to be enabled AND ``GEMINI_API_KEY`` set. + """ + return is_enabled() and bool(os.environ.get("GEMINI_API_KEY")) diff --git a/src/tarkamcp/dashboard/app.py b/src/tarkamcp/dashboard/app.py index dc29fbe..b0466c2 100644 --- a/src/tarkamcp/dashboard/app.py +++ b/src/tarkamcp/dashboard/app.py @@ -217,19 +217,23 @@ def _bearer_live(deps: DashboardDeps, session: Session) -> bool: def build_dashboard_routes(deps: DashboardDeps) -> list[Route | Mount]: """Return Starlette routes for the dashboard, ready to mount.""" + def _default_landing() -> str: + """Post-login destination: chat if Gemini is configured, else tokens.""" + return "/app/chat" if deps.engine is not None else "/app/tokens" + async def index(request: Request) -> Response: session = _load_session(request, deps) if session and _bearer_live(deps, session): - return RedirectResponse("/app/chat", status_code=302) + return RedirectResponse(_default_landing(), status_code=302) if session: return RedirectResponse("/app/refresh", status_code=302) return RedirectResponse("/app/login", status_code=302) async def login_get(request: Request) -> Response: - # If a valid session already exists, send straight to chat. + # If a valid session already exists, send to the default landing. session = _load_session(request, deps) if session and _bearer_live(deps, session): - return RedirectResponse("/app/chat", status_code=302) + return RedirectResponse(_default_landing(), status_code=302) if session: # Session valid but bearer stale -> refresh page is the right one. return RedirectResponse("/app/refresh", status_code=302) @@ -256,16 +260,16 @@ def _v(name: str) -> str: client_id = _v("client_id").strip() client_secret = _v("client_secret") totp = _v("totp").strip() - next_url = _v("next").strip() or "/app/chat" - if not (next_url.startswith("/app/") or next_url == "/app/chat"): - next_url = "/app/chat" + next_url = _v("next").strip() or _default_landing() + if not next_url.startswith("/app/"): + next_url = _default_landing() def _fail(message: str, *, status: int = 400, locked: bool = False) -> Response: return _render( "login.html", request, client_id=client_id, - next=next_url if next_url != "/app/chat" else "", + next=next_url if next_url != _default_landing() else "", banner=message, locked=locked, status_code=status, @@ -315,7 +319,7 @@ async def refresh_get(request: Request) -> Response: if not session: return RedirectResponse("/app/login", status_code=302) if _bearer_live(deps, session): - return RedirectResponse("/app/chat", status_code=302) + return RedirectResponse(_default_landing(), status_code=302) client_name = ( deps.client_store.get_name(session.client_id) # type: ignore[attr-defined] @@ -345,7 +349,7 @@ async def refresh_post(request: Request) -> Response: next_value = form.get("next", "") next_url = next_value.strip() if isinstance(next_value, str) else "" if not (next_url.startswith("/app/")): - next_url = "/app/chat" + next_url = _default_landing() client_name = ( deps.client_store.get_name(session.client_id) # type: ignore[attr-defined] @@ -358,7 +362,7 @@ def _fail(message: str, *, status: int = 400, locked: bool = False) -> Response: request, client_id=session.client_id, client_name=client_name, - next=next_url if next_url != "/app/chat" else "", + next=next_url if next_url != _default_landing() else "", banner=message, locked=locked, status_code=status, @@ -517,6 +521,10 @@ async def chat_get(request: Request) -> Response: return RedirectResponse("/app/login", status_code=302) if not _bearer_live(deps, session): return RedirectResponse("/app/refresh", status_code=302) + # No Gemini key configured: the chat panel has nothing to drive, + # but the Tokens page is still useful. Redirect there. + if deps.engine is None: + return RedirectResponse("/app/tokens", status_code=302) return _render( "chat.html", request, @@ -955,6 +963,7 @@ def _render_tokens_page( just_created=just_created, mcp_url=mcp_url, locked=deps.totp_locked(session.client_id), + chat_enabled=deps.engine is not None, ) diff --git a/src/tarkamcp/dashboard/templates/tokens.html b/src/tarkamcp/dashboard/templates/tokens.html index f8c8d38..f8afef7 100644 --- a/src/tarkamcp/dashboard/templates/tokens.html +++ b/src/tarkamcp/dashboard/templates/tokens.html @@ -4,10 +4,12 @@ {% block body %}
+ {% if chat_enabled %} Retour au chat + {% endif %}

Tokens API

diff --git a/tests/test_dashboard_integration.py b/tests/test_dashboard_integration.py index 5c72881..773e9b7 100644 --- a/tests/test_dashboard_integration.py +++ b/tests/test_dashboard_integration.py @@ -104,6 +104,9 @@ def totp_record_failure(cid): def totp_record_success(cid): failures.pop(cid, None) + # A sentinel non-None engine so the post-login landing stays /app/chat. + # These integration tests exercise chat-mode routing; the tokens-only + # mode (engine=None) is covered separately. return DashboardDeps( database=db, session_store=session_store, @@ -112,6 +115,7 @@ def totp_record_success(cid): totp_locked=totp_locked, totp_record_failure=totp_record_failure, totp_record_success=totp_record_success, + engine=object(), # type: ignore[arg-type] ) @@ -121,6 +125,38 @@ def client(deps): return TestClient(app, follow_redirects=False) +@pytest.fixture() +def tokens_only_client(tmp_path): + """A fixture mirroring ``deps``/``client`` but with engine=None. + + Exercises the tokens-only mode of the dashboard: no Gemini key set, + so ``/app/chat`` redirects to ``/app/tokens`` and post-login lands + there directly. + """ + db = Database(tmp_path / "dashboard-tokens-only.db") + session_store = SessionStore(db, key=os.urandom(32)) + failures: dict[str, tuple[int, float]] = {} + + deps_local = DashboardDeps( + database=db, + session_store=session_store, + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: ( + failures.get(cid, (0, 0.0))[0] >= 5 + and time.time() < failures.get(cid, (0, 0.0))[1] + ), + totp_record_failure=lambda cid: failures.__setitem__( + cid, + (failures.get(cid, (0, 0.0))[0] + 1, time.time() + 300), + ), + totp_record_success=lambda cid: (failures.pop(cid, None), None)[1], + engine=None, + ) + app = Starlette(routes=build_dashboard_routes(deps_local)) + return TestClient(app, follow_redirects=False) + + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -350,3 +386,64 @@ def test_security_headers_present(client): assert r.headers.get("X-Content-Type-Options") == "nosniff" assert "Referrer-Policy" in r.headers assert "Content-Security-Policy" in r.headers + + +# --------------------------------------------------------------------------- +# Tokens-only mode (engine=None): chat redirects to tokens +# --------------------------------------------------------------------------- + + +def test_tokens_only_login_lands_on_tokens(tokens_only_client): + r = tokens_only_client.get("/app/login") + token = r.cookies.get(CSRF_COOKIE) + assert token + r = tokens_only_client.post( + "/app/login", + data={ + "csrf_token": token, + "client_id": "tarkamcp_test", + "client_secret": "sk_test", + "totp": "123456", + "remember": "on", + }, + ) + assert r.status_code == 303 + assert r.headers["location"] == "/app/tokens" + + +def test_tokens_only_chat_redirects_to_tokens(tokens_only_client): + r = tokens_only_client.get("/app/login") + token = r.cookies.get(CSRF_COOKIE) + tokens_only_client.post( + "/app/login", + data={ + "csrf_token": token, + "client_id": "tarkamcp_test", + "client_secret": "sk_test", + "totp": "123456", + "remember": "on", + }, + ) + r = tokens_only_client.get("/app/chat") + assert r.status_code == 302 + assert r.headers["location"] == "/app/tokens" + + +def test_tokens_only_login_page_redirects_when_authenticated(tokens_only_client): + r = tokens_only_client.get("/app/login") + token = r.cookies.get(CSRF_COOKIE) + tokens_only_client.post( + "/app/login", + data={ + "csrf_token": token, + "client_id": "tarkamcp_test", + "client_secret": "sk_test", + "totp": "123456", + "remember": "on", + }, + ) + # Already authenticated: /app/login should bounce to the tokens page + # since there is no chat engine configured. + r = tokens_only_client.get("/app/login") + assert r.status_code == 302 + assert r.headers["location"] == "/app/tokens" From 96ef3a9a23ba91f25f2a797d2d90d3c01cbf7e8f Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 03:28:05 +0200 Subject: [PATCH 045/155] Require manual approval for proxmox_exec_* + trim README into docs/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proxmox_exec_command and proxmox_exec_command_async join ssh_exec_* in the chat's manual-approval allowlist: both can fire arbitrary shell on a host or VM through the QEMU Guest Agent, so a single confused turn could rm -rf a production box. proxmox_exec_get_result stays unconfirmed because it only reads. README also gets a new "Sécurité" section pushing the same principle for *external* MCP clients (Claude Desktop, Gemini CLI, ChatGPT) where the chat-side gate doesn't apply -- users need to keep the "approve each tool call" toggle on and never blanket-allow exec tools. The Tests and Dépannage sections move out of the README into docs/tests.md and docs/troubleshooting.md respectively; README keeps short pointers. The TOC is reshuffled so the security section sits right after the dashboard block, before any per-service config. Co-Authored-By: Claude Opus 4.7 --- README.md | 83 ++++++++++------------------------ docs/dashboard.md | 6 +-- docs/tests.md | 35 ++++++++++++++ docs/troubleshooting.md | 38 ++++++++++++++++ src/tarkamcp/dashboard/chat.py | 11 +++-- tests/test_dashboard_chat.py | 15 ++++++ 6 files changed, 122 insertions(+), 66 deletions(-) create mode 100644 docs/tests.md create mode 100644 docs/troubleshooting.md diff --git a/README.md b/README.md index a4b4e45..7e784da 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,15 @@ Compatible **Claude** (web, mobile) • **ChatGPT** • **Gemini** (CLI, A - [Architecture](#architecture) - [Installation](#installation) +- [Connexion par plateforme](#connexion-par-plateforme) +- [Dashboard web](#dashboard-web) +- [Sécurité : review manuelle des actions sensibles](#sécurité--review-manuelle-des-actions-sensibles) - [Configuration Proxmox](#configuration-proxmox) - [Configuration iLO](#configuration-ilo) - [Configuration .env](#configuration-env) -- [Connexion par plateforme](#connexion-par-plateforme) -- [Dashboard web](#dashboard-web) -- [Tests](#tests) +- [Tests](#tests) — détails dans [docs/tests.md](docs/tests.md) - [Outils disponibles](#outils-disponibles) -- [Dépannage](#dépannage) +- [Dépannage & exemples](#dépannage--exemples) — détails dans [docs/troubleshooting.md](docs/troubleshooting.md) --- @@ -231,6 +232,21 @@ Un panel web optionnel est servi par TarkaMCP sur la même URL (`https://mcp.exa Le chat nécessite `GEMINI_API_KEY` ; la page **Tokens API** fonctionne sans. Détails complets (modes, pages, flow, modèles, sécurité SSH, tarifs, limites d'usage, architecture) dans [docs/dashboard.md](docs/dashboard.md). +--- + +## Sécurité : review manuelle des actions sensibles + +> **Ne laisse jamais un LLM exécuter des commandes shell sur ton infra sans les avoir relues toi-même.** + +TarkaMCP expose des outils qui peuvent faire des dégâts irréversibles (`ssh_exec_command*`, `proxmox_exec_command*`, power off iLO, `vm_stop`, `vm_create`, etc.). Le LLM ne comprend pas toujours les conséquences d'une commande — un `rm -rf` "pour faire propre", un `systemctl stop` sur le mauvais service, un `pct destroy` au lieu de `pct stop`. Quelques règles : + +- **Désactive l'auto-approve** sur chaque client MCP externe (Claude Desktop, Gemini CLI, ChatGPT MCP, etc.). La plupart offrent un toggle "Approve each tool call" ou équivalent — garde-le **activé**, et refuse l'option "Always allow this tool". +- **Relis l'argument `command` avant d'autoriser** un appel `ssh_exec_command*` ou `proxmox_exec_command*`. Pose-toi la question : "si cette commande tournait sur la mauvaise VM / le mauvais host, est-ce que je pourrais récupérer ?" +- **Le chat intégré (`/app/chat`) force déjà une approbation humaine** pour `ssh_exec_command`, `ssh_exec_command_async`, `proxmox_exec_command` et `proxmox_exec_command_async` — même si tu cliques vite, lis au moins les `args` de la tool-card. Le timeout est à 5 min et l'absence de réponse vaut refus. +- **Préfère les outils lecture-seule** (`*_list_*`, `*_status`, `*_get_*`, `get_logs`, `health_status`…) pour l'exploration. Ils ne sont jamais bloqués par confirmation parce qu'ils ne peuvent rien casser. +- **Ne partage jamais** un bearer `/app/tokens` avec un client MCP qui n'est pas le tien. Un token compromis = shell arbitraire sur pve1/pve2 pendant 24 h. + +Un `systemctl restart tarkamcp` invalide tous les bearers en mémoire : en cas de doute sur un token qui fuite, c'est la corde de panique. --- @@ -334,28 +350,7 @@ Modules chargés conditionnellement : sans SSH → pas de `ssh_*`, sans iLO → ## Tests -```bash -# Tous les tests -python tests/test_integration.py - -# Par section -python tests/test_integration.py --section proxmox -python tests/test_integration.py --section ssh -python tests/test_integration.py --section ilo - -# Avec tests VM lifecycle (start/stop/clone) -python tests/test_integration.py --test-vmid 9999 -``` - -| Section | Tests | Description | -|---------|-------|-------------| -| Proxmox Monitoring | 12 | nodes, status, VMs, logs, tasks | -| Proxmox System | 4 | storage, network | -| Proxmox Exec | 6 | QEMU GA + LXC, sync/async | -| VM Lifecycle | 7 | start/stop/restart/config/clone | -| SSH | 8 | exec, host resolution, async | -| iLO | 6 | health, power, event log | -| Resources & Errors | 8 | config, prompts, error handling | +Tests unitaires (`pytest`) pour le dashboard + tests d'intégration (`python tests/test_integration.py`) qui tapent la vraie infra. Détails des sections, flags CLI et prérequis : [docs/tests.md](docs/tests.md). --- @@ -417,41 +412,9 @@ python tests/test_integration.py --test-vmid 9999 --- -## Dépannage - -| Erreur | Cause | Solution | -|--------|-------|----------| -| `PVE1_HOST ... required` | `.env` non chargé | Vérifier `/opt/tarkamcp/.env` | -| `Node 'pveX' is unreachable` | API Proxmox down | `curl -sk https://pve1:8006/api2/json/version` | -| `QEMU Guest Agent may not be running` | Agent non installé | Voir [Configuration Proxmox](#installer-le-qemu-guest-agent) | -| `iLO ... unreachable` | pve1 down ou iLO injoignable | Vérifier pve1 d'abord | -| `SSH connection failed` | Auth SSH désactivée | `grep PasswordAuthentication /etc/ssh/sshd_config` | -| `invalid_client` | Mauvais Client ID/Secret | `tarkamcp auth list` pour vérifier | -| `421 Misdirected Request` | Hostname public absent de l'allowlist | Ajouter le domaine à `TARKAMCP_ALLOWED_HOSTS` dans `.env` puis redémarrer | -| `{"error":"unauthorized"}` sur `/authorize` | Client ID inexistant côté serveur | Créer le client avec `tarkamcp auth create`, puis recoller l'ID dans le connecteur | -| `invalid_grant` + `missing or invalid totp` | Code 2FA faux, expiré (>30 s), ou déjà utilisé | Générer un nouveau code dans l'app. Vérifier l'horloge du serveur vs celle du téléphone (`timedatectl`). | -| Page 2FA affiche "Trop de tentatives" | 5 codes faux consécutifs → lockout 5 min | Attendre. Le compteur se réinitialise à la prochaine validation correcte. | -| Clients silencieusement révoqués après update | Migration 2FA : les anciens clients sans TOTP sont rejetés au démarrage | Regarder `journalctl -u tarkamcp` pour la liste, recréer via `tarkamcp auth create` | -| Dashboard boucle entre `/app/refresh` et `/app/chat` | Bearer wipe après redémarrage du service | Taper le code TOTP sur la page de refresh pour regénérer un bearer | -| Clients MCP externes déconnectés après un restart | `TokenStore` en mémoire, wipé au restart | Recréer les tokens dans `/app/tokens` (max 3, expire 24 h) | -| `event: error ... "code": "remote_mode_disabled"` dans le chat | `TARKAMCP_DASHBOARD_MCP_MODE=remote` dans `.env` | Retirer la ligne du `.env` et redémarrer — seul le mode local est supporté | -| Chat bloqué sur une card SSH avec deux boutons | Confirmation obligatoire pour `ssh_exec_command*` | Cliquer **Autoriser** ou **Refuser**. Timeout à 5 min sinon auto-reject | -| Tokens API : "Limite atteinte : maximum 3 tokens" | 3 tokens nommés déjà actifs pour ce client | Révoquer un token existant dans la liste avant d'en créer un nouveau | - ---- - -## Exemple d'utilisation - -> **"pve2 ne répond plus, qu'est-ce qui se passe ?"** -> -> L'IA va : `proxmox_list_nodes` → voit pve2 offline → `ilo_power_status` → vérifie si allumé → `ilo_health_status` → checker le hardware → proposer un diagnostic +## Dépannage & exemples -> **"Mets à jour les paquets sur tous les conteneurs"** -> -> L'API Proxmox n'expose pas d'endpoint `exec` pour les LXC. L'IA utilise -> donc `proxmox_list_vms` → liste les CTs → `ssh_exec_command_async` sur le -> nœud hôte avec `pct exec -- sh -c 'apt update && apt upgrade -y'` -> pour chacun → poll les résultats. +Tableau des erreurs courantes, causes et correctifs — plus quelques scénarios d'usage type — dans [docs/troubleshooting.md](docs/troubleshooting.md). --- diff --git a/docs/dashboard.md b/docs/dashboard.md index 7e8fb25..b5b3805 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -65,16 +65,16 @@ Contraintes : - **Effort de thinking** : `minimal` / `low` / `medium` / `high` via dropdown. `gemini-2.5-pro` ne peut pas désactiver le thinking — le budget est automatiquement clampé à 128 tokens minimum. - **Rendu markdown** : le client parse les headings (`#`–`######`), listes ordonnées/non-ordonnées, blockquotes, horizontal rules, code fences (avec `lang-*` class), inline code, bold/italic/strike et links HTTP(S). -## Confirmation obligatoire pour SSH exec +## Confirmation obligatoire pour les outils qui font du shell -`ssh_exec_command` et `ssh_exec_command_async` ne s'exécutent **jamais** sans clic manuel depuis le chat. Quand Gemini demande à lancer une commande SSH : +`ssh_exec_command`, `ssh_exec_command_async`, `proxmox_exec_command` et `proxmox_exec_command_async` ne s'exécutent **jamais** sans clic manuel depuis le chat. Quand Gemini demande à lancer une de ces commandes : 1. La tool-card apparaît en état "approbation requise" (badge orange, auto-ouverte pour voir les `args`). 2. Deux boutons : **Autoriser** / **Refuser**. 3. Tant que tu n'as pas cliqué, le turn Gemini reste bloqué côté serveur (timeout à 5 min). 4. Si tu refuses, Gemini reçoit un `FunctionResponse {"error": "user_rejected"}` et peut adapter sa réponse. -L'allow-list est hardcodée dans `src/tarkamcp/dashboard/chat.py` (`_NEEDS_CONFIRMATION`). Pour l'étendre (par exemple aux outils `proxmox_exec_*`), ajoute les noms à ce set. +L'allow-list est hardcodée dans `src/tarkamcp/dashboard/chat.py` (`_NEEDS_CONFIRMATION`). Seul le chat intégré applique cette confirmation — les clients MCP externes (Claude Desktop, Gemini CLI, ChatGPT MCP) doivent avoir leur propre mode "approuver chaque appel" activé côté client (voir la section **Sécurité** du README principal). ## Données stockées diff --git a/docs/tests.md b/docs/tests.md new file mode 100644 index 0000000..9f41294 --- /dev/null +++ b/docs/tests.md @@ -0,0 +1,35 @@ +# Tests + +Les tests unitaires et dashboard tournent sans infra réelle (SQLite in-memory + mocks Starlette) : + +```bash +pytest tests/test_dashboard_unit.py tests/test_dashboard_chat.py \ + tests/test_dashboard_usage.py tests/test_dashboard_integration.py +``` + +Les **tests d'intégration** ciblent la vraie infra Proxmox / iLO / SSH et se lancent via un script dédié : + +```bash +# Tous les tests +python tests/test_integration.py + +# Par section +python tests/test_integration.py --section proxmox +python tests/test_integration.py --section ssh +python tests/test_integration.py --section ilo + +# Avec tests VM lifecycle (start/stop/clone) +python tests/test_integration.py --test-vmid 9999 +``` + +| Section | Tests | Description | +|---------|-------|-------------| +| Proxmox Monitoring | 12 | nodes, status, VMs, logs, tasks | +| Proxmox System | 4 | storage, network | +| Proxmox Exec | 6 | QEMU GA + LXC, sync/async | +| VM Lifecycle | 7 | start/stop/restart/config/clone | +| SSH | 8 | exec, host resolution, async | +| iLO | 6 | health, power, event log | +| Resources & Errors | 8 | config, prompts, error handling | + +Ces tests exigent que `.env` soit renseigné avec des credentials valides et que l'infra cible soit joignable. Ils **ne tournent pas en CI** — c'est de la vérification locale avant un deploy sur pve1. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..daa3e50 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,38 @@ +# Dépannage + +## Erreurs courantes + +| Erreur | Cause | Solution | +|--------|-------|----------| +| `PVE1_HOST ... required` | `.env` non chargé | Vérifier `/opt/tarkamcp/.env` | +| `Node 'pveX' is unreachable` | API Proxmox down | `curl -sk https://pve1:8006/api2/json/version` | +| `QEMU Guest Agent may not be running` | Agent non installé | Voir [README#installer-le-qemu-guest-agent](../README.md#installer-le-qemu-guest-agent) | +| `iLO ... unreachable` | pve1 down ou iLO injoignable | Vérifier pve1 d'abord | +| `SSH connection failed` | Auth SSH désactivée | `grep PasswordAuthentication /etc/ssh/sshd_config` | +| `invalid_client` | Mauvais Client ID/Secret | `tarkamcp auth list` pour vérifier | +| `421 Misdirected Request` | Hostname public absent de l'allowlist | Ajouter le domaine à `TARKAMCP_ALLOWED_HOSTS` dans `.env` puis redémarrer | +| `{"error":"unauthorized"}` sur `/authorize` | Client ID inexistant côté serveur | Créer le client avec `tarkamcp auth create`, puis recoller l'ID dans le connecteur | +| `invalid_grant` + `missing or invalid totp` | Code 2FA faux, expiré (>30 s), ou déjà utilisé | Générer un nouveau code dans l'app. Vérifier l'horloge du serveur vs celle du téléphone (`timedatectl`). | +| Page 2FA affiche "Trop de tentatives" | 5 codes faux consécutifs → lockout 5 min | Attendre. Le compteur se réinitialise à la prochaine validation correcte. | +| Clients silencieusement révoqués après update | Migration 2FA : les anciens clients sans TOTP sont rejetés au démarrage | Regarder `journalctl -u tarkamcp` pour la liste, recréer via `tarkamcp auth create` | +| Dashboard boucle entre `/app/refresh` et `/app/chat` | Bearer wipe après redémarrage du service | Taper le code TOTP sur la page de refresh pour regénérer un bearer | +| Clients MCP externes déconnectés après un restart | `TokenStore` en mémoire, wipé au restart | Recréer les tokens dans `/app/tokens` (max 3, expire 24 h) | +| `event: error ... "code": "remote_mode_disabled"` dans le chat | `TARKAMCP_DASHBOARD_MCP_MODE=remote` dans `.env` | Retirer la ligne du `.env` et redémarrer — seul le mode local est supporté | +| Chat bloqué sur une card SSH / exec avec deux boutons | Confirmation obligatoire pour `ssh_exec_command*` et `proxmox_exec_command*` | Cliquer **Autoriser** ou **Refuser**. Timeout à 5 min sinon auto-reject | +| Tokens API : "Limite atteinte : maximum 3 tokens" | 3 tokens nommés déjà actifs pour ce client | Révoquer un token existant dans la liste avant d'en créer un nouveau | + +## Exemples d'utilisation + +> **"pve2 ne répond plus, qu'est-ce qui se passe ?"** +> +> L'IA va : `proxmox_list_nodes` → voit pve2 offline → `ilo_power_status` → vérifie si allumé → `ilo_health_status` → checker le hardware → proposer un diagnostic + +> **"Mets à jour les paquets sur tous les conteneurs"** +> +> L'API Proxmox n'expose pas d'endpoint `exec` pour les LXC. L'IA utilise +> donc `proxmox_list_vms` → liste les CTs → `ssh_exec_command_async` sur le +> nœud hôte avec `pct exec -- sh -c 'apt update && apt upgrade -y'` +> pour chacun → poll les résultats. Chaque appel `ssh_exec_command*` et +> `proxmox_exec_command*` **exige une approbation manuelle** dans le chat +> intégré ; les clients MCP externes (Claude, ChatGPT, Gemini) doivent faire +> pareil si l'auto-approve n'est pas désactivé. diff --git a/src/tarkamcp/dashboard/chat.py b/src/tarkamcp/dashboard/chat.py index 4d8f45f..032178f 100644 --- a/src/tarkamcp/dashboard/chat.py +++ b/src/tarkamcp/dashboard/chat.py @@ -141,12 +141,17 @@ class UsageAccumulated: # Tool names that MUST go through a human approval step before we run -# them from a Gemini turn. SSH exec on the PVE hosts is the obvious one -# -- any conversation could otherwise fire arbitrary shell. Keep this -# list tight; every entry adds a modal click to the UX. +# them from a Gemini turn. Anything that can fire arbitrary shell on a +# host or VM (SSH directly, QEMU Guest Agent exec via proxmox_exec_*) +# belongs here -- otherwise a single compromised/confused turn could +# rm -rf a production box. ``proxmox_exec_get_result`` is read-only so +# it stays unconfirmed. Keep this list tight; every entry adds a modal +# click to the UX. _NEEDS_CONFIRMATION: frozenset[str] = frozenset({ "ssh_exec_command", "ssh_exec_command_async", + "proxmox_exec_command", + "proxmox_exec_command_async", }) diff --git a/tests/test_dashboard_chat.py b/tests/test_dashboard_chat.py index 95c235a..8b6a0be 100644 --- a/tests/test_dashboard_chat.py +++ b/tests/test_dashboard_chat.py @@ -849,3 +849,18 @@ def test_chat_page_renders_after_login(app_and_client): assert "chat-root" in r.text assert "gemini-3-flash-preview" in r.text assert "gemini-3.1-pro-preview" in r.text + + +def test_needs_confirmation_includes_proxmox_exec(): + """Every tool that can fire arbitrary shell on a host/VM must require + human approval -- not just SSH, but also the QEMU Guest Agent exec + path (``proxmox_exec_command`` + its async twin). + """ + from tarkamcp.dashboard.chat import _NEEDS_CONFIRMATION + assert "proxmox_exec_command" in _NEEDS_CONFIRMATION + assert "proxmox_exec_command_async" in _NEEDS_CONFIRMATION + assert "ssh_exec_command" in _NEEDS_CONFIRMATION + assert "ssh_exec_command_async" in _NEEDS_CONFIRMATION + # The read-only result-fetcher must NOT require a click. + assert "proxmox_exec_get_result" not in _NEEDS_CONFIRMATION + assert "ssh_exec_get_result" not in _NEEDS_CONFIRMATION From c7c0edfe18b1424fe667c2630d909c38cb7477e2 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 04:22:31 +0200 Subject: [PATCH 046/155] Rebrand TarkaMCP to BeaconMCP with modular N-node / N-BMC support - Package tarkamcp -> beaconmcp (git mv, version 2.0.0) - YAML-first config (beaconmcp.yaml + ${ENV} resolver), legacy env fallback with DeprecationWarning - New bmc/ package with BMCClient Protocol, HP iLO + generic IPMI backends, iDRAC + Supermicro stubs, registry, and bmc_* tools taking a device_id parameter - SSH vmid_to_ip fallback now configurable per-deployment - README, docs, install.sh, OAuth UI and dashboard templates translated to English with professional tone - validate-config CLI subcommand for dry-run config verification - New test suites for YAML loader, BMC registry, and IPMI backend (135/135 unit tests passing) Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 81 ++- CLAUDE.md | 53 +- README.md | 506 +++++++----------- beaconmcp.yaml.example | 96 ++++ deploy/beaconmcp.service | 15 + deploy/install.sh | 91 ++-- deploy/tarkamcp.service | 15 - docs/dashboard.md | 160 +++--- ...sign.md => 2026-04-16-beaconmcp-design.md} | 24 +- ... 2026-04-17-beaconmcp-dashboard-design.md} | 54 +- docs/tests.md | 38 +- docs/troubleshooting.md | 59 +- infrastructure.yaml | 26 - pyproject.toml | 16 +- src/{tarkamcp => beaconmcp}/__init__.py | 0 src/{tarkamcp => beaconmcp}/__main__.py | 148 +++-- src/{tarkamcp => beaconmcp}/assets/logo.webp | Bin src/{tarkamcp => beaconmcp}/auth.py | 10 +- src/beaconmcp/bmc/__init__.py | 21 + src/beaconmcp/bmc/base.py | 74 +++ src/beaconmcp/bmc/hp_ilo.py | 183 +++++++ src/beaconmcp/bmc/idrac.py | 21 + src/beaconmcp/bmc/ipmi.py | 116 ++++ src/beaconmcp/bmc/registry.py | 38 ++ src/beaconmcp/bmc/supermicro.py | 22 + src/beaconmcp/bmc/tools.py | 183 +++++++ src/beaconmcp/config.py | 454 ++++++++++++++++ .../dashboard/__init__.py | 8 +- src/{tarkamcp => beaconmcp}/dashboard/app.py | 56 +- src/{tarkamcp => beaconmcp}/dashboard/chat.py | 74 +-- .../dashboard/confirmations.py | 0 .../dashboard/conversations.py | 0 src/{tarkamcp => beaconmcp}/dashboard/csrf.py | 4 +- src/{tarkamcp => beaconmcp}/dashboard/db.py | 4 +- .../dashboard/session.py | 12 +- .../dashboard/static/app.css | 2 +- .../dashboard/static/chat.js | 26 +- .../dashboard/templates/base.html | 4 +- .../dashboard/templates/chat.html | 30 +- .../dashboard/templates/login.html | 14 +- .../dashboard/templates/tokens.html | 50 +- .../dashboard/templates/totp_refresh.html | 14 +- .../dashboard/usage.py | 0 .../ilo => beaconmcp/proxmox}/__init__.py | 0 src/{tarkamcp => beaconmcp}/proxmox/client.py | 0 .../proxmox/monitoring.py | 0 src/{tarkamcp => beaconmcp}/proxmox/system.py | 0 src/{tarkamcp => beaconmcp}/proxmox/vms.py | 0 .../security}/__init__.py | 0 src/{tarkamcp => beaconmcp}/security/tools.py | 0 src/{tarkamcp => beaconmcp}/server.py | 70 +-- .../security => beaconmcp/ssh}/__init__.py | 0 src/{tarkamcp => beaconmcp}/ssh/client.py | 46 +- src/{tarkamcp => beaconmcp}/ssh/tools.py | 6 +- src/tarkamcp/config.py | 105 ---- src/tarkamcp/ilo/client.py | 193 ------- src/tarkamcp/ilo/tools.py | 103 ---- src/tarkamcp/ssh/__init__.py | 0 tests/test_bmc_ipmi.py | 124 +++++ tests/test_bmc_registry.py | 87 +++ tests/test_config_yaml.py | 191 +++++++ tests/test_dashboard_chat.py | 30 +- tests/test_dashboard_integration.py | 52 +- tests/test_dashboard_unit.py | 84 +-- tests/test_dashboard_usage.py | 16 +- tests/test_integration.py | 78 +-- 66 files changed, 2585 insertions(+), 1402 deletions(-) create mode 100644 beaconmcp.yaml.example create mode 100644 deploy/beaconmcp.service delete mode 100644 deploy/tarkamcp.service rename docs/superpowers/specs/{2026-04-16-tarkamcp-design.md => 2026-04-16-beaconmcp-design.md} (90%) rename docs/superpowers/specs/{2026-04-17-tarka-dashboard-design.md => 2026-04-17-beaconmcp-dashboard-design.md} (90%) delete mode 100644 infrastructure.yaml rename src/{tarkamcp => beaconmcp}/__init__.py (100%) rename src/{tarkamcp => beaconmcp}/__main__.py (81%) rename src/{tarkamcp => beaconmcp}/assets/logo.webp (100%) rename src/{tarkamcp => beaconmcp}/auth.py (97%) create mode 100644 src/beaconmcp/bmc/__init__.py create mode 100644 src/beaconmcp/bmc/base.py create mode 100644 src/beaconmcp/bmc/hp_ilo.py create mode 100644 src/beaconmcp/bmc/idrac.py create mode 100644 src/beaconmcp/bmc/ipmi.py create mode 100644 src/beaconmcp/bmc/registry.py create mode 100644 src/beaconmcp/bmc/supermicro.py create mode 100644 src/beaconmcp/bmc/tools.py create mode 100644 src/beaconmcp/config.py rename src/{tarkamcp => beaconmcp}/dashboard/__init__.py (75%) rename src/{tarkamcp => beaconmcp}/dashboard/app.py (95%) rename src/{tarkamcp => beaconmcp}/dashboard/chat.py (92%) rename src/{tarkamcp => beaconmcp}/dashboard/confirmations.py (100%) rename src/{tarkamcp => beaconmcp}/dashboard/conversations.py (100%) rename src/{tarkamcp => beaconmcp}/dashboard/csrf.py (92%) rename src/{tarkamcp => beaconmcp}/dashboard/db.py (98%) rename src/{tarkamcp => beaconmcp}/dashboard/session.py (95%) rename src/{tarkamcp => beaconmcp}/dashboard/static/app.css (99%) rename src/{tarkamcp => beaconmcp}/dashboard/static/chat.js (97%) rename src/{tarkamcp => beaconmcp}/dashboard/templates/base.html (83%) rename src/{tarkamcp => beaconmcp}/dashboard/templates/chat.html (92%) rename src/{tarkamcp => beaconmcp}/dashboard/templates/login.html (78%) rename src/{tarkamcp => beaconmcp}/dashboard/templates/tokens.html (78%) rename src/{tarkamcp => beaconmcp}/dashboard/templates/totp_refresh.html (71%) rename src/{tarkamcp => beaconmcp}/dashboard/usage.py (100%) rename src/{tarkamcp/ilo => beaconmcp/proxmox}/__init__.py (100%) rename src/{tarkamcp => beaconmcp}/proxmox/client.py (100%) rename src/{tarkamcp => beaconmcp}/proxmox/monitoring.py (100%) rename src/{tarkamcp => beaconmcp}/proxmox/system.py (100%) rename src/{tarkamcp => beaconmcp}/proxmox/vms.py (100%) rename src/{tarkamcp/proxmox => beaconmcp/security}/__init__.py (100%) rename src/{tarkamcp => beaconmcp}/security/tools.py (100%) rename src/{tarkamcp => beaconmcp}/server.py (64%) rename src/{tarkamcp/security => beaconmcp/ssh}/__init__.py (100%) rename src/{tarkamcp => beaconmcp}/ssh/client.py (80%) rename src/{tarkamcp => beaconmcp}/ssh/tools.py (93%) delete mode 100644 src/tarkamcp/config.py delete mode 100644 src/tarkamcp/ilo/client.py delete mode 100644 src/tarkamcp/ilo/tools.py delete mode 100644 src/tarkamcp/ssh/__init__.py create mode 100644 tests/test_bmc_ipmi.py create mode 100644 tests/test_bmc_registry.py create mode 100644 tests/test_config_yaml.py diff --git a/.env.example b/.env.example index dc5e00b..794cccb 100644 --- a/.env.example +++ b/.env.example @@ -1,55 +1,40 @@ -# Proxmox nodes -- API tokens (create on each node via Datacenter > Permissions > API Tokens) -PVE1_HOST=pve1.example.com -PVE1_TOKEN_ID=root@pam!tarkamcp +# BeaconMCP secrets. +# +# Topology lives in beaconmcp.yaml (see beaconmcp.yaml.example). This file +# only holds the values the YAML references as ${VAR}. Every variable below +# is optional — include only the secrets your topology actually uses. + +# --- Proxmox API tokens ---------------------------------------------------- +# One secret per entry under proxmox.nodes[] in beaconmcp.yaml. PVE1_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - -# PVE2 is optional (graceful degradation if missing or node is down) -PVE2_HOST=pve2.example.com -PVE2_TOKEN_ID=root@pam!tarkamcp PVE2_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -# iLO -- single unit, local network only (accessed via SSH tunnel through PVE1) -ILO_HOST=192.168.x.x -ILO_USER=Administrator -ILO_PASSWORD=xxxxx -ILO_JUMP_HOST=pve1 - -# SSH credentials (fallback access to hosts and VMs) -SSH_USER=root -SSH_PASSWORD=xxxxx - -# Options -PVE_VERIFY_SSL=false - -# Path to infrastructure.yaml (defaults to ./infrastructure.yaml) -# INFRA_YAML_PATH=./infrastructure.yaml - -# HTTP mode (for Claude mobile/web, ChatGPT, Gemini) -# Clients are managed via: tarkamcp auth create --name "My Client" -# Client credentials are stored in clients.json -# TARKAMCP_CLIENTS_FILE=/opt/tarkamcp/clients.json -# TARKAMCP_PORT=8420 -# TARKAMCP_HOST=0.0.0.0 +# --- BMC credentials ------------------------------------------------------- +# One secret per entry under bmc.devices[]. Name the env vars after the +# device id for clarity. +RACK1_ILO_PASSWORD=change-me +RACK2_IPMI_PASSWORD=change-me -# DNS-rebinding protection (MCP SDK). The public hostname this server is -# exposed on MUST be listed here, otherwise requests get 421 Misdirected. -# Comma-separated. Wildcard ports with ":*" are supported. -TARKAMCP_ALLOWED_HOSTS=mcp.example.com,127.0.0.1:*,localhost:*,[::1]:* -# TARKAMCP_ALLOWED_ORIGINS=https://claude.ai,https://chat.openai.com,https://gemini.google.com +# --- SSH fallback ---------------------------------------------------------- +SSH_PASSWORD=change-me -# Dashboard (login + chat panels at /app/*). Optional: omit GEMINI_API_KEY to -# disable the dashboard entirely. +# --- Dashboard ------------------------------------------------------------- +# Omit GEMINI_API_KEY to disable the integrated chat (the tokens page still works). # GEMINI_API_KEY=... -# TARKAMCP_SESSION_KEY is auto-generated by deploy/install.sh on first run. -# TARKAMCP_SESSION_KEY= -# TARKAMCP_DASHBOARD_DB=/opt/tarkamcp/dashboard.db -# TARKAMCP_DASHBOARD_ENABLED=true -# TARKAMCP_DASHBOARD_PUBLIC_URL=https://mcp.example.com # optional, used in MCP tool URLs sent to Gemini -# Budget per client for the Gemini chat panel. Hard-rejects new turns -# once a cap is hit; set to 0 to disable that window. The 5h window is -# an Anthropic-style session (opens on the first message after >=5h of -# inactivity, lasts exactly 5h, then closes). The weekly window is a -# rolling trailing 7 days. -# TARKAMCP_DASHBOARD_LIMIT_5H_USD=2.0 -# TARKAMCP_DASHBOARD_LIMIT_WEEK_USD=10.0 +# Auto-generated by deploy/install.sh on first run. Encrypts client_secret +# at rest for dashboard sessions. Regenerating invalidates every session. +# BEACONMCP_SESSION_KEY= + +# --- Legacy env-var overrides (deprecated, removed in 2.1) ----------------- +# Only used when no beaconmcp.yaml is found. Prefer the YAML file. +# BEACONMCP_CONFIG=/etc/beaconmcp/config.yaml +# BEACONMCP_CLIENTS_FILE=/opt/beaconmcp/clients.json +# BEACONMCP_PORT=8420 +# BEACONMCP_HOST=0.0.0.0 +# BEACONMCP_ALLOWED_HOSTS=mcp.example.com,127.0.0.1:*,localhost:*,[::1]:* +# BEACONMCP_ALLOWED_ORIGINS=https://claude.ai,https://chat.openai.com,https://gemini.google.com +# BEACONMCP_DASHBOARD_ENABLED=true +# BEACONMCP_DASHBOARD_PUBLIC_URL=https://mcp.example.com +# BEACONMCP_DASHBOARD_LIMIT_5H_USD=2.0 +# BEACONMCP_DASHBOARD_LIMIT_WEEK_USD=10.0 diff --git a/CLAUDE.md b/CLAUDE.md index 2211c24..5e1e477 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,51 +1,62 @@ -# TarkaMCP +# BeaconMCP -Remote MCP server for managing a Proxmox VE infrastructure (pve1.example.com, pve2.example.com) with HP iLO 4 hardware management and SSH fallback access. Runs as an HTTP server with OAuth 2.1 client credentials authentication. +Remote MCP server for managing any Proxmox VE cluster together with its BMC-managed hardware (HP iLO, IPMI). Runs over HTTP with OAuth 2.1 + TOTP. Topology is described in a single YAML file; secrets are referenced through `${ENV_VAR}` placeholders. ## Quick Start ```bash pip install -e . -cp .env.example .env # Fill in Proxmox credentials -tarkamcp auth create --name "x" # Create OAuth client -tarkamcp serve # Start HTTP server on :8420 +cp beaconmcp.yaml.example beaconmcp.yaml # Describe your infrastructure +cp .env.example .env # Fill in the referenced secrets +beaconmcp validate-config # Dry-run the loader, secrets masked +beaconmcp auth create --name "x" # Create an OAuth client +beaconmcp serve # Start HTTP server on :8420 ``` ## Project Structure ``` -src/tarkamcp/ - __main__.py CLI: serve (HTTP server) + auth (client management) - server.py FastMCP server, registers all tool modules - config.py Environment variable loading & validation +src/beaconmcp/ + __main__.py CLI: serve + auth + validate-config + server.py FastMCP server, registers every tool module + config.py YAML loader with ${ENV} resolver + legacy env fallback auth.py OAuth 2.1 client credentials (ClientStore + TokenStore) proxmox/ - client.py proxmoxer wrapper (API token auth, error handling) + client.py proxmoxer wrapper (API-token auth, N-node aware) monitoring.py 6 tools: list_nodes, node_status, list_vms, vm_status, get_logs, get_tasks vms.py 7 tools: vm_start/stop/restart/create/clone/migrate/config system.py 5 tools: storage_status, network_config, exec_command (sync+async+get_result) ssh/ - client.py asyncssh wrapper (host resolution, connection caching) + client.py asyncssh wrapper with configurable VMID->IP template tools.py 4 tools: ssh_exec_command (sync+async+get_result), ssh_list_sessions - ilo/ - client.py python-hpilo wrapper (SSH tunnel via pve1 to local iLO) - tools.py 7 tools: server_info, health_status, power_status/on/off/reset, event_log + bmc/ + base.py BMCClient Protocol + shared exceptions + stub base class + hp_ilo.py HPILOBackend (python-hpilo, optional SSH jump tunnel) + ipmi.py GenericIPMIBackend (shells out to ipmitool) + idrac.py IDRACStubBackend (TODO) + supermicro.py SupermicroStubBackend (TODO) + registry.py build_registry(config) -> {device_id: BMCClient} + tools.py 8 tools: bmc_list_devices + 7 action tools (device_id param) + dashboard/ Optional web panel: /app/login, /app/chat, /app/tokens +beaconmcp.yaml.example Template describing the full config schema deploy/ - install.sh One-command install script for Proxmox nodes - tarkamcp.service systemd unit file + install.sh One-command install script + beaconmcp.service systemd unit file ``` ## Configuration -All via environment variables (`.env` file). See `.env.example`. +Two files: -**Required:** `PVE1_HOST`, `PVE1_TOKEN_ID`, `PVE1_TOKEN_SECRET` -**Optional:** PVE2, iLO, SSH credentials (modules conditionally registered) +- **`beaconmcp.yaml`** — topology and feature flags. Resolution order: `--config` flag → `BEACONMCP_CONFIG` env → `./beaconmcp.yaml` → `/etc/beaconmcp/config.yaml`. +- **`.env`** — secrets referenced by the YAML via `${VAR}`. + +Legacy `PVE*_*`, `ILO_*`, `SSH_*` env vars still work when no YAML is found (deprecated; removed in 2.1). ## Auth -OAuth 2.1 client credentials. Manage with `tarkamcp auth create/list/revoke`. +OAuth 2.1 client credentials + mandatory TOTP. Manage with `beaconmcp auth create/list/revoke`. ## Design Spec -See `docs/superpowers/specs/2026-04-16-tarkamcp-design.md` +See `docs/superpowers/specs/2026-04-16-beaconmcp-design.md`. diff --git a/README.md b/README.md index 7e784da..f1f6b0b 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,36 @@

-# TarkaMCP +# BeaconMCP [![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776AB?logo=python&logoColor=white)](https://www.python.org/downloads/) [![MCP Protocol](https://img.shields.io/badge/MCP-Model_Context_Protocol-5A67D8)](https://modelcontextprotocol.io/) [![Proxmox VE](https://img.shields.io/badge/Proxmox-VE_8.x-E57000?logo=proxmox&logoColor=white)](https://www.proxmox.com/) -[![HP iLO 4](https://img.shields.io/badge/HP-iLO_4-0096D6?logo=hp&logoColor=white)](https://www.hpe.com/us/en/servers/integrated-lights-out-ilo.html) +[![HP iLO](https://img.shields.io/badge/HP-iLO_4%2F5-0096D6?logo=hp&logoColor=white)](https://www.hpe.com/us/en/servers/integrated-lights-out-ilo.html) +[![IPMI](https://img.shields.io/badge/IPMI-2.0-4E5D70)](https://en.wikipedia.org/wiki/Intelligent_Platform_Management_Interface) [![ChatGPT](https://img.shields.io/badge/ChatGPT-Compatible-74AA9C?logo=openai&logoColor=white)](https://chatgpt.com/) [![Gemini](https://img.shields.io/badge/Gemini-Compatible-4285F4?logo=google&logoColor=white)](https://gemini.google.com/) -[![License](https://img.shields.io/github/license/Showdown76py/TarkaMCP)](LICENSE) -[![Claude Code](https://img.shields.io/badge/Built_with-Claude_Code-F97316)](https://claude.ai/code) +[![License](https://img.shields.io/github/license/Showdown76py/BeaconMCP)](LICENSE) -**Serveur MCP remote pour la gestion d'infrastructure Proxmox VE.** +**Remote MCP server for Proxmox VE clusters and BMC-managed hardware.** -Compatible **Claude** (web, mobile) • **ChatGPT** • **Gemini** (CLI, API) +Works with **Claude** (web, mobile, desktop) • **ChatGPT** • **Gemini** (CLI, API) -[Installation](#installation) • [Connexion](#connexion-par-plateforme) • [Outils](#outils-disponibles) • [Tests](#tests) +[Installation](#installation) • [Connecting clients](#connecting-clients) • [Tools](#available-tools) • [Tests](#tests)
--- -### Fonctionnalités +## Overview -- **29 outils MCP** répartis en 3 modules (Proxmox, SSH, iLO) -- **Multi-plateforme** -- Claude, ChatGPT, Gemini via Streamable HTTP + OAuth 2.1 -- **Diagnostic automatisé** -- l'IA identifie les crashs, vérifie le hardware, propose des résolutions -- **Exécution de commandes** dans les VMs/CTs via QEMU Guest Agent ou SSH (sync et async) -- **Gestion hardware à distance** -- power on/off/reset, températures, ventilateurs via iLO 4 -- **Architecture modulaire** -- chaque module se charge uniquement si ses credentials sont configurés +BeaconMCP exposes a Proxmox VE cluster and the hardware underneath it (HP iLO, generic IPMI) as a single Streamable HTTP MCP server. Any MCP-capable client can diagnose a crash, power-cycle a frozen host, create or migrate VMs, and execute commands inside guests or on the bare-metal nodes — through a single OAuth 2.1 endpoint. ---- - -## Table des matières - -- [Architecture](#architecture) -- [Installation](#installation) -- [Connexion par plateforme](#connexion-par-plateforme) -- [Dashboard web](#dashboard-web) -- [Sécurité : review manuelle des actions sensibles](#sécurité--review-manuelle-des-actions-sensibles) -- [Configuration Proxmox](#configuration-proxmox) -- [Configuration iLO](#configuration-ilo) -- [Configuration .env](#configuration-env) -- [Tests](#tests) — détails dans [docs/tests.md](docs/tests.md) -- [Outils disponibles](#outils-disponibles) -- [Dépannage & exemples](#dépannage--exemples) — détails dans [docs/troubleshooting.md](docs/troubleshooting.md) +- **30 MCP tools** across four modules: Proxmox (monitoring, VM lifecycle, system), SSH fallback, and BMC hardware management. +- **N nodes, N BMC devices.** Declare as many Proxmox nodes as your cluster has, and as many BMC endpoints (HP iLO, IPMI) as you manage. No hard-coded node counts. +- **Backend-agnostic hardware layer.** HP iLO and generic IPMI ship out of the box; Dell iDRAC and Supermicro are pluggable stubs. +- **YAML-first configuration** with `${ENV}` references for secrets. Validation runs at startup. +- **OAuth 2.1 + TOTP.** Client credentials with mandatory second factor on every token issuance. +- **Optional web dashboard** — login, API-token management, and an (optional) integrated Gemini chat panel. --- @@ -52,150 +38,128 @@ Compatible **Claude** (web, mobile) • **ChatGPT** • **Gemini** (CLI, A ``` Clients (Claude, ChatGPT, Gemini) - | - | HTTPS (Cloudflare Tunnel) - v -+--[ pve1.example.com ]-------------------+ -| | -| TarkaMCP (HTTP :8420) | -| +-- proxmox/ -----> Proxmox API :8006 | -| +-- ssh/ ---------> SSH :22 | -| +-- ilo/ ---------> iLO 4 (réseau local)| -| | -+--------------------------------------------+ - | - | API Proxmox - v - pve2.example.com + │ + │ HTTPS (reverse proxy / tunnel) + ▼ +┌──────────────────────────────────┐ +│ BeaconMCP (HTTP :8420) │ +│ ├── proxmox/ → Proxmox API │ +│ ├── ssh/ → SSH :22 │ +│ ├── bmc/ → iLO / IPMI │ +│ └── dashboard/ → /app/* │ +└──────────────────────────────────┘ + │ + │ managed cluster + ▼ + Proxmox nodes (N) · BMC devices (N) ``` -Le serveur tourne sur pve1 et expose un endpoint MCP via Cloudflare Tunnel. Toutes les plateformes s'y connectent avec des credentials OAuth. +BeaconMCP runs on any host that can reach the Proxmox API of every declared node and the BMC management network. It speaks MCP over Streamable HTTP and is typically placed behind a reverse proxy with DNS-rebinding protection configured via `server.allowed_hosts` in the YAML. + +--- + +## Requirements + +- Python 3.11+ +- Proxmox VE 8.x with API tokens provisioned on each node (Datacenter → Permissions → API Tokens) +- *(optional)* `ipmitool` binary on the BeaconMCP host if any IPMI BMC is configured +- *(optional)* reachable jump host (a Proxmox node) for HP iLO devices exposed only on a private management VLAN +- *(optional)* `GEMINI_API_KEY` to enable the integrated chat panel --- ## Installation -### 1. Installer sur pve1 +### 1. Install ```bash -git clone https://github.com/Showdown76py/TarkaMCP.git /opt/tarkamcp -cd /opt/tarkamcp +git clone https://github.com/Showdown76py/BeaconMCP.git /opt/beaconmcp +cd /opt/beaconmcp sudo bash deploy/install.sh ``` -### 2. Configurer les credentials Proxmox +The install script creates a `beaconmcp` system user, installs the package in editable mode, registers a systemd unit, and creates `/opt/beaconmcp` for persistent state. + +### 2. Configure ```bash -nano /opt/tarkamcp/.env +cp beaconmcp.yaml.example /opt/beaconmcp/beaconmcp.yaml +cp .env.example /opt/beaconmcp/.env +# Edit both: YAML defines the topology, .env holds the secrets. ``` -Remplir au minimum `PVE1_HOST`, `PVE1_TOKEN_ID`, `PVE1_TOKEN_SECRET` (voir [Configuration .env](#configuration-env)). - -### 3. Créer un client OAuth (avec 2FA) +The YAML declares Proxmox nodes, BMC devices, SSH credentials, the dashboard configuration, and DNS-rebinding allowlists. Secrets are referenced via `${ENV_VAR}` placeholders resolved at startup against the `.env` file. Validate the result without starting the server: ```bash -tarkamcp auth create --name "Claude Web" -``` - +beaconmcp validate-config +# prints the fully-resolved config with secrets masked, and a one-line summary. ``` - Client ID: tarkamcp_a1b2c3... - Client Secret: sk_d4e5f6... - --- 2FA / Google Authenticator --- - Scanne ce QR code dans ton app (Google Authenticator, Authy, 1Password) : +### 3. Provision an OAuth client - █▀▀▀▀▀█ ▄▀ ▄█ █▀▀▀▀▀█ - █ ███ █ ▀ ▄▄▄ █ ███ █ - ... - - Secret manuel : JBSWY3DPEHPK3PXP - URI otpauth : otpauth://totp/TarkaMCP:tarkamcp_...?secret=...&issuer=TarkaMCP +```bash +beaconmcp auth create --name "Claude Web" ``` -**Important** : le Client Secret ET le secret TOTP ne sont affichés qu'une seule fois. Scanne le QR tout de suite dans ton app d'authentification, sinon tu devras révoquer et recréer le client. +The CLI prints a client id, a client secret, and a TOTP seed (with an ASCII QR code). **Both secrets are displayed exactly once.** Scan the QR into an authenticator app (Google Authenticator, Authy, 1Password) immediately, or store the raw seed in a secrets manager. -### 4. Démarrer le serveur +Repeat for each MCP client that should have access (ChatGPT, Gemini, etc.). Clients are listed and revoked with: ```bash -sudo systemctl start tarkamcp -curl http://localhost:8420/health -# → {"status": "ok", "server": "tarkamcp"} +beaconmcp auth list +beaconmcp auth revoke ``` -### 5. Exposer via Cloudflare Tunnel - -Dans le dashboard Cloudflare Zero Trust, ajouter un tunnel : - -| Paramètre | Valeur | -|-----------|--------| -| **Hostname** | `mcp.example.com` | -| **Service** | `http://localhost:8420` | - -Puis déclarer ce hostname dans `.env` via `TARKAMCP_ALLOWED_HOSTS`, sinon le -SDK MCP renverra `421 Misdirected Request` (protection DNS-rebinding). - -### Gérer les clients +### 4. Start the server ```bash -# Créer un client par plateforme -tarkamcp auth create --name "ChatGPT" -tarkamcp auth create --name "Gemini" +sudo systemctl enable --now beaconmcp +curl http://localhost:8420/health +# {"status":"ok","server":"beaconmcp"} +``` -# Lister -tarkamcp auth list +### 5. Expose publicly -# Révoquer un accès -tarkamcp auth revoke tarkamcp_abc123... -``` +Place BeaconMCP behind a reverse proxy that terminates TLS and forwards the public hostname to `http://localhost:8420`. Declare that hostname under `server.allowed_hosts` in `beaconmcp.yaml`; without it the MCP SDK rejects incoming requests with `421 Misdirected Request` (DNS-rebinding protection). --- -## Connexion par plateforme +## Connecting clients -### Claude (web & mobile) +### Claude (web, mobile, desktop) -1. **Settings** > **Integrations** > **Add custom connector** -2. Remplir : - - **Name** : `TarkaMCP` - - **Remote MCP server URL** : `https://mcp.example.com/mcp` - - **OAuth Client ID** : `tarkamcp_a1b2c3...` - - **OAuth Client Secret** : `sk_d4e5f6...` -3. **Add** +1. **Settings → Integrations → Add custom connector.** +2. Fill in: + - **Name:** BeaconMCP + - **Remote MCP server URL:** `https:///mcp` + - **OAuth Client ID** and **OAuth Client Secret** from `beaconmcp auth create`. +3. **Add.** -À la connexion, une page TarkaMCP s'ouvre dans ton navigateur et demande le code 2FA à 6 chiffres depuis Google Authenticator. Saisis-le, tu es redirigé vers Claude automatiquement. Le token dure 24 h, après quoi Claude redemande le code. +On first use, Claude redirects to the BeaconMCP authorization page, which prompts for the 6-digit TOTP code. Tokens last 24 hours; Claude re-prompts at expiry. ### ChatGPT -1. **Settings** > **Developer Mode** > **MCP Servers** -2. URL : `https://mcp.example.com/mcp` -3. Obtenir un bearer token. Deux options : - - **Via le dashboard** (recommandé) : [page Tokens API](#page-tokens-api) → crée un token nommé "ChatGPT", copie la valeur. - - **Via curl** : - ```bash - TOTP=$(oathtool --totp -b "$TOTP_SECRET") # ou tape-le depuis l'app - curl -X POST https://mcp.example.com/oauth/token \ - -d "grant_type=client_credentials&client_id=ID&client_secret=SECRET&totp=$TOTP" - ``` -4. Utiliser la valeur comme bearer token. Expire dans 24 h. +1. **Settings → Developer Mode → MCP Servers.** +2. URL: `https:///mcp`. +3. Obtain a bearer token either from the dashboard's **API Tokens** page (recommended) or via a direct OAuth token request: + + ```bash + TOTP=$(oathtool --totp -b "$TOTP_SECRET") + curl -X POST https:///oauth/token \ + -d "grant_type=client_credentials&client_id=$ID&client_secret=$SECRET&totp=$TOTP" + ``` + +4. Use the returned `access_token` as the bearer. Expires in 24 hours. ### Gemini CLI ```bash -# Option 1 : récupère un token depuis le dashboard (Tokens API → nouveau token "Gemini CLI") -gemini mcp add tarkamcp --url https://mcp.example.com/mcp \ - --header "Authorization: Bearer " - -# Option 2 : via curl -TOTP=$(oathtool --totp -b "$TOTP_SECRET") -TOKEN=$(curl -s -X POST https://mcp.example.com/oauth/token \ - -d "grant_type=client_credentials&client_id=ID&client_secret=SECRET&totp=$TOTP" \ - | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") - -gemini mcp add tarkamcp --url https://mcp.example.com/mcp \ - --header "Authorization: Bearer $TOKEN" +gemini mcp add beaconmcp \ + --url https:///mcp \ + --header "Authorization: Bearer " ``` -Le token est valide 24 h (option 1 comme option 2). Recréer un nouveau token dans le dashboard ou relancer le bloc curl pour renouveler. +Issue the token either from the dashboard or from the `curl` snippet above. ### Gemini API @@ -203,225 +167,157 @@ Le token est valide 24 h (option 1 comme option 2). Recréer un nouveau token da import requests, pyotp from google import genai -totp = pyotp.TOTP("JBSWY3DPEHPK3PXP").now() # le secret affiché à la création -token = requests.post("https://mcp.example.com/oauth/token", data={ - "grant_type": "client_credentials", - "client_id": "tarkamcp_...", - "client_secret": "sk_...", - "totp": totp, -}).json()["access_token"] +totp = pyotp.TOTP(TOTP_SECRET).now() +token = requests.post( + "https:///oauth/token", + data={ + "grant_type": "client_credentials", + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "totp": totp, + }, +).json()["access_token"] client = genai.Client() response = client.models.generate_content( model="gemini-2.0-flash", - contents="Liste les VMs sur pve1", - config={"tools": [{"mcp_servers": [{ - "url": "https://mcp.example.com/mcp", - "headers": {"Authorization": f"Bearer {token}"}, - }]}]}, + contents="List the VMs on pve1", + config={ + "tools": [ + { + "mcp_servers": [ + { + "url": "https:///mcp", + "headers": {"Authorization": f"Bearer {token}"}, + } + ] + } + ] + }, ) ``` -> Stocker le secret TOTP dans le code va à l'encontre de l'intérêt du 2FA. Préfère un vault (1Password CLI, `pass`, secret manager) ou tape le code à la main. +Storing the TOTP seed next to the client secret defeats the second factor. Prefer a secrets manager or a hardware authenticator for production workloads. --- -## Dashboard web +## Dashboard -Un panel web optionnel est servi par TarkaMCP sur la même URL (`https://mcp.example.com/app/...`) — login TOTP, chat Gemini multi-conversations, et génération de tokens API pour brancher des clients MCP externes (Gemini web, ChatGPT, Claude Desktop…). +An optional web panel is mounted under `/app/*` on the same port as the MCP endpoint. It provides TOTP login, an API-token management page (used to wire external clients like the Gemini web UI or ChatGPT MCP without exposing the OAuth flow), and an optional integrated Gemini chat. The chat panel is gated by `GEMINI_API_KEY`; the tokens page works without it. -Le chat nécessite `GEMINI_API_KEY` ; la page **Tokens API** fonctionne sans. Détails complets (modes, pages, flow, modèles, sécurité SSH, tarifs, limites d'usage, architecture) dans [docs/dashboard.md](docs/dashboard.md). +Full reference: [docs/dashboard.md](docs/dashboard.md). --- -## Sécurité : review manuelle des actions sensibles +## Configuration -> **Ne laisse jamais un LLM exécuter des commandes shell sur ton infra sans les avoir relues toi-même.** +Two files are read at startup: -TarkaMCP expose des outils qui peuvent faire des dégâts irréversibles (`ssh_exec_command*`, `proxmox_exec_command*`, power off iLO, `vm_stop`, `vm_create`, etc.). Le LLM ne comprend pas toujours les conséquences d'une commande — un `rm -rf` "pour faire propre", un `systemctl stop` sur le mauvais service, un `pct destroy` au lieu de `pct stop`. Quelques règles : +- **`beaconmcp.yaml`** — topology and feature flags. Path resolution: `--config` flag → `BEACONMCP_CONFIG` env → `./beaconmcp.yaml` → `/etc/beaconmcp/config.yaml`. See [`beaconmcp.yaml.example`](beaconmcp.yaml.example) for the full schema. +- **`.env`** — secrets referenced by the YAML as `${VAR}`. Missing references fail the startup check with the offending YAML path. -- **Désactive l'auto-approve** sur chaque client MCP externe (Claude Desktop, Gemini CLI, ChatGPT MCP, etc.). La plupart offrent un toggle "Approve each tool call" ou équivalent — garde-le **activé**, et refuse l'option "Always allow this tool". -- **Relis l'argument `command` avant d'autoriser** un appel `ssh_exec_command*` ou `proxmox_exec_command*`. Pose-toi la question : "si cette commande tournait sur la mauvaise VM / le mauvais host, est-ce que je pourrais récupérer ?" -- **Le chat intégré (`/app/chat`) force déjà une approbation humaine** pour `ssh_exec_command`, `ssh_exec_command_async`, `proxmox_exec_command` et `proxmox_exec_command_async` — même si tu cliques vite, lis au moins les `args` de la tool-card. Le timeout est à 5 min et l'absence de réponse vaut refus. -- **Préfère les outils lecture-seule** (`*_list_*`, `*_status`, `*_get_*`, `get_logs`, `health_status`…) pour l'exploration. Ils ne sont jamais bloqués par confirmation parce qu'ils ne peuvent rien casser. -- **Ne partage jamais** un bearer `/app/tokens` avec un client MCP qui n'est pas le tien. Un token compromis = shell arbitraire sur pve1/pve2 pendant 24 h. +Common keys: -Un `systemctl restart tarkamcp` invalide tous les bearers en mémoire : en cas de doute sur un token qui fuite, c'est la corde de panique. +| Section | Notes | +|---------|-------| +| `server.allowed_hosts` | DNS-rebinding allowlist — **must** include the public FQDN behind your reverse proxy. | +| `server.allowed_origins` | CORS allowlist for browser-based MCP clients. | +| `proxmox.nodes[]` | One entry per Proxmox node. Needs an API token per node. | +| `ssh.vmid_to_ip` | Optional template (e.g. `"192.168.1.{id}"`) used by `ssh_exec_command` when the `host` argument is a bare VMID. Omit to disable numeric-ID shortcuts. | +| `bmc.devices[]` | Zero or more BMCs. `type` is one of `hp_ilo`, `ipmi`, `idrac` (stub), `supermicro` (stub). `jump_host` is optional — set it to the name of a `proxmox.nodes[]` entry to route the connection over an SSH tunnel. | +| `features.dashboard.limits` | Per-5h and per-week USD caps for the Gemini chat. Set to `0` to disable a window. | --- -## Configuration Proxmox - -### Créer un API token - -Sur l'interface web Proxmox (`https://pve1.example.com`) : - -1. **Datacenter** > **Permissions** > **API Tokens** > **Add** -2. **User** : `root@pam`, **Token ID** : `tarkamcp` -3. **Décocher** Privilege Separation -4. Copier le secret affiché - -Répéter sur pve2 quand disponible. - -### Installer le QEMU Guest Agent +## Security: manual review of sensitive actions -Nécessaire pour exécuter des commandes à l'intérieur des VMs. +> **Never let an LLM execute shell commands on infrastructure you care about without reading the command first.** -```bash -# Debian/Ubuntu -apt install -y qemu-guest-agent && systemctl enable --now qemu-guest-agent - -# CentOS/RHEL -dnf install -y qemu-guest-agent && systemctl enable --now qemu-guest-agent -``` - -Puis dans Proxmox : VM > **Options** > **QEMU Guest Agent** > cocher > redémarrer la VM. - -Les conteneurs LXC n'ont pas besoin du Guest Agent. - -### Configurer SSH (optionnel) - -SSH sert de fallback quand l'API Proxmox ne suffit pas. - -```bash -# Vérifier que l'auth par mot de passe est active -grep "^PasswordAuthentication" /etc/ssh/sshd_config -``` - ---- - -## Configuration iLO - -L'iLO est sur le réseau local. TarkaMCP y accède via un tunnel SSH ouvert sur -`ILO_JUMP_HOST` (par défaut `pve1`) ; les credentials SSH doivent donc être -configurés. Quand TarkaMCP tourne lui-même sur pve1, le tunnel est trivial -(localhost → iLO) mais reste nécessaire vu que python-hpilo est synchrone. +BeaconMCP exposes tools that cause irreversible changes: `ssh_exec_command*`, `proxmox_exec_command*`, `bmc_power_off`, `proxmox_vm_stop`, `proxmox_vm_create`, and more. Models do not always grasp the consequences of a command — an errant `rm -rf`, a `systemctl stop` on the wrong unit, a `pct destroy` mistaken for `pct stop`. A few working rules: -```bash -# Trouver l'IP de l'iLO depuis pve1 -nmap -sn 192.168.1.0/24 | grep -B2 "HP\|iLO" +- **Disable auto-approve** on every external MCP client (Claude Desktop, Gemini CLI, ChatGPT MCP). Keep per-call approval enabled; refuse "always allow this tool". +- **Read the `command` argument** before approving any `ssh_exec_command*` or `proxmox_exec_command*` call. Ask: if this ran against the wrong VM or host, could I recover? +- **The integrated chat** at `/app/chat` already forces human confirmation for every `ssh_exec_command*` and `proxmox_exec_command*` call. Read the arguments shown on the confirmation card even when you click through fast. No answer within 5 minutes counts as refusal. +- **Prefer read-only tools** (`*_list_*`, `*_status`, `*_get_*`, `get_logs`, `health_status`) for exploration — they cannot break anything and are never gated by confirmation. +- **Do not share a `/app/tokens` bearer** with a client you do not fully control. A leaked token grants arbitrary shell access on your Proxmox nodes for 24 hours. -# Tester -curl -sk https://IP_ILO/xmldata?item=All | grep PRODUCT_NAME -``` +`systemctl restart beaconmcp` invalidates every in-memory bearer. When in doubt about a token, restart is the panic lever. --- -## Configuration .env - -```bash -cp .env.example .env && nano .env -``` - -```env -# OBLIGATOIRE -PVE1_HOST=pve1.example.com -PVE1_TOKEN_ID=root@pam!tarkamcp -PVE1_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - -# OPTIONNEL -- PVE2 -PVE2_HOST=pve2.example.com -PVE2_TOKEN_ID=root@pam!tarkamcp -PVE2_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - -# OPTIONNEL -- iLO -ILO_HOST=192.168.1.X -ILO_USER=Administrator -ILO_PASSWORD=xxxxx -ILO_JUMP_HOST=pve1 - -# OPTIONNEL -- SSH -SSH_USER=root -SSH_PASSWORD=xxxxx - -# OPTIONS -PVE_VERIFY_SSL=false -# TARKAMCP_PORT=8420 - -# OBLIGATOIRE en prod -- hostnames publics autorisés par la protection -# DNS-rebinding du SDK MCP (sinon 421 Misdirected Request). Virgules. -TARKAMCP_ALLOWED_HOSTS=mcp.example.com,127.0.0.1:*,localhost:*,[::1]:* -# TARKAMCP_ALLOWED_ORIGINS=https://claude.ai,https://chat.openai.com,https://gemini.google.com -``` - -Modules chargés conditionnellement : sans SSH → pas de `ssh_*`, sans iLO → pas de `ilo_*`. +## Available tools + +### Proxmox — monitoring (6) + +| Tool | Description | +|------|-------------| +| `proxmox_list_nodes` | List cluster nodes and their status. | +| `proxmox_node_status` | CPU, memory, disk, uptime of a single node. | +| `proxmox_list_vms` | List every VM and container across the cluster. | +| `proxmox_vm_status` | Detailed state of a VM or container. | +| `proxmox_get_logs` | System or task logs. | +| `proxmox_get_tasks` | Recent task history. | + +### Proxmox — VM lifecycle (7) + +| Tool | Description | +|------|-------------| +| `proxmox_vm_start` | Start a VM or container. | +| `proxmox_vm_stop` | Stop (clean or forced). | +| `proxmox_vm_restart` | Restart. | +| `proxmox_vm_create` | Provision a new VM or container. | +| `proxmox_vm_clone` | Clone an existing one. | +| `proxmox_vm_migrate` | Migrate across nodes. | +| `proxmox_vm_config` | Read or update configuration. | + +### Proxmox — system (5) + +| Tool | Description | +|------|-------------| +| `proxmox_storage_status` | Storage pool status. | +| `proxmox_network_config` | Network configuration per node. | +| `proxmox_exec_command` | Command inside a VM or container (sync, via QEMU Guest Agent). | +| `proxmox_exec_command_async` | Long-running command (async). | +| `proxmox_exec_get_result` | Fetch the result of an async command. | + +### SSH fallback (4) + +| Tool | Description | +|------|-------------| +| `ssh_exec_command` | Command on a host (sync). `host` accepts node names, VMIDs, hostnames, or IPs. | +| `ssh_exec_command_async` | Long-running command (async). | +| `ssh_exec_get_result` | Fetch the result of an async SSH command. | +| `ssh_list_sessions` | List active and recent SSH sessions. | + +### BMC — hardware management (8) + +| Tool | Description | +|------|-------------| +| `bmc_list_devices` | List configured BMCs (`id`, `type`). Call first to discover valid `device_id` values. | +| `bmc_server_info` | Server model, serial, firmware. | +| `bmc_health_status` | Temperatures, fans, power supplies, disks, memory. | +| `bmc_power_status` | Current physical power state. | +| `bmc_power_on` | Power on. | +| `bmc_power_off` | ACPI shutdown (or `force=true` to cut power). | +| `bmc_power_reset` | Hard reset. | +| `bmc_get_event_log` | BMC event log (default 50, max 200). | + +Each `bmc_*` action tool takes a `device_id` argument. When only one device is configured, `device_id` is optional and defaults to that device. --- ## Tests -Tests unitaires (`pytest`) pour le dashboard + tests d'intégration (`python tests/test_integration.py`) qui tapent la vraie infra. Détails des sections, flags CLI et prérequis : [docs/tests.md](docs/tests.md). - ---- - -## Outils disponibles - -### Proxmox -- Monitoring (6) - -| Outil | Description | -|-------|-------------| -| `proxmox_list_nodes` | Liste les nœuds avec leur statut | -| `proxmox_node_status` | CPU, RAM, disque, uptime d'un nœud | -| `proxmox_list_vms` | Liste toutes les VMs/CTs | -| `proxmox_vm_status` | État détaillé d'une VM/CT | -| `proxmox_get_logs` | Logs système ou tâches | -| `proxmox_get_tasks` | Tâches récentes | - -### Proxmox -- Gestion VMs (7) - -| Outil | Description | -|-------|-------------| -| `proxmox_vm_start` | Démarrer une VM/CT | -| `proxmox_vm_stop` | Arrêter (clean ou force) | -| `proxmox_vm_restart` | Redémarrer | -| `proxmox_vm_create` | Créer une VM/CT | -| `proxmox_vm_clone` | Cloner | -| `proxmox_vm_migrate` | Migrer vers un autre nœud | -| `proxmox_vm_config` | Lire/modifier la config | - -### Proxmox -- Système (5) - -| Outil | Description | -|-------|-------------| -| `proxmox_storage_status` | État du stockage | -| `proxmox_network_config` | Config réseau du nœud | -| `proxmox_exec_command` | Commande dans une VM/CT (sync) | -| `proxmox_exec_command_async` | Commande longue (async) | -| `proxmox_exec_get_result` | Résultat d'une commande async | - -### SSH (4) - -| Outil | Description | -|-------|-------------| -| `ssh_exec_command` | Commande sur un hôte (sync) | -| `ssh_exec_command_async` | Commande longue (async) | -| `ssh_exec_get_result` | Résultat d'une commande async | -| `ssh_list_sessions` | Sessions SSH actives | - -### iLO (7) - -| Outil | Description | -|-------|-------------| -| `ilo_server_info` | Modèle, serial, firmware | -| `ilo_health_status` | Températures, ventilateurs, alims, disques | -| `ilo_power_status` | État d'alimentation (ON/OFF) | -| `ilo_power_on` | Allumer le serveur | -| `ilo_power_off` | Éteindre (clean ou force) | -| `ilo_power_reset` | Hard reset | -| `ilo_get_event_log` | Journal d'événements iLO | +The project ships unit tests (`pytest`) for the dashboard and configuration, plus an integration script (`python tests/test_integration.py`) that exercises a live Proxmox cluster. Flags, prerequisites, and fixtures are documented in [docs/tests.md](docs/tests.md). --- -## Dépannage & exemples +## Troubleshooting -Tableau des erreurs courantes, causes et correctifs — plus quelques scénarios d'usage type — dans [docs/troubleshooting.md](docs/troubleshooting.md). +Common errors, their causes, and the fixes that worked are in [docs/troubleshooting.md](docs/troubleshooting.md). --- -## Licence +## License [Apache 2.0](LICENSE) - -
-Construit avec Claude Code -
diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example new file mode 100644 index 0000000..1632a43 --- /dev/null +++ b/beaconmcp.yaml.example @@ -0,0 +1,96 @@ +# BeaconMCP configuration file. +# +# Path resolution: --config flag > BEACONMCP_CONFIG env > ./beaconmcp.yaml +# > /etc/beaconmcp/config.yaml. +# +# Secrets use ${VAR_NAME} references resolved against the process environment +# at load time. Put them in a .env file next to this one (loaded by the CLI) +# or export them through your init system. Missing references fail fast with +# the offending YAML path. + +version: 1 + +server: + host: 0.0.0.0 + port: 8420 + # Host header allowlist for DNS-rebinding protection. Include the public + # FQDN behind the reverse proxy; 127.0.0.1 and localhost are already safe. + allowed_hosts: + - mcp.example.com + - "127.0.0.1:*" + - "localhost:*" + - "[::1]:*" + # CORS origin allowlist. + allowed_origins: + - https://claude.ai + - https://chat.openai.com + - https://gemini.google.com + clients_file: /opt/beaconmcp/clients.json + session_key: ${BEACONMCP_SESSION_KEY} # optional, generated if omitted + +proxmox: + verify_ssl: false + # One entry per Proxmox node. N nodes supported; the first one is not special. + # Each node needs an API token (Datacenter > Permissions > API Tokens). + nodes: + - name: pve1 + host: pve1.example.com + token_id: root@pam!beaconmcp + token_secret: ${PVE1_TOKEN_SECRET} + + - name: pve2 + host: pve2.example.com + token_id: root@pam!beaconmcp + token_secret: ${PVE2_TOKEN_SECRET} + +ssh: + user: root + password: ${SSH_PASSWORD} + # Template used to resolve bare numeric IDs (VMIDs) to IP addresses in + # ssh_* tools, e.g. "192.168.1.{id}". Omit or set to null to disable the + # numeric-ID fallback — callers must then supply a hostname or IP directly. + vmid_to_ip: "192.168.1.{id}" + +bmc: + # N BMC (Baseboard Management Controller) devices, typed by backend: + # hp_ilo -> HP iLO 4/5 (via python-hpilo, optionally SSH-tunneled) + # ipmi -> generic IPMI 2.0 via ipmitool (requires ipmitool installed) + # idrac -> Dell iDRAC (stub; TODO) + # supermicro -> Supermicro BMC (stub; TODO) + devices: + - id: rack1-ilo + type: hp_ilo + host: 192.168.10.10 + user: Administrator + password: ${RACK1_ILO_PASSWORD} + jump_host: pve1 # optional; references proxmox.nodes[].name + + - id: rack2-bmc + type: ipmi + host: 192.168.10.11 + user: admin + password: ${RACK2_IPMI_PASSWORD} + # no jump_host = direct connection + +features: + dashboard: + enabled: true + gemini_api_key: ${GEMINI_API_KEY} # empty disables the chat panel + limits: + per_5h_usd: 2.0 + per_week_usd: 10.0 + public_url: https://mcp.example.com # used to generate MCP URLs in the UI + mcp_mode: local # "local" (default) or "remote" + ssh: + enabled: true + +# Free-form infrastructure context exposed as an MCP resource. Edit freely: +# the LLM reads this to understand your topology, naming conventions, and +# operational notes. +infrastructure: + conventions: + vmid_to_ip: "VMIDs map 1:1 to 192.168.1.{VMID}" + naming: "VMs are prefixed by role (web-101, db-102)" + notes: + - "API tokens must be created on each Proxmox node before use." + - "BMC devices are reachable on a private management VLAN." diff --git a/deploy/beaconmcp.service b/deploy/beaconmcp.service new file mode 100644 index 0000000..49a8267 --- /dev/null +++ b/deploy/beaconmcp.service @@ -0,0 +1,15 @@ +[Unit] +Description=BeaconMCP - Proxmox MCP Server +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/beaconmcp +EnvironmentFile=/opt/beaconmcp/.env +ExecStart=/opt/beaconmcp/.venv/bin/python -m beaconmcp serve +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/install.sh b/deploy/install.sh index dfb0170..35df92d 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,90 +1,101 @@ #!/bin/bash -# TarkaMCP - Installation rapide sur un noeud Proxmox +# BeaconMCP - quick install on a Linux host (tested on Debian/Ubuntu) # Usage: bash deploy/install.sh set -e -INSTALL_DIR="/opt/tarkamcp" -REPO="https://github.com/Showdown76py/TarkaMCP.git" +INSTALL_DIR="/opt/beaconmcp" +REPO="https://github.com/Showdown76py/BeaconMCP.git" VENV_DIR="$INSTALL_DIR/.venv" -echo "=== TarkaMCP - Installation ===" +echo "=== BeaconMCP - Installation ===" -# 1. Dépendances système -echo "[*] Vérification des dépendances système..." +# 1. System dependencies +echo "[*] Checking system dependencies..." apt-get update -qq apt-get install -y python3 python3-pip python3-venv git -# Paquet venv versionné (ex: python3.11-venv sur Debian 12) +# Versioned venv package (e.g. python3.11-venv on Debian 12) PY_VER=$(python3 -c 'import sys; print(f"python{sys.version_info.major}.{sys.version_info.minor}")') apt-get install -y "${PY_VER}-venv" 2>/dev/null || true -# 2. Cloner ou mettre à jour +# 2. Clone or update if [ -d "$INSTALL_DIR/.git" ]; then - echo "[*] Mise à jour de TarkaMCP..." + echo "[*] Updating BeaconMCP..." cd "$INSTALL_DIR" && git pull else - echo "[*] Clonage de TarkaMCP..." + echo "[*] Cloning BeaconMCP..." git clone "$REPO" "$INSTALL_DIR" cd "$INSTALL_DIR" fi -# 3. Environnement virtuel Python +# 3. Python virtual environment if [ ! -x "$VENV_DIR/bin/pip" ]; then - echo "[*] (Re)création du virtual env Python..." + echo "[*] (Re)creating the Python virtualenv..." rm -rf "$VENV_DIR" python3 -m venv "$VENV_DIR" fi -# 4. Installer les dépendances dans le venv -echo "[*] Installation des dépendances Python..." +# 4. Install Python dependencies +echo "[*] Installing Python dependencies..." "$VENV_DIR/bin/pip" install --upgrade pip --quiet "$VENV_DIR/bin/pip" install -e . --quiet -# 5. Fichier .env +# 5. .env file if [ ! -f "$INSTALL_DIR/.env" ]; then - echo "[*] Création du fichier .env..." + echo "[*] Creating .env..." cp .env.example .env - echo " .env créé. Remplis-le avec tes credentials Proxmox." + echo " .env created. Fill it with your Proxmox credentials and BMC secrets." else - echo "[*] .env existant conservé." + echo "[*] Existing .env preserved." fi -# 5.b TARKAMCP_SESSION_KEY pour le dashboard (chiffrement client_secret au repos) -if ! grep -q "^TARKAMCP_SESSION_KEY=" "$INSTALL_DIR/.env"; then +# 5.b beaconmcp.yaml config file +if [ ! -f "$INSTALL_DIR/beaconmcp.yaml" ]; then + echo "[*] Creating beaconmcp.yaml..." + cp beaconmcp.yaml.example beaconmcp.yaml + echo " beaconmcp.yaml created. Edit it to describe your topology." +else + echo "[*] Existing beaconmcp.yaml preserved." +fi + +# 5.c BEACONMCP_SESSION_KEY for the dashboard (encrypts client_secret at rest) +if ! grep -q "^BEACONMCP_SESSION_KEY=" "$INSTALL_DIR/.env"; then SESSION_KEY=$(openssl rand -base64 32) echo "" >> "$INSTALL_DIR/.env" echo "# Auto-generated by install.sh -- DO NOT regenerate or all dashboard sessions invalidate." >> "$INSTALL_DIR/.env" - echo "TARKAMCP_SESSION_KEY=$SESSION_KEY" >> "$INSTALL_DIR/.env" - echo "[*] TARKAMCP_SESSION_KEY généré dans .env." + echo "BEACONMCP_SESSION_KEY=$SESSION_KEY" >> "$INSTALL_DIR/.env" + echo "[*] BEACONMCP_SESSION_KEY generated in .env." fi if ! grep -q "^GEMINI_API_KEY=" "$INSTALL_DIR/.env"; then - echo "[!] GEMINI_API_KEY absent du .env -- le dashboard chat sera désactivé." - echo " Pour l'activer, ajoute GEMINI_API_KEY=... dans /opt/tarkamcp/.env" + echo "[!] GEMINI_API_KEY missing from .env -- the dashboard chat will be disabled." + echo " Add GEMINI_API_KEY=... to /opt/beaconmcp/.env to enable it." fi -# 6. Wrapper tarkamcp dans /usr/local/bin -echo "[*] Installation du wrapper 'tarkamcp' dans /usr/local/bin..." -cat > /usr/local/bin/tarkamcp < /usr/local/bin/beaconmcp </app/...`). Three pages: -- **`/app/login`** — récupère un bearer MCP via Client ID + Secret + TOTP, session persistée 90 jours dans un cookie HttpOnly. Plus de curl sur téléphone. -- **`/app/chat`** — chat multi-conversations avec Gemini 2.5 Flash/Pro (stable) ou Gemini 3 Flash / 3.1 Pro (preview, allowlist Google requise). **Nécessite `GEMINI_API_KEY`.** -- **`/app/tokens`** — génère des bearers nommés pour brancher TarkaMCP sur Gemini web, ChatGPT, Claude Desktop, etc. sans passer par curl. **Fonctionne sans `GEMINI_API_KEY`.** +- **`/app/login`** — exchanges a client id + client secret + TOTP code for an MCP bearer, and stores it in a 90-day HttpOnly session cookie. Removes the need to issue `curl` requests from a phone. +- **`/app/chat`** — multi-conversation chat with Gemini 2.5 Flash/Pro (stable) or Gemini 3 Flash / 3.1 Pro (preview, Google allowlist required). **Requires `GEMINI_API_KEY`.** +- **`/app/tokens`** — generates named bearers so external MCP clients (Gemini web, ChatGPT, Claude Desktop) can be wired up without the OAuth dance. **Works without `GEMINI_API_KEY`.** -## Activation +## Enabling -Le dashboard est actif par défaut dès qu'une `TARKAMCP_SESSION_KEY` est posée. Deux modes : +The dashboard is on by default as long as a `BEACONMCP_SESSION_KEY` is set. Two modes: -| Mode | Condition | Pages actives | -|------|-----------|---------------| -| **Complet** | `GEMINI_API_KEY` défini | `/app/login`, `/app/chat`, `/app/tokens` | -| **Tokens only** | pas de `GEMINI_API_KEY` | `/app/login`, `/app/tokens` (le chat redirige vers tokens) | +| Mode | Condition | Active pages | +|------|-----------|--------------| +| **Full** | `GEMINI_API_KEY` set | `/app/login`, `/app/chat`, `/app/tokens` | +| **Tokens only** | `GEMINI_API_KEY` absent | `/app/login`, `/app/tokens` (chat redirects to tokens) | -1. (optionnel) Ajouter une clé Gemini pour activer le chat intégré : +1. *(optional)* Add a Gemini API key to enable the integrated chat: ```env GEMINI_API_KEY=... ``` -2. La clé de chiffrement de session (`TARKAMCP_SESSION_KEY`) est auto-générée par `install.sh` au premier run. En déploiement manuel : +2. The session encryption key (`BEACONMCP_SESSION_KEY`) is generated by `install.sh` on first run. For manual deployments: ```bash - echo "TARKAMCP_SESSION_KEY=$(openssl rand -base64 32)" >> /opt/tarkamcp/.env + echo "BEACONMCP_SESSION_KEY=$(openssl rand -base64 32)" >> /opt/beaconmcp/.env ``` -3. Redémarrer : `systemctl restart tarkamcp`. +3. Restart: `systemctl restart beaconmcp`. + +At boot, the server logs the active dashboard URL and whether the chat is enabled: -Au démarrage, le serveur affiche : ``` Dashboard: http://0.0.0.0:8420/app/login (chat: enabled) ``` -…ou `(chat: disabled, tokens only)` si la clé Gemini manque, ou `disabled` si `TARKAMCP_DASHBOARD_ENABLED=false`. + +or `(chat: disabled, tokens only)` when the Gemini key is missing, or `disabled` when `BEACONMCP_DASHBOARD_ENABLED=false`. ## Flow -1. Tu ouvres `https://mcp.example.com/` sur ton téléphone → redirige vers `/app/login`. -2. Tu tapes Client ID + Client Secret + code TOTP (une seule fois tous les 90 jours). -3. Tu arrives sur `/app/chat` (ou directement sur `/app/tokens` en mode tokens only) avec l'historique de tes conversations. -4. Toutes les 24 h le bearer MCP expire — le dashboard redemande *juste* le code TOTP (client_id et secret stockés chiffrés côté serveur). +1. Navigate to `https:///` on any device — the root path redirects to `/app/login`. +2. Enter client id + client secret + current TOTP code (once per 90-day window). +3. Land on `/app/chat` (or `/app/tokens` in tokens-only mode) with the conversation history restored. +4. Every 24 hours the underlying MCP bearer expires. The dashboard prompts only for a fresh TOTP code on `/app/refresh`; the client id and secret remain encrypted server-side. + +## Tokens page + +Reached from the sidebar or directly at `/app/tokens`. Intended for users wiring BeaconMCP into an external MCP client rather than using the integrated chat. -## Page Tokens API +It exposes: -Accessible via la card "Tokens API" dans la sidebar du chat, ou directement à `/app/tokens`. Conçue pour les utilisateurs qui branchent TarkaMCP sur un client MCP externe plutôt que d'utiliser le chat intégré. +- The MCP URL to paste into the external client (with a copy button). +- A creation form that requires a **name** (60 characters max, e.g. "Gemini Web", "ChatGPT macOS") plus the current TOTP code. +- The newly generated token, shown **once** in an orange card with a copy button. Reloading the page removes it from view. +- The list of active tokens: name, 12-character prefix, hours until expiry, revoke button. -Elle affiche : -- L'URL MCP à coller dans le client externe (bouton Copier). -- Un formulaire de création qui **exige un nom** (max 60 caractères, ex. "Gemini Web", "ChatGPT macOS") + le code TOTP courant. -- Le token généré une **seule fois** dans une card orange avec bouton Copier — après rechargement il n'est plus affiché. -- La liste des tokens actifs : nom, préfixe 12 car, heures avant expiration, bouton Révoquer. +Constraints: -Contraintes : +| Setting | Value | +|---------|-------| +| Expiry | **24 h** (inherits `TokenStore.TOKEN_TTL`) | +| Per-client cap | **3 active tokens** | +| TOTP | Re-verified on every creation | +| Revocation | By prefix (6 chars minimum), scoped to the owning client | +| Storage | **In-memory** — `systemctl restart` invalidates every token | -| Paramètre | Valeur | -|-----------|--------| -| Expiration | **24 h** (hérité de `TokenStore.TOKEN_TTL`) | -| Cap par client | **3 tokens actifs** maximum | -| TOTP | Re-vérifié à chaque création | -| Révocation | Par préfixe (≥ 6 car), scoped au client propriétaire | -| Stockage | **En mémoire** — un `systemctl restart` invalide tous les tokens | +## Chat — models and thinking -## Chat : modèles & thinking +- **Gemini 2.5 Flash / Pro** — available on every AI Studio key. **Used by default** (`gemini-2.5-flash`). +- **Gemini 3 Flash / 3.1 Pro (preview)** — gated by a Google allowlist. Without allowlist access, the dashboard surfaces a clear message pointing back to 2.5. +- **Thinking effort** — dropdown with `minimal` / `low` / `medium` / `high`. `gemini-2.5-pro` cannot disable thinking, so `minimal` is clamped to the 128-token floor automatically. +- **Markdown rendering** — the client parses headings (`#`–`######`), ordered and unordered lists, blockquotes, horizontal rules, code fences (with `lang-*` class), inline code, bold/italic/strikethrough, and HTTP(S) links. -- **Gemini 2.5 Flash / Pro** : dispos sur toute clé AI Studio. **Utilisés par défaut** (`gemini-2.5-flash`). -- **Gemini 3 Flash / 3.1 Pro (preview)** : gated par allowlist Google. Sans allowlist, le dashboard affiche un message clair indiquant de rebasculer sur 2.5. -- **Effort de thinking** : `minimal` / `low` / `medium` / `high` via dropdown. `gemini-2.5-pro` ne peut pas désactiver le thinking — le budget est automatiquement clampé à 128 tokens minimum. -- **Rendu markdown** : le client parse les headings (`#`–`######`), listes ordonnées/non-ordonnées, blockquotes, horizontal rules, code fences (avec `lang-*` class), inline code, bold/italic/strike et links HTTP(S). +## Mandatory confirmation for shell-capable tools -## Confirmation obligatoire pour les outils qui font du shell +`ssh_exec_command`, `ssh_exec_command_async`, `proxmox_exec_command`, and `proxmox_exec_command_async` **never** run without manual approval from the chat UI. When Gemini calls one of them: -`ssh_exec_command`, `ssh_exec_command_async`, `proxmox_exec_command` et `proxmox_exec_command_async` ne s'exécutent **jamais** sans clic manuel depuis le chat. Quand Gemini demande à lancer une de ces commandes : +1. The tool card switches to an "approval required" state (orange badge, auto-expanded so arguments are visible). +2. Two buttons: **Approve** / **Reject**. +3. The Gemini turn blocks server-side until the decision is made (5-minute timeout). +4. On rejection, Gemini receives a `FunctionResponse {"error": "user_rejected"}` and can revise its reply. -1. La tool-card apparaît en état "approbation requise" (badge orange, auto-ouverte pour voir les `args`). -2. Deux boutons : **Autoriser** / **Refuser**. -3. Tant que tu n'as pas cliqué, le turn Gemini reste bloqué côté serveur (timeout à 5 min). -4. Si tu refuses, Gemini reçoit un `FunctionResponse {"error": "user_rejected"}` et peut adapter sa réponse. +The allow-list is hard-coded in `src/beaconmcp/dashboard/chat.py` (`_NEEDS_CONFIRMATION`). Only the integrated chat enforces this gate; external MCP clients (Claude Desktop, Gemini CLI, ChatGPT MCP) must enable their own per-call approval mode (see the **Security** section of the root README). -L'allow-list est hardcodée dans `src/tarkamcp/dashboard/chat.py` (`_NEEDS_CONFIRMATION`). Seul le chat intégré applique cette confirmation — les clients MCP externes (Claude Desktop, Gemini CLI, ChatGPT MCP) doivent avoir leur propre mode "approuver chaque appel" activé côté client (voir la section **Sécurité** du README principal). +## Stored data -## Données stockées +SQLite at `/opt/beaconmcp/dashboard.db` (WAL mode). Five tables: -SQLite à `/opt/tarkamcp/dashboard.db` (WAL). Cinq tables : -- `sessions` — cookie → client_id + client_secret chiffré AES-GCM + bearer courant. -- `conversations` — titre, modèle, effort, client propriétaire. -- `messages` — user/assistant, contenu, tool_calls JSON, thinking résumé. -- `usage_events` — ledger per-turn : client_id, tokens (prompt/cached/output), coût USD, horodatage. -- `usage_5h_sessions` — une ligne par client avec la session 5h courante (matérialisée pour éviter un `GROUP BY` à chaque pré-check). +- `sessions` — cookie → client id + AES-GCM-encrypted client secret + current bearer. +- `conversations` — title, model, effort, owning client. +- `messages` — user/assistant, content, tool calls JSON, thinking summary. +- `usage_events` — per-turn ledger: client id, tokens (prompt/cached/output), USD cost, timestamp. +- `usage_5h_sessions` — one row per client holding the current 5-hour session (materialized to avoid a `GROUP BY` on every pre-check). -Tout est scopé par `client_id` ; tu peux avoir plusieurs sessions actives (téléphone + PC) pour un même client. +Everything is scoped by `client_id`; a single client can hold multiple active sessions (phone + laptop). -## Limites d'usage & coût +## Usage limits and cost -Chaque tour du chat calcule son coût USD à partir du `usage_metadata` renvoyé par Gemini (tokens input facturés au tarif cache-réduit quand `cachedContentTokenCount` est non-nul, ce que Gemini 2.5+ applique automatiquement via l'implicit caching dès que le prompt dépasse 1024 tokens pour Flash / 4096 pour Pro — zéro code à écrire côté client). +Each chat turn computes its USD cost from the `usage_metadata` returned by Gemini. Input tokens are billed at the cached-discount rate whenever `cachedContentTokenCount` is non-zero — Gemini 2.5+ applies implicit caching automatically once the prompt crosses 1024 tokens for Flash or 4096 tokens for Pro, with no client-side work. -Deux fenêtres sont appliquées **par client OAuth** : +Two windows are enforced **per OAuth client**: -| Fenêtre | Semantique | Variable d'env | Défaut | -|---------|-----------|----------------|--------| -| **5h** | Session Anthropic-style : ouvre au 1er message après ≥5h d'inactivité, dure 5h pile, puis ferme | `TARKAMCP_DASHBOARD_LIMIT_5H_USD` | `2.0` | -| **Semaine** | Somme rolling sur les 7 derniers jours glissants | `TARKAMCP_DASHBOARD_LIMIT_WEEK_USD` | `10.0` | +| Window | Semantics | Env variable | Default | +|--------|-----------|--------------|---------| +| **5h** | Anthropic-style session: opens on the first message after ≥5 h of idle time, lasts exactly 5 h, then closes. | `BEACONMCP_DASHBOARD_LIMIT_5H_USD` | `2.0` | +| **Week** | Rolling sum over the last 7 days. | `BEACONMCP_DASHBOARD_LIMIT_WEEK_USD` | `10.0` | -Poser la variable à `0` désactive la fenêtre correspondante. Au dépassement, la prochaine requête est rejetée **avant** d'être envoyée à Gemini, avec un message précisant l'heure de reset pour la fenêtre 5h. +Setting a variable to `0` disables that window. When a cap is exceeded, the next request is rejected **before** being sent to Gemini, with a message stating when the 5 h window resets. (When `beaconmcp.yaml` is used, the same caps are configured under `features.dashboard.limits`.) -Le panel chat affiche en footer une ligne discrète `5H XX% · 7J XX%` (pourcentage consommé par fenêtre), mise à jour après chaque tour via SSE `usage_update`. Cliquer la barre ouvre un modal style Claude avec barres de progression, heure de réinitialisation de la session 5h, label « fenêtre glissante 7j » et bouton « Actualiser ». +The chat footer shows a compact `5H XX% · 7D XX%` line updated after every turn via an SSE `usage_update` event. Clicking the bar opens a modal with progress bars, the 5 h reset time, a "rolling 7-day window" label, and a refresh button. -Tarifs utilisés (USD / 1M tokens, alignés sur le tarif public Google AI Studio au 2026-04-17) : +Rates used (USD per 1 M tokens, aligned with the public Google AI Studio pricing on 2026-04-17): -| Modèle | Input | Cached | Output | -|--------|-------|--------|--------| +| Model | Input | Cached | Output | +|-------|-------|--------|--------| | `gemini-2.5-flash` | $0.30 | $0.03 | $2.50 | | `gemini-2.5-pro` (≤200k / >200k) | $1.25 / $2.50 | $0.125 / $0.25 | $10.00 / $15.00 | | `gemini-3-flash-preview` | $0.50 | $0.05 | $3.00 | | `gemini-3.1-pro-preview` (≤200k / >200k) | $2.00 / $4.00 | $0.20 / $0.40 | $12.00 / $18.00 | -Les constantes vivent dans `src/tarkamcp/dashboard/usage.py` — mettre à jour si Google ajuste ses prix. +Constants live in `src/beaconmcp/dashboard/usage.py` — update them when Google adjusts its prices. -## Architecture MCP interne +## Internal MCP architecture -Le dashboard tient lui-même une session MCP (`streamablehttp_client` + `ClientSession`) vers le `/mcp` local (`http://127.0.0.1:8420/mcp`). Les outils sont convertis manuellement en `FunctionDeclaration` et la boucle `function_call` / `function_response` est orchestrée côté serveur (AFC SDK désactivé via `AutomaticFunctionCallingConfig(disable=True)`) pour contourner des bugs connus de google-genai sur Gemini 2.5 Pro avec MCP + streaming + thinking. +The dashboard keeps its own MCP session (`streamablehttp_client` + `ClientSession`) pointed at the local `/mcp` endpoint (`http://127.0.0.1:8420/mcp`). Tools are converted manually into `FunctionDeclaration` objects and the `function_call` / `function_response` loop is orchestrated server-side (AFC SDK disabled via `AutomaticFunctionCallingConfig(disable=True)`). This works around known `google-genai` bugs with Gemini 2.5 Pro + MCP + streaming + thinking. -> Un mode "remote" (McpServer backend-driven) existait mais a été désactivé : il produisait systématiquement des 500 INTERNAL à cause de la perte de l'header Authorization via Cloudflare Tunnel. Si `TARKAMCP_DASHBOARD_MCP_MODE=remote` est défini, le service affiche un warning au boot et chaque turn chat retourne un message d'erreur actionable. +> A remote mode (server-driven `McpServer`) exists but is disabled: requests through Cloudflare Tunnel lose the `Authorization` header, producing systematic 500 INTERNAL responses. Setting `BEACONMCP_DASHBOARD_MCP_MODE=remote` logs a startup warning and returns an actionable error on every chat turn. -## Robustesse aux redémarrages +## Resilience to restarts -Le `TokenStore` est en mémoire : après `systemctl restart tarkamcp`, les bearers sont invalidés alors que les sessions dashboard (SQLite) persistent. Le dashboard détecte cela via `TokenStore.validate()` sur chaque route sensible ; si le bearer n'existe plus côté MCP mais que la session est encore timestamp-valide, l'utilisateur est redirigé vers `/app/refresh` pour retaper son TOTP et émettre un nouveau bearer. +`TokenStore` lives in memory. After `systemctl restart beaconmcp`, bearers are invalidated while dashboard sessions (SQLite) persist. The dashboard detects this by calling `TokenStore.validate()` on every sensitive route; when a bearer is gone but the session timestamp is still valid, the user is routed to `/app/refresh` to enter a fresh TOTP code and mint a new bearer. -Conséquence pour les tokens externes (`/app/tokens`) : un restart du service force toutes les intégrations Gemini web / ChatGPT / Claude Desktop à régénérer leur token. Si ça devient gênant, migrer le `TokenStore` vers SQLite (non fait actuellement). +Consequence for externally-issued tokens (`/app/tokens`): a service restart forces every Gemini-web / ChatGPT / Claude-Desktop integration to regenerate its token. If this is operationally annoying, move `TokenStore` to SQLite (not done today). -## Désactiver complètement +## Disabling entirely ```env -TARKAMCP_DASHBOARD_ENABLED=false +BEACONMCP_DASHBOARD_ENABLED=false ``` -Pour garder les tokens mais couper le chat : ne mets simplement pas `GEMINI_API_KEY`. +To keep the tokens page but drop the chat, leave `GEMINI_API_KEY` unset. diff --git a/docs/superpowers/specs/2026-04-16-tarkamcp-design.md b/docs/superpowers/specs/2026-04-16-beaconmcp-design.md similarity index 90% rename from docs/superpowers/specs/2026-04-16-tarkamcp-design.md rename to docs/superpowers/specs/2026-04-16-beaconmcp-design.md index 53a0210..875b2f8 100644 --- a/docs/superpowers/specs/2026-04-16-tarkamcp-design.md +++ b/docs/superpowers/specs/2026-04-16-beaconmcp-design.md @@ -1,8 +1,8 @@ -# TarkaMCP -- Proxmox Infrastructure MCP Server +# BeaconMCP -- Proxmox Infrastructure MCP Server ## Context -TarkaMCP is an MCP server that gives Claude direct access to a Proxmox VE infrastructure for diagnostics, VM management, system administration, and hardware management. The motivation: when a server crashes or misbehaves, Claude should be able to diagnose the issue, check hardware health, and propose/execute resolutions -- rather than the user having to manually SSH, check logs, and relay information back and forth. +BeaconMCP is an MCP server that gives Claude direct access to a Proxmox VE infrastructure for diagnostics, VM management, system administration, and hardware management. The motivation: when a server crashes or misbehaves, Claude should be able to diagnose the issue, check hardware health, and propose/execute resolutions -- rather than the user having to manually SSH, check logs, and relay information back and forth. **Infrastructure:** - **pve1.example.com** -- Proxmox VE node (active), exposed on the internet via HTTPS @@ -12,10 +12,10 @@ TarkaMCP is an MCP server that gives Claude direct access to a Proxmox VE infras ## Architecture -Single Python MCP server (`tarkamcp`) with modular design, running in **stdio** mode. Three core modules: +Single Python MCP server (`beaconmcp`) with modular design, running in **stdio** mode. Three core modules: ``` -src/tarkamcp/ +src/beaconmcp/ ├── __init__.py ├── __main__.py # Entry point ├── server.py # FastMCP server, registers all tools @@ -123,11 +123,11 @@ All configuration via environment variables, loaded from `.env` file by `python- ```env # Proxmox nodes -- API tokens (to be created on the Proxmox nodes) PVE1_HOST=pve1.example.com -PVE1_TOKEN_ID=root@pam!tarkamcp +PVE1_TOKEN_ID=root@pam!beaconmcp PVE1_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx PVE2_HOST=pve2.example.com -PVE2_TOKEN_ID=root@pam!tarkamcp +PVE2_TOKEN_ID=root@pam!beaconmcp PVE2_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # iLO -- single unit, local network only @@ -164,13 +164,13 @@ Add to `~/.claude/settings.json` or project `.claude/settings.json`: ```json { "mcpServers": { - "tarkamcp": { + "beaconmcp": { "command": "python", - "args": ["-m", "tarkamcp"], - "cwd": "/path/to/TarkaMCP/src", + "args": ["-m", "beaconmcp"], + "cwd": "/path/to/BeaconMCP/src", "env": { "PVE1_HOST": "pve1.example.com", - "PVE1_TOKEN_ID": "root@pam!tarkamcp", + "PVE1_TOKEN_ID": "root@pam!beaconmcp", "PVE1_TOKEN_SECRET": "..." } } @@ -228,11 +228,11 @@ notes: - "API tokens must be created on each Proxmox node before use" ``` -The server exposes this as `tarkamcp://infrastructure` -- a readable resource that provides Claude with the full infrastructure context. +The server exposes this as `beaconmcp://infrastructure` -- a readable resource that provides Claude with the full infrastructure context. ### MCP Prompt: Infrastructure Overview -The server registers an MCP prompt `tarkamcp-context` that injects a concise infrastructure summary into the conversation. This follows prompt engineering best practices (from `docs/prompt-engineering-guide.md`): +The server registers an MCP prompt `beaconmcp-context` that injects a concise infrastructure summary into the conversation. This follows prompt engineering best practices (from `docs/prompt-engineering-guide.md`): - Role definition: "You are managing a Proxmox VE infrastructure" - Context: node topology, naming conventions, access constraints - Positive instructions: what to check first, how to diagnose diff --git a/docs/superpowers/specs/2026-04-17-tarka-dashboard-design.md b/docs/superpowers/specs/2026-04-17-beaconmcp-dashboard-design.md similarity index 90% rename from docs/superpowers/specs/2026-04-17-tarka-dashboard-design.md rename to docs/superpowers/specs/2026-04-17-beaconmcp-dashboard-design.md index 852b870..5a35ee0 100644 --- a/docs/superpowers/specs/2026-04-17-tarka-dashboard-design.md +++ b/docs/superpowers/specs/2026-04-17-beaconmcp-dashboard-design.md @@ -1,4 +1,4 @@ -# TarkaMCP Dashboard — Design +# BeaconMCP Dashboard — Design **Date:** 2026-04-17 **Status:** Draft (awaiting review) @@ -6,17 +6,17 @@ ## 1. Goals -Add a web-accessible companion dashboard to TarkaMCP providing two surfaces: +Add a web-accessible companion dashboard to BeaconMCP providing two surfaces: - **Login panel** — collect `client_id` + `client_secret` + TOTP, exchange for an MCP bearer, and persist a browser session for 90 days so users (especially on mobile) no longer need to run `curl + oathtool` to obtain a token. -- **Chat panel** — multi-conversation chat interface powered by Gemini 3 Flash / 3.1 Pro, invoking TarkaMCP tools through the Gemini SDK's native MCP integration. Usable from phone or desktop. +- **Chat panel** — multi-conversation chat interface powered by Gemini 3 Flash / 3.1 Pro, invoking BeaconMCP tools through the Gemini SDK's native MCP integration. Usable from phone or desktop. ### Success criteria 1. From a phone browser, an operator can log in, open a chat, ask "status de pve2", and see tool-call results without typing in a shell. 2. Session survives closing the browser and coming back within 90 days; only TOTP is re-prompted every 24h. 3. UI feels smooth (streaming responses, no full-page reloads, < 100 ms perceived click latency). -4. The dashboard is optional — if `GEMINI_API_KEY` is absent, the rest of TarkaMCP runs unchanged. +4. The dashboard is optional — if `GEMINI_API_KEY` is absent, the rest of BeaconMCP runs unchanged. ### Non-goals (explicit) @@ -33,13 +33,13 @@ Add a web-accessible companion dashboard to TarkaMCP providing two surfaces: | Decision | Choice | |---|---| | Gemini API key location | Server-side env var `GEMINI_API_KEY`. Server proxies all Gemini calls. | -| Tool execution path | Approach **A** — Gemini SDK invoked with an `McpServer` tool pointing to `https://mcp.example.com/mcp` + user bearer in headers. Google's backend calls TarkaMCP directly. | +| Tool execution path | Approach **A** — Gemini SDK invoked with an `McpServer` tool pointing to `https://mcp.example.com/mcp` + user bearer in headers. Google's backend calls BeaconMCP directly. | | Session durability | 90-day HttpOnly server-side session cookie. TOTP re-prompt every 24 h to refresh MCP bearer. | | Conversation model | Multiple conversations with sidebar, server-persisted in SQLite. | | Thinking control | Gemini 3 effort presets: `minimal` / `low` / `medium` / `high`. Per-conversation setting. | | Tool-call visualization | Inline collapsible cards showing `name · duration · status`; expand for args + result preview. | | Frontend stack | Vanilla HTML + CSS custom properties + ES modules. Jinja2 server-rendered templates. Marked + DOMPurify vendored for markdown. No build step. | -| Chat history storage | SQLite at `/opt/tarkamcp/dashboard.db` (WAL, foreign keys). Single DB for sessions + conversations + messages. | +| Chat history storage | SQLite at `/opt/beaconmcp/dashboard.db` (WAL, foreign keys). Single DB for sessions + conversations + messages. | | Theme | Light / dark via `prefers-color-scheme`, slate-neutral palette + Proxmox orange `#e57000` accent. | | Model selector | Default `gemini-3-flash`; dropdown to switch to `gemini-3.1-pro`. Choice persisted per conversation. | | Iconography | Inline monochrome SVG icons (plus, chevron, arrow, ellipsis, status check/warn/spinner). **No Unicode emojis anywhere.** The "no AI slop" rule bans emojis 🎉✨🤖 etc., not vector icons. | @@ -47,7 +47,7 @@ Add a web-accessible companion dashboard to TarkaMCP providing two surfaces: ## 3. Module layout ``` -src/tarkamcp/ +src/beaconmcp/ dashboard/ NEW MODULE __init__.py register_dashboard_routes(app, ...) app.py Starlette routes: login, refresh, logout, chat, api/* @@ -69,7 +69,7 @@ src/tarkamcp/ __main__.py + mount dashboard routes when enabled ``` -The dashboard is mounted conditionally in `__main__.py::_run_http`, similar to how SSH / iLO modules register themselves. Requires `GEMINI_API_KEY` set. Can be disabled via `TARKAMCP_DASHBOARD_ENABLED=false`. +The dashboard is mounted conditionally in `__main__.py::_run_http`, similar to how SSH / iLO modules register themselves. Requires `GEMINI_API_KEY` set. Can be disabled via `BEACONMCP_DASHBOARD_ENABLED=false`. ## 4. URL routing @@ -118,7 +118,7 @@ CREATE INDEX idx_sessions_client ON sessions(client_id); ### Client secret encryption -- Master key: env var `TARKAMCP_SESSION_KEY` (32 bytes, base64-encoded). Generated by `deploy/install.sh` if absent. +- Master key: env var `BEACONMCP_SESSION_KEY` (32 bytes, base64-encoded). Generated by `deploy/install.sh` if absent. - Algorithm: AES-256-GCM via `cryptography.hazmat.primitives.ciphers.aead.AESGCM`. - Storage layout: `nonce(12 bytes) || ciphertext || tag`. - Rotation: not addressed in v1. Compromise recovery = wipe `sessions` table, rotate key, users log in again. @@ -126,7 +126,7 @@ CREATE INDEX idx_sessions_client ON sessions(client_id); ### Cookie ``` -Set-Cookie: tarkamcp_session=; +Set-Cookie: beaconmcp_session=; HttpOnly; Secure; SameSite=Strict; Path=/app; Max-Age=7776000 @@ -142,7 +142,7 @@ Set-Cookie: tarkamcp_session=; 3. ClientStore.verify_totp(client_id, totp) → 401 + increment fail count 4. token_store.issue(client_id) → (bearer, 86400) 5. Generate session_id = secrets.token_urlsafe(32) -6. AES-GCM encrypt client_secret with TARKAMCP_SESSION_KEY +6. AES-GCM encrypt client_secret with BEACONMCP_SESSION_KEY 7. INSERT INTO sessions (...) 8. Set cookie; 302 → /app/chat ``` @@ -167,35 +167,35 @@ If the session cookie is missing, unknown, or past `expires_at`: 302 → `/app/l 1. Load session 2. token_store.revoke(session.mcp_bearer) (existing 8s grace) 3. DELETE FROM sessions WHERE session_id=? -4. Clear-Site-Data: "cookies" + Set-Cookie tarkamcp_session=; Max-Age=0 +4. Clear-Site-Data: "cookies" + Set-Cookie beaconmcp_session=; Max-Age=0 5. 302 → /app/login ``` ### Multi-session and admin revocation - Multiple sessions per `client_id` (phone + desktop) are allowed. -- `tarkamcp auth revoke ` cascades: deletes the client, deletes all its sessions, revokes all its bearers. -- New CLI subcommand `tarkamcp dashboard sessions [--client-id X]` lists sessions with last-seen timestamps and supports `--kill `. +- `beaconmcp auth revoke ` cascades: deletes the client, deletes all its sessions, revokes all its bearers. +- New CLI subcommand `beaconmcp dashboard sessions [--client-id X]` lists sessions with last-seen timestamps and supports `--kill `. ### Security | Surface | Measure | |---|---| | Session cookie | HttpOnly, Secure, SameSite=Strict, Path=/app, Max-Age=7776000 | -| CSRF | Double-submit cookie `tarkamcp_csrf_token` (JS-readable, `SameSite=Strict`, `Path=/app`) + header `X-CSRF-Token` required on POST/PATCH/DELETE | +| CSRF | Double-submit cookie `beaconmcp_csrf_token` (JS-readable, `SameSite=Strict`, `Path=/app`) + header `X-CSRF-Token` required on POST/PATCH/DELETE | | Session fixation | Regenerate `session_id` at login | | Secret at rest | AES-256-GCM with env-derived key | -| Secret in logs | Logging filter redacts `sk_*` and `tarkamcp_*` tokens | +| Secret in logs | Logging filter redacts `sk_*` and `beaconmcp_*` tokens | | Login brute-force | Reuses existing 5-failure / 5-minute TOTP lockout | | Clickjacking | `X-Frame-Options: DENY` on `/app/*` | | MIME sniffing | `X-Content-Type-Options: nosniff` | | Referrer | `Referrer-Policy: strict-origin-when-cross-origin` | | CSP | `default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; img-src 'self' data:` | -| DNS rebinding | Existing `TransportSecuritySettings` + `TARKAMCP_ALLOWED_HOSTS` | +| DNS rebinding | Existing `TransportSecuritySettings` + `BEACONMCP_ALLOWED_HOSTS` | ## 6. Database schema -SQLite at `/opt/tarkamcp/dashboard.db` (overridable via `TARKAMCP_DASHBOARD_DB`). Mode WAL, `synchronous=NORMAL`, `foreign_keys=ON`. Versioned via `PRAGMA user_version`. +SQLite at `/opt/beaconmcp/dashboard.db` (overridable via `BEACONMCP_DASHBOARD_DB`). Mode WAL, `synchronous=NORMAL`, `foreign_keys=ON`. Versioned via `PRAGMA user_version`. ```sql CREATE TABLE sessions (...); -- see §5 @@ -292,7 +292,7 @@ Centered card layout, styled with the dashboard's light/dark palette (not couple ``` ┌──────────────┬──────────────────────────────────────────────┐ -│ TarkaMCP │ │ +│ BeaconMCP │ │ │ + Nouveau │ [user bubble, right-aligned] │ │ ───────── │ │ │ > pve2 down? │ Gemini 3 Flash · low │ @@ -301,7 +301,7 @@ Centered card layout, styled with the dashboard's light/dark palette (not couple │ │ ▸ proxmox_list_vms · 180 ms · ok │ │ │ │ │ ───────── │ ┌──────────────────────────────────────────┐ │ -│ Modèle │ │ Envoyer un message à TarkaMCP… │ │ +│ Modèle │ │ Envoyer un message à BeaconMCP… │ │ │ Flash ▼ │ └──────────────────────────────────────────┘ │ │ Effort: low │ Flash ▼ · effort ▼ [Envoyer] │ │ Déconnexion │ │ @@ -359,7 +359,7 @@ Centered card layout, styled with the dashboard's light/dark palette (not couple ### Composer -- Auto-growing ` -
@@ -137,16 +137,16 @@

Nouveau chat

-

URL du serveur MCP

+

MCP server URL

{{ mcp_url }} -
@@ -32,27 +32,27 @@

URL du serveur MCP

{% if just_created %}
-

Nouveau token créé

+

New token created

- Token pour {{ just_created.name }}. Copie-le maintenant — - il ne sera plus jamais affiché. + Token for {{ just_created.name }}. Copy it now — it + will not be shown again.

{{ just_created.token }}

- Dans l'en-tête HTTP de ton client MCP : Authorization: Bearer {{ just_created.token[:8] }}… + In your MCP client's HTTP header: Authorization: Bearer {{ just_created.token[:8] }}…

{% endif %}
-

Créer un token ({{ count }}/{{ cap }})

+

Create a token ({{ count }}/{{ cap }})

{% if form_error %} @@ -63,31 +63,31 @@

Créer un token ({{ count }}/{{ cap }})

- +
{% else %}

- Tu as atteint la limite de {{ cap }} tokens actifs. Révoque un token - existant pour en créer un nouveau. + You reached the limit of {{ cap }} active tokens. Revoke an existing + token before creating a new one.

{% endif %}
-

Tokens actifs

+

Active tokens

{% if tokens %}
    {% for t in tokens %} @@ -97,21 +97,21 @@

    Tokens actifs

    {{ t.prefix }}…
- expire dans {{ t.expires_in_hours }} h + expires in {{ t.expires_in_hours }} h
-
{% endfor %} {% else %} -

Aucun token actif pour le moment.

+

No active tokens yet.

{% endif %} @@ -131,7 +131,7 @@

Tokens actifs

try { await navigator.clipboard.writeText(value); const original = btn.textContent; - btn.textContent = "Copié ✓"; + btn.textContent = "Copied ✓"; setTimeout(() => { btn.innerHTML = original; }, 1500); } catch (err) { console.error(err); diff --git a/src/tarkamcp/dashboard/templates/totp_refresh.html b/src/beaconmcp/dashboard/templates/totp_refresh.html similarity index 71% rename from src/tarkamcp/dashboard/templates/totp_refresh.html rename to src/beaconmcp/dashboard/templates/totp_refresh.html index f1ee6bb..351dd76 100644 --- a/src/tarkamcp/dashboard/templates/totp_refresh.html +++ b/src/beaconmcp/dashboard/templates/totp_refresh.html @@ -1,11 +1,11 @@ {% extends "base.html" %} -{% block title %}Code 2FA · TarkaMCP{% endblock %} +{% block title %}2FA code · BeaconMCP{% endblock %} {% block body_class %}centered-page{% endblock %} {% block body %}
-

TarkaMCP

-

Session active pour {{ client_name }}

-

Le token MCP a expiré. Saisis le code 2FA pour le renouveler.

+

BeaconMCP

+

Session active for {{ client_name }}

+

The MCP token expired. Enter the 2FA code to renew it.

{% if banner %} @@ -16,19 +16,19 @@

TarkaMCP

{% if next %}{% endif %} - +
- +
{% endblock %} diff --git a/src/tarkamcp/dashboard/usage.py b/src/beaconmcp/dashboard/usage.py similarity index 100% rename from src/tarkamcp/dashboard/usage.py rename to src/beaconmcp/dashboard/usage.py diff --git a/src/tarkamcp/ilo/__init__.py b/src/beaconmcp/proxmox/__init__.py similarity index 100% rename from src/tarkamcp/ilo/__init__.py rename to src/beaconmcp/proxmox/__init__.py diff --git a/src/tarkamcp/proxmox/client.py b/src/beaconmcp/proxmox/client.py similarity index 100% rename from src/tarkamcp/proxmox/client.py rename to src/beaconmcp/proxmox/client.py diff --git a/src/tarkamcp/proxmox/monitoring.py b/src/beaconmcp/proxmox/monitoring.py similarity index 100% rename from src/tarkamcp/proxmox/monitoring.py rename to src/beaconmcp/proxmox/monitoring.py diff --git a/src/tarkamcp/proxmox/system.py b/src/beaconmcp/proxmox/system.py similarity index 100% rename from src/tarkamcp/proxmox/system.py rename to src/beaconmcp/proxmox/system.py diff --git a/src/tarkamcp/proxmox/vms.py b/src/beaconmcp/proxmox/vms.py similarity index 100% rename from src/tarkamcp/proxmox/vms.py rename to src/beaconmcp/proxmox/vms.py diff --git a/src/tarkamcp/proxmox/__init__.py b/src/beaconmcp/security/__init__.py similarity index 100% rename from src/tarkamcp/proxmox/__init__.py rename to src/beaconmcp/security/__init__.py diff --git a/src/tarkamcp/security/tools.py b/src/beaconmcp/security/tools.py similarity index 100% rename from src/tarkamcp/security/tools.py rename to src/beaconmcp/security/tools.py diff --git a/src/tarkamcp/server.py b/src/beaconmcp/server.py similarity index 64% rename from src/tarkamcp/server.py rename to src/beaconmcp/server.py index d32c021..69a8ce8 100644 --- a/src/tarkamcp/server.py +++ b/src/beaconmcp/server.py @@ -6,21 +6,21 @@ from mcp.server.transport_security import TransportSecuritySettings from mcp.types import Icon +from .bmc import build_registry as build_bmc_registry +from .bmc import register_bmc_tools from .config import Config from .proxmox.client import ProxmoxClient from .proxmox.monitoring import register_monitoring_tools -from .proxmox.vms import register_vm_tools from .proxmox.system import register_system_tools +from .proxmox.vms import register_vm_tools +from .security.tools import register_security_tools from .ssh.client import SSHClient from .ssh.tools import register_ssh_tools -from .ilo.client import ILOClient -from .ilo.tools import register_ilo_tools -from .security.tools import register_security_tools -config = Config.from_env() +config = Config.load() proxmox_client = ProxmoxClient(config) ssh_client = SSHClient(config) -ilo_client = ILOClient(config) +bmc_registry = build_bmc_registry(config) def _csv_env(name: str, default: list[str]) -> list[str]: @@ -31,14 +31,15 @@ def _csv_env(name: str, default: list[str]) -> list[str]: # DNS-rebinding protection: the MCP SDK rejects any Host header that is not -# explicitly allowlisted. The public hostname this server is reverse-proxied -# behind (e.g. mcp.example.com) MUST be set via TARKAMCP_ALLOWED_HOSTS. -_allowed_hosts = _csv_env( - "TARKAMCP_ALLOWED_HOSTS", +# explicitly allowlisted. The public hostname behind the reverse proxy MUST +# appear either in ``server.allowed_hosts`` in beaconmcp.yaml or in the +# legacy BEACONMCP_ALLOWED_HOSTS env var. +_allowed_hosts = config.server.allowed_hosts or _csv_env( + "BEACONMCP_ALLOWED_HOSTS", ["127.0.0.1:*", "localhost:*", "[::1]:*"], ) -_allowed_origins = _csv_env( - "TARKAMCP_ALLOWED_ORIGINS", +_allowed_origins = config.server.allowed_origins or _csv_env( + "BEACONMCP_ALLOWED_ORIGINS", ["https://claude.ai", "https://chat.openai.com", "https://gemini.google.com"], ) @@ -64,15 +65,16 @@ def _load_icons() -> list[Icon]: mcp = FastMCP( - "tarkamcp", + "beaconmcp", instructions=( - "TarkaMCP provides tools to manage a Proxmox VE infrastructure. " - "Use proxmox_* tools for VM/CT management and diagnostics, " - "ilo_* tools for hardware management (power, health), " - "and ssh_* tools for direct shell access as fallback. " - "Start with proxmox_list_nodes to see cluster status." + "BeaconMCP exposes a Proxmox VE cluster (N nodes), N BMC devices " + "(HP iLO, IPMI, iDRAC, Supermicro), and an SSH fallback as a single " + "MCP server. Use proxmox_* tools for VM/CT management and " + "diagnostics, bmc_* tools for hardware power and health, and ssh_* " + "tools for direct shell access. Start with proxmox_list_nodes to " + "see the cluster and bmc_list_devices to see hardware endpoints." ), - website_url="https://github.com/Showdown76py/TarkaMCP", + website_url="https://github.com/Showdown76py/BeaconMCP", icons=_load_icons(), transport_security=TransportSecuritySettings( enable_dns_rebinding_protection=True, @@ -82,11 +84,11 @@ def _load_icons() -> list[Icon]: ) -@mcp.resource("tarkamcp://infrastructure") +@mcp.resource("beaconmcp://infrastructure") def get_infrastructure() -> str: """Infrastructure context: node topology, naming conventions, and access constraints.""" if not config.infrastructure: - return "No infrastructure.yaml configured." + return "No infrastructure context configured." import yaml @@ -94,10 +96,13 @@ def get_infrastructure() -> str: @mcp.prompt() -def tarkamcp_context() -> str: +def beaconmcp_context() -> str: """Inject infrastructure context into the conversation for Proxmox management tasks.""" nodes_info = ", ".join(n.name for n in config.pve_nodes) - ilo_info = "iLO available (via SSH tunnel through pve1)" if config.ilo else "iLO not configured" + if bmc_registry: + bmc_info = ", ".join(f"{d.id} ({d.type})" for d in config.bmc_devices) + else: + bmc_info = "no BMC devices configured" ssh_info = "SSH fallback available" if config.ssh else "SSH not configured" infra = config.infrastructure @@ -112,7 +117,7 @@ def tarkamcp_context() -> str: return f"""You are managing a Proxmox VE infrastructure with the following topology: Nodes: {nodes_info} -Hardware: {ilo_info} +BMC: {bmc_info} Access: {ssh_info} Conventions: @@ -122,11 +127,14 @@ def tarkamcp_context() -> str: {notes} Diagnostic workflow: -1. Check cluster status with proxmox_list_nodes -2. For a specific node, use proxmox_node_status -3. If a node is unreachable via API, try ssh_exec_command on the host -4. If the host is completely unresponsive, use ilo_health_status and ilo_power_status -5. For in-VM issues, use proxmox_exec_command (QEMU Guest Agent) or ssh_exec_command""" +1. Check cluster state with proxmox_list_nodes. +2. For a specific node, use proxmox_node_status. +3. If a node is unreachable via API, try ssh_exec_command on the host. +4. If the host is completely unresponsive, list BMC devices with + bmc_list_devices and use bmc_health_status / bmc_power_status on the + matching one. +5. For in-VM issues, prefer proxmox_exec_command (QEMU Guest Agent) or + ssh_exec_command.""" # Register tool modules @@ -135,6 +143,6 @@ def tarkamcp_context() -> str: register_system_tools(mcp, proxmox_client) if config.ssh: register_ssh_tools(mcp, ssh_client) -if config.ilo: - register_ilo_tools(mcp, ilo_client) +if bmc_registry: + register_bmc_tools(mcp, bmc_registry) register_security_tools(mcp) diff --git a/src/tarkamcp/security/__init__.py b/src/beaconmcp/ssh/__init__.py similarity index 100% rename from src/tarkamcp/security/__init__.py rename to src/beaconmcp/ssh/__init__.py diff --git a/src/tarkamcp/ssh/client.py b/src/beaconmcp/ssh/client.py similarity index 80% rename from src/tarkamcp/ssh/client.py rename to src/beaconmcp/ssh/client.py index ddc8ab4..c887c6e 100644 --- a/src/tarkamcp/ssh/client.py +++ b/src/beaconmcp/ssh/client.py @@ -11,6 +11,18 @@ from ..config import Config +class SSHNotConfiguredError(Exception): + def __init__(self) -> None: + super().__init__( + "SSH credentials are not configured. Add an 'ssh:' section " + "to beaconmcp.yaml." + ) + + +class SSHHostResolutionError(Exception): + """Raised when a host identifier cannot be resolved to a target address.""" + + @dataclass class SSHExecSession: exec_id: str @@ -51,20 +63,32 @@ def resolve_host(self, host: str) -> str: """Resolve a host identifier to an actual hostname/IP. Accepts: - - Node name (pve1, pve2) -> resolved from config - - Numeric VMID (101) -> resolved to 192.168.1.{VMID} via convention - - Direct IP or hostname -> passed through + - Node name (e.g. pve1) -> resolved from ``proxmox.nodes`` config. + - Numeric VMID -> substituted into the ``ssh.vmid_to_ip`` template + (e.g. ``"192.168.1.{id}"``). Raises when the template is unset. + - Direct IP or hostname -> passed through unchanged. """ - # Check if it's a configured node name node_host = self._config.get_node_host(host) if node_host: return node_host - # Check if it's a numeric VMID if host.isdigit(): - return f"192.168.1.{host}" + template = self._config.ssh.vmid_to_ip if self._config.ssh else None + if not template: + raise SSHHostResolutionError( + f"Host {host!r} looks like a numeric VMID, but no " + "'ssh.vmid_to_ip' template is configured in beaconmcp.yaml. " + "Either set the template (e.g. '192.168.1.{id}') or pass " + "a hostname / IP directly." + ) + try: + return template.format(id=host) + except (KeyError, IndexError) as exc: + raise SSHHostResolutionError( + f"ssh.vmid_to_ip template {template!r} is invalid: {exc}. " + "Use '{id}' as the only placeholder." + ) from exc - # Direct IP/hostname return host async def _get_connection(self, host: str) -> asyncssh.SSHClientConnection: @@ -168,11 +192,3 @@ def list_sessions() -> list[dict[str, Any]]: } for s in _ssh_sessions.values() ] - - -class SSHNotConfiguredError(Exception): - def __init__(self) -> None: - super().__init__( - "SSH credentials are not configured. " - "Set SSH_USER and SSH_PASSWORD in your .env file." - ) diff --git a/src/tarkamcp/ssh/tools.py b/src/beaconmcp/ssh/tools.py similarity index 93% rename from src/tarkamcp/ssh/tools.py rename to src/beaconmcp/ssh/tools.py index 1b4fa61..17b216f 100644 --- a/src/tarkamcp/ssh/tools.py +++ b/src/beaconmcp/ssh/tools.py @@ -5,7 +5,7 @@ from mcp.server.fastmcp import FastMCP -from .client import SSHClient, SSHNotConfiguredError +from .client import SSHClient, SSHHostResolutionError, SSHNotConfiguredError def register_ssh_tools(mcp: FastMCP, ssh_client: SSHClient) -> None: @@ -23,7 +23,7 @@ async def ssh_exec_command(host: str, command: str, timeout: int = 60) -> dict[s timeout = min(timeout, 300) try: return await ssh_client.exec_command(host, command, timeout) - except SSHNotConfiguredError as e: + except (SSHNotConfiguredError, SSHHostResolutionError) as e: return {"error": str(e)} @mcp.tool() @@ -43,7 +43,7 @@ async def ssh_exec_command_async(host: str, command: str) -> dict[str, Any]: "resolved_host": ssh_client.resolve_host(host), "command": command, } - except SSHNotConfiguredError as e: + except (SSHNotConfiguredError, SSHHostResolutionError) as e: return {"error": str(e)} @mcp.tool() diff --git a/src/tarkamcp/config.py b/src/tarkamcp/config.py deleted file mode 100644 index 2e2ca37..0000000 --- a/src/tarkamcp/config.py +++ /dev/null @@ -1,105 +0,0 @@ -from __future__ import annotations - -import os -import sys -from dataclasses import dataclass -from pathlib import Path - -import yaml - - -@dataclass -class PVENode: - name: str - host: str - token_id: str - token_secret: str - - -@dataclass -class ILOConfig: - host: str - user: str - password: str - jump_host: str # Proxmox node name used as SSH tunnel - - -@dataclass -class SSHConfig: - user: str - password: str - - -@dataclass -class Config: - pve_nodes: list[PVENode] - ilo: ILOConfig | None - ssh: SSHConfig | None - verify_ssl: bool - infrastructure: dict - - @classmethod - def from_env(cls) -> Config: - # PVE1 is required - pve1_host = os.environ.get("PVE1_HOST", "") - pve1_token_id = os.environ.get("PVE1_TOKEN_ID", "") - pve1_token_secret = os.environ.get("PVE1_TOKEN_SECRET", "") - - if not pve1_host or not pve1_token_id or not pve1_token_secret: - print( - "ERROR: PVE1_HOST, PVE1_TOKEN_ID, and PVE1_TOKEN_SECRET are required.", - file=sys.stderr, - ) - sys.exit(1) - - nodes = [PVENode("pve1", pve1_host, pve1_token_id, pve1_token_secret)] - - # PVE2 is optional - pve2_host = os.environ.get("PVE2_HOST", "") - pve2_token_id = os.environ.get("PVE2_TOKEN_ID", "") - pve2_token_secret = os.environ.get("PVE2_TOKEN_SECRET", "") - if pve2_host and pve2_token_id and pve2_token_secret: - nodes.append(PVENode("pve2", pve2_host, pve2_token_id, pve2_token_secret)) - - # iLO is optional - ilo = None - ilo_host = os.environ.get("ILO_HOST", "") - ilo_user = os.environ.get("ILO_USER", "") - ilo_password = os.environ.get("ILO_PASSWORD", "") - ilo_jump = os.environ.get("ILO_JUMP_HOST", "pve1") - if ilo_host and ilo_user and ilo_password: - ilo = ILOConfig(ilo_host, ilo_user, ilo_password, ilo_jump) - - # SSH is optional - ssh = None - ssh_user = os.environ.get("SSH_USER", "") - ssh_password = os.environ.get("SSH_PASSWORD", "") - if ssh_user and ssh_password: - ssh = SSHConfig(ssh_user, ssh_password) - - verify_ssl = os.getenv("PVE_VERIFY_SSL", "false").lower() == "true" - - # Load infrastructure context - infra_path = Path(os.getenv("INFRA_YAML_PATH", "infrastructure.yaml")) - infrastructure = {} - if infra_path.exists(): - with open(infra_path) as f: - infrastructure = yaml.safe_load(f) or {} - - return cls( - pve_nodes=nodes, - ilo=ilo, - ssh=ssh, - verify_ssl=verify_ssl, - infrastructure=infrastructure, - ) - - def get_node(self, name: str) -> PVENode | None: - for node in self.pve_nodes: - if node.name == name: - return node - return None - - def get_node_host(self, name: str) -> str | None: - node = self.get_node(name) - return node.host if node else None diff --git a/src/tarkamcp/ilo/client.py b/src/tarkamcp/ilo/client.py deleted file mode 100644 index 4db58bf..0000000 --- a/src/tarkamcp/ilo/client.py +++ /dev/null @@ -1,193 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import Any - -import asyncssh -import hpilo - -from ..config import Config - - -_tunnel: asyncssh.SSHClientConnection | None = None -_tunnel_listener: Any = None -_tunnel_local_port: int | None = None - - -class ILOClient: - """HP iLO 4 client that connects through an SSH tunnel via a Proxmox node.""" - - def __init__(self, config: Config) -> None: - self._config = config - - async def _ensure_tunnel(self) -> int: - """Ensure the SSH tunnel to iLO is up and return the local port.""" - global _tunnel, _tunnel_listener, _tunnel_local_port - - if _tunnel is not None and not _tunnel.is_closed() and _tunnel_local_port is not None: - return _tunnel_local_port - - # Clean up old tunnel - if _tunnel_listener is not None: - _tunnel_listener.close() - if _tunnel is not None: - _tunnel.close() - - ilo_cfg = self._config.ilo - if not ilo_cfg: - raise ILONotConfiguredError() - - ssh_cfg = self._config.ssh - if not ssh_cfg: - raise ILOTunnelError( - "SSH credentials are required to tunnel to iLO. " - "Set SSH_USER and SSH_PASSWORD in your .env file." - ) - - # Get jump host details - jump_host = self._config.get_node_host(ilo_cfg.jump_host) - if not jump_host: - raise ILOTunnelError( - f"Jump host '{ilo_cfg.jump_host}' is not configured as a Proxmox node. " - f"Check ILO_JUMP_HOST in your .env file." - ) - - try: - _tunnel = await asyncssh.connect( - jump_host, - username=ssh_cfg.user, - password=ssh_cfg.password, - known_hosts=None, - ) - - # Forward local port to iLO's HTTPS port (443) - _tunnel_listener = await _tunnel.forward_local_port( - "", 0, # Bind to random available port - ilo_cfg.host, 443, - ) - _tunnel_local_port = _tunnel_listener.get_port() - return _tunnel_local_port - - except Exception as e: - _tunnel = None - _tunnel_listener = None - _tunnel_local_port = None - raise ILOTunnelError( - f"Failed to create SSH tunnel to iLO through '{ilo_cfg.jump_host}' ({jump_host}): {e}. " - f"Check that {ilo_cfg.jump_host} is reachable with proxmox_list_nodes first." - ) from e - - async def _call_ilo(self, method: str, **kwargs: Any) -> Any: - """Call an hpilo method through the SSH tunnel. - - python-hpilo is synchronous, so we run it in a thread executor. - """ - local_port = await self._ensure_tunnel() - ilo_cfg = self._config.ilo - if not ilo_cfg: - raise ILONotConfiguredError() - - def _sync_call() -> Any: - # IMPORTANT: use 127.0.0.1, NOT "localhost". python-hpilo treats the - # literal string "localhost" as a signal to switch to ILO_LOCAL mode - # (which shells out to the hponcfg utility on the local machine) and - # ignores host/port/credentials. We need remote RIBCL over our SSH - # tunnel, so bind to the loopback IP instead. - ilo = hpilo.Ilo( - "127.0.0.1", - port=local_port, - login=ilo_cfg.user, - password=ilo_cfg.password, - ssl_context=None, # Disable SSL verification for tunneled connection - ) - return getattr(ilo, method)(**kwargs) - - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, _sync_call) - - async def get_server_info(self) -> dict[str, Any]: - try: - product = await self._call_ilo("get_product_name") - serial = await self._call_ilo("get_server_name") - fw = await self._call_ilo("get_fw_version") - return { - "product_name": product, - "server_name": serial, - "firmware": fw, - } - except (ILONotConfiguredError, ILOTunnelError): - raise - except Exception as e: - return {"error": f"Failed to get server info from iLO: {e}"} - - async def get_health(self) -> dict[str, Any]: - try: - health = await self._call_ilo("get_embedded_health") - return health - except (ILONotConfiguredError, ILOTunnelError): - raise - except Exception as e: - return {"error": f"Failed to get health data from iLO: {e}"} - - async def get_power_status(self) -> dict[str, Any]: - try: - status = await self._call_ilo("get_host_power_status") - return {"power_status": status} - except (ILONotConfiguredError, ILOTunnelError): - raise - except Exception as e: - return {"error": f"Failed to get power status from iLO: {e}"} - - async def power_on(self) -> dict[str, Any]: - try: - await self._call_ilo("set_host_power", host_power=True) - return {"action": "power_on", "result": "success"} - except (ILONotConfiguredError, ILOTunnelError): - raise - except Exception as e: - return {"error": f"Failed to power on via iLO: {e}"} - - async def power_off(self, force: bool = False) -> dict[str, Any]: - try: - if force: - await self._call_ilo("set_host_power", host_power=False) - else: - await self._call_ilo("press_pwr_btn") - return {"action": "power_off", "force": force, "result": "success"} - except (ILONotConfiguredError, ILOTunnelError): - raise - except Exception as e: - return {"error": f"Failed to power off via iLO: {e}"} - - async def power_reset(self) -> dict[str, Any]: - try: - await self._call_ilo("reset_server") - return {"action": "power_reset", "result": "success"} - except (ILONotConfiguredError, ILOTunnelError): - raise - except Exception as e: - return {"error": f"Failed to reset server via iLO: {e}"} - - async def get_event_log(self, limit: int = 50) -> dict[str, Any]: - try: - log = await self._call_ilo("get_ilo_event_log") - if isinstance(log, list): - log = log[:limit] - return {"events": log, "total": len(log) if isinstance(log, list) else 0} - except (ILONotConfiguredError, ILOTunnelError): - raise - except Exception as e: - return {"error": f"Failed to get event log from iLO: {e}"} - - -class ILONotConfiguredError(Exception): - def __init__(self) -> None: - super().__init__( - "iLO credentials are not configured. " - "Set ILO_HOST, ILO_USER, and ILO_PASSWORD in your .env file." - ) - - -class ILOTunnelError(Exception): - def __init__(self, message: str) -> None: - super().__init__(message) diff --git a/src/tarkamcp/ilo/tools.py b/src/tarkamcp/ilo/tools.py deleted file mode 100644 index 960ffdf..0000000 --- a/src/tarkamcp/ilo/tools.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from mcp.server.fastmcp import FastMCP - -from .client import ILOClient, ILONotConfiguredError, ILOTunnelError - - -def register_ilo_tools(mcp: FastMCP, ilo_client: ILOClient) -> None: - """Register HP iLO hardware management tools.""" - - @mcp.tool() - async def ilo_server_info() -> dict[str, Any]: - """Get physical server information: model, serial number, firmware versions. - - Use to identify the hardware and check firmware levels. - Connects to iLO 4 through an SSH tunnel via pve1. - If this fails, pve1 may be unreachable -- check with proxmox_list_nodes first. - """ - try: - return await ilo_client.get_server_info() - except (ILONotConfiguredError, ILOTunnelError) as e: - return {"error": str(e)} - - @mcp.tool() - async def ilo_health_status() -> dict[str, Any]: - """Get full hardware health: temperatures, fans, power supplies, disks, memory status. - - Use when diagnosing hardware issues -- overheating, fan failures, disk errors, PSU problems. - This is the most important iLO tool for crash investigation. - Returns detailed sensor readings and component health status. - """ - try: - return await ilo_client.get_health() - except (ILONotConfiguredError, ILOTunnelError) as e: - return {"error": str(e)} - - @mcp.tool() - async def ilo_power_status() -> dict[str, Any]: - """Get the current physical power state of the server (ON/OFF). - - Use to check if the server is physically powered on. - If a Proxmox node is unreachable but power is ON, the issue is likely software. - If power is OFF, use ilo_power_on to start it. - """ - try: - return await ilo_client.get_power_status() - except (ILONotConfiguredError, ILOTunnelError) as e: - return {"error": str(e)} - - @mcp.tool() - async def ilo_power_on() -> dict[str, Any]: - """Power on the physical server via iLO. - - Use when the server is physically powered off and needs to be started. - Check ilo_power_status first to confirm it's actually off. - After powering on, wait 2-3 minutes then check proxmox_list_nodes for the node to appear. - """ - try: - return await ilo_client.power_on() - except (ILONotConfiguredError, ILOTunnelError) as e: - return {"error": str(e)} - - @mcp.tool() - async def ilo_power_off(force: bool = False) -> dict[str, Any]: - """Power off the physical server via iLO. - - Default (force=false): sends an ACPI shutdown signal (clean shutdown, like pressing the power button). - With force=true: immediately cuts power (use only when the server is completely unresponsive). - Try proxmox_vm_stop and ssh_exec_command 'shutdown -h now' before using force power off. - """ - try: - return await ilo_client.power_off(force) - except (ILONotConfiguredError, ILOTunnelError) as e: - return {"error": str(e)} - - @mcp.tool() - async def ilo_power_reset() -> dict[str, Any]: - """Hard reset the physical server via iLO. - - Use as a last resort when the server is completely frozen and doesn't respond to - any software-level reboot commands. Equivalent to pressing the physical reset button. - Try proxmox_vm_restart and ssh_exec_command 'reboot' before using this. - """ - try: - return await ilo_client.power_reset() - except (ILONotConfiguredError, ILOTunnelError) as e: - return {"error": str(e)} - - @mcp.tool() - async def ilo_get_event_log(limit: int = 50) -> dict[str, Any]: - """Get the iLO event log: hardware errors, reboots, power events, component failures. - - Use to investigate past hardware events and find root causes of crashes. - Returns the most recent events (default 50, max 200). - Events include timestamps, severity, and descriptions. - """ - limit = min(limit, 200) - try: - return await ilo_client.get_event_log(limit) - except (ILONotConfiguredError, ILOTunnelError) as e: - return {"error": str(e)} diff --git a/src/tarkamcp/ssh/__init__.py b/src/tarkamcp/ssh/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_bmc_ipmi.py b/tests/test_bmc_ipmi.py new file mode 100644 index 0000000..200c74b --- /dev/null +++ b/tests/test_bmc_ipmi.py @@ -0,0 +1,124 @@ +"""Unit tests for the generic IPMI backend (mocked subprocess).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from beaconmcp.bmc.ipmi import GenericIPMIBackend +from beaconmcp.config import ( + BMCDevice, + Config, + FeaturesConfig, + PVENode, + ServerConfig, +) + + +def _cfg() -> Config: + return Config( + server=ServerConfig(), + pve_nodes=[ + PVENode( + name="pve1", + host="pve1.example.com", + token_id="root@pam!beaconmcp", + token_secret="x", + ) + ], + bmc_devices=[], + ssh=None, + features=FeaturesConfig(), + verify_ssl=False, + infrastructure={}, + ) + + +def _backend() -> GenericIPMIBackend: + device = BMCDevice( + id="rack1-ipmi", + type="ipmi", + host="10.0.0.11", + user="admin", + password="pw", + ) + return GenericIPMIBackend(device, _cfg()) + + +class _FakeProc: + def __init__(self, stdout: bytes, stderr: bytes = b"", rc: int = 0) -> None: + self._stdout = stdout + self._stderr = stderr + self.returncode = rc + + async def communicate(self) -> tuple[bytes, bytes]: + return self._stdout, self._stderr + + +@pytest.mark.asyncio +async def test_power_on_calls_ipmitool_with_correct_argv() -> None: + backend = _backend() + fake_proc = _FakeProc(b"Chassis Power Control: Up/On\n") + mock = AsyncMock(return_value=fake_proc) + + with patch("asyncio.create_subprocess_exec", mock): + result = await backend.power_on() + + mock.assert_awaited_once() + assert mock.await_args is not None + argv = mock.await_args.args + assert argv[0] == "ipmitool" + assert "-H" in argv and "10.0.0.11" in argv + assert "-U" in argv and "admin" in argv + assert "-P" in argv and "pw" in argv + assert argv[-3:] == ("chassis", "power", "on") + assert result["action"] == "power_on" + assert result["result"] == "success" + + +@pytest.mark.asyncio +async def test_power_status_parses_on_off() -> None: + backend = _backend() + fake_proc = _FakeProc(b"Chassis Power is on\n") + with patch( + "asyncio.create_subprocess_exec", + AsyncMock(return_value=fake_proc), + ): + result = await backend.power_status() + assert result["power_status"] == "on" + + fake_proc = _FakeProc(b"Chassis Power is off\n") + with patch( + "asyncio.create_subprocess_exec", + AsyncMock(return_value=fake_proc), + ): + result = await backend.power_status() + assert result["power_status"] == "off" + + +@pytest.mark.asyncio +async def test_missing_ipmitool_binary_returns_error() -> None: + backend = _backend() + with patch( + "asyncio.create_subprocess_exec", + AsyncMock(side_effect=FileNotFoundError()), + ): + result = await backend.power_on() + assert "error" in result + assert "ipmitool" in result["error"] + + +@pytest.mark.asyncio +async def test_event_log_limits_output() -> None: + backend = _backend() + lines = "\n".join(f"event {i}" for i in range(60)).encode() + fake_proc = _FakeProc(lines) + with patch( + "asyncio.create_subprocess_exec", + AsyncMock(return_value=fake_proc), + ): + result = await backend.event_log(limit=10) + assert result["total"] == 10 + assert result["events"][0] == "event 50" + assert result["events"][-1] == "event 59" diff --git a/tests/test_bmc_registry.py b/tests/test_bmc_registry.py new file mode 100644 index 0000000..9477322 --- /dev/null +++ b/tests/test_bmc_registry.py @@ -0,0 +1,87 @@ +"""Unit tests for the BMC registry.""" + +from __future__ import annotations + +import pytest + +from beaconmcp.bmc import build_registry +from beaconmcp.bmc.hp_ilo import HPILOBackend +from beaconmcp.bmc.idrac import IDRACStubBackend +from beaconmcp.bmc.ipmi import GenericIPMIBackend +from beaconmcp.bmc.supermicro import SupermicroStubBackend +from beaconmcp.config import ( + BMCDevice, + Config, + FeaturesConfig, + PVENode, + ServerConfig, +) + + +def _make_config(devices: list[BMCDevice]) -> Config: + return Config( + server=ServerConfig(), + pve_nodes=[ + PVENode( + name="pve1", + host="pve1.example.com", + token_id="root@pam!beaconmcp", + token_secret="x", + ) + ], + bmc_devices=devices, + ssh=None, + features=FeaturesConfig(), + verify_ssl=False, + infrastructure={}, + ) + + +def test_empty_registry() -> None: + registry = build_registry(_make_config([])) + assert registry == {} + + +def test_single_hp_ilo_device() -> None: + dev = BMCDevice(id="rack1-ilo", type="hp_ilo", host="10.0.0.10", user="admin", password="pw") + registry = build_registry(_make_config([dev])) + assert list(registry.keys()) == ["rack1-ilo"] + assert isinstance(registry["rack1-ilo"], HPILOBackend) + assert registry["rack1-ilo"].type == "hp_ilo" + + +def test_multiple_mixed_devices() -> None: + cfg = _make_config( + [ + BMCDevice(id="ilo", type="hp_ilo", host="10.0.0.10", user="a", password="x"), + BMCDevice(id="ipmi", type="ipmi", host="10.0.0.11", user="a", password="y"), + BMCDevice(id="dell", type="idrac", host="10.0.0.12", user="a", password="z"), + BMCDevice(id="smci", type="supermicro", host="10.0.0.13", user="a", password="w"), + ] + ) + registry = build_registry(cfg) + + assert set(registry.keys()) == {"ilo", "ipmi", "dell", "smci"} + assert isinstance(registry["ilo"], HPILOBackend) + assert isinstance(registry["ipmi"], GenericIPMIBackend) + assert isinstance(registry["dell"], IDRACStubBackend) + assert isinstance(registry["smci"], SupermicroStubBackend) + + +def test_unknown_type_raises_at_startup() -> None: + cfg = _make_config( + [BMCDevice(id="x", type="nope", host="10.0.0.10", user="a", password="b")] + ) + with pytest.raises(ValueError, match="Unknown BMC type 'nope'"): + build_registry(cfg) + + +@pytest.mark.asyncio +async def test_stub_backend_returns_error() -> None: + cfg = _make_config( + [BMCDevice(id="dell", type="idrac", host="10.0.0.12", user="a", password="z")] + ) + registry = build_registry(cfg) + result = await registry["dell"].power_status() + assert "error" in result + assert "iDRAC" in result["error"] diff --git a/tests/test_config_yaml.py b/tests/test_config_yaml.py new file mode 100644 index 0000000..6913cb4 --- /dev/null +++ b/tests/test_config_yaml.py @@ -0,0 +1,191 @@ +"""Unit tests for the YAML-first config loader.""" + +from __future__ import annotations + +import textwrap +import warnings +from pathlib import Path + +import pytest + +from beaconmcp.config import Config, ConfigError + + +def _write(path: Path, yaml_text: str) -> Path: + path.write_text(textwrap.dedent(yaml_text).lstrip()) + return path + + +def test_yaml_happy_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PVE1_TOKEN_SECRET", "secret1") + monkeypatch.setenv("RACK1_ILO_PASSWORD", "ilopw") + path = _write( + tmp_path / "beaconmcp.yaml", + """ + version: 1 + proxmox: + verify_ssl: false + nodes: + - name: pve1 + host: pve1.example.com + token_id: root@pam!beaconmcp + token_secret: ${PVE1_TOKEN_SECRET} + bmc: + devices: + - id: rack1-ilo + type: hp_ilo + host: 10.0.0.10 + user: Administrator + password: ${RACK1_ILO_PASSWORD} + jump_host: pve1 + """, + ) + + cfg = Config.load(config_path=path) + + assert [n.name for n in cfg.pve_nodes] == ["pve1"] + assert cfg.pve_nodes[0].token_secret == "secret1" + assert len(cfg.bmc_devices) == 1 + assert cfg.bmc_devices[0].password == "ilopw" + assert cfg.bmc_devices[0].jump_host == "pve1" + assert cfg.verify_ssl is False + + +def test_missing_env_ref_raises_with_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PVE1_TOKEN_SECRET", raising=False) + path = _write( + tmp_path / "beaconmcp.yaml", + """ + version: 1 + proxmox: + nodes: + - name: pve1 + host: pve1.example.com + token_id: root@pam!beaconmcp + token_secret: ${PVE1_TOKEN_SECRET} + """, + ) + + with pytest.raises(ConfigError) as exc: + Config.load(config_path=path) + message = str(exc.value) + assert "PVE1_TOKEN_SECRET" in message + assert "proxmox.nodes.[0].token_secret" in message + + +def test_duplicate_bmc_device_id_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PVE1_TOKEN_SECRET", "x") + path = _write( + tmp_path / "beaconmcp.yaml", + """ + version: 1 + proxmox: + nodes: + - name: pve1 + host: pve1.example.com + token_id: root@pam!beaconmcp + token_secret: ${PVE1_TOKEN_SECRET} + bmc: + devices: + - id: dup + type: hp_ilo + host: 10.0.0.10 + user: admin + password: x + - id: dup + type: ipmi + host: 10.0.0.11 + user: admin + password: y + """, + ) + + with pytest.raises(ConfigError, match="Duplicate BMC device id"): + Config.load(config_path=path) + + +def test_no_proxmox_nodes_exits(tmp_path: Path) -> None: + path = _write( + tmp_path / "beaconmcp.yaml", + """ + version: 1 + proxmox: + nodes: [] + """, + ) + with pytest.raises(SystemExit): + Config.load(config_path=path) + + +def test_legacy_env_fallback_synthesizes_config( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Point the loader at an empty directory so no YAML is found. + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("BEACONMCP_CONFIG", raising=False) + monkeypatch.setenv("PVE1_HOST", "pve1.example.com") + monkeypatch.setenv("PVE1_TOKEN_ID", "root@pam!beaconmcp") + monkeypatch.setenv("PVE1_TOKEN_SECRET", "legacy-secret") + monkeypatch.setenv("SSH_USER", "root") + monkeypatch.setenv("SSH_PASSWORD", "legacy-ssh") + monkeypatch.setenv("ILO_HOST", "10.0.0.10") + monkeypatch.setenv("ILO_USER", "Administrator") + monkeypatch.setenv("ILO_PASSWORD", "legacy-ilo") + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + cfg = Config.load() + + assert any("deprecated" in str(w.message).lower() for w in captured) + assert len(cfg.pve_nodes) == 1 + assert cfg.pve_nodes[0].token_secret == "legacy-secret" + assert len(cfg.bmc_devices) == 1 + assert cfg.bmc_devices[0].type == "hp_ilo" + assert cfg.bmc_devices[0].jump_host == "pve1" + assert cfg.ssh is not None + assert cfg.ssh.password == "legacy-ssh" + + +def test_ssh_vmid_to_ip_defaults_none_when_absent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("PVE1_TOKEN_SECRET", "x") + monkeypatch.setenv("SSH_PASSWORD", "pw") + path = _write( + tmp_path / "beaconmcp.yaml", + """ + version: 1 + proxmox: + nodes: + - name: pve1 + host: pve1.example.com + token_id: root@pam!beaconmcp + token_secret: ${PVE1_TOKEN_SECRET} + ssh: + user: root + password: ${SSH_PASSWORD} + """, + ) + cfg = Config.load(config_path=path) + assert cfg.ssh is not None + assert cfg.ssh.vmid_to_ip is None + + +def test_redacted_masks_secrets(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PVE1_TOKEN_SECRET", "abcdefghij") + path = _write( + tmp_path / "beaconmcp.yaml", + """ + version: 1 + proxmox: + nodes: + - name: pve1 + host: pve1.example.com + token_id: root@pam!beaconmcp + token_secret: ${PVE1_TOKEN_SECRET} + """, + ) + cfg = Config.load(config_path=path) + redacted = cfg.redacted() + assert "abcdefghij" not in str(redacted) + assert "***" in redacted["proxmox"]["nodes"][0]["token_secret"] diff --git a/tests/test_dashboard_chat.py b/tests/test_dashboard_chat.py index 8b6a0be..c0804e2 100644 --- a/tests/test_dashboard_chat.py +++ b/tests/test_dashboard_chat.py @@ -14,8 +14,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -from tarkamcp.dashboard.app import BEARER_TTL_SECONDS, DashboardDeps, build_dashboard_routes -from tarkamcp.dashboard.chat import ( +from beaconmcp.dashboard.app import BEARER_TTL_SECONDS, DashboardDeps, build_dashboard_routes +from beaconmcp.dashboard.chat import ( ErrorEvent, FakeChatEngine, FakeScript, @@ -25,11 +25,11 @@ ToolCallStart, ToolConfirmRequired, ) -from tarkamcp.dashboard.confirmations import ConfirmationStore -from tarkamcp.dashboard.conversations import ConversationStore -from tarkamcp.dashboard.csrf import CSRF_COOKIE -from tarkamcp.dashboard.db import Database -from tarkamcp.dashboard.session import SessionStore +from beaconmcp.dashboard.confirmations import ConfirmationStore +from beaconmcp.dashboard.conversations import ConversationStore +from beaconmcp.dashboard.csrf import CSRF_COOKIE +from beaconmcp.dashboard.db import Database +from beaconmcp.dashboard.session import SessionStore class FakeClientStore: @@ -456,7 +456,7 @@ def validate(self, token): content="{}") cid = r.json()["conversation"]["id"] - # Simulate `systemctl restart tarkamcp` wiping every issued token. + # Simulate `systemctl restart beaconmcp` wiping every issued token. token_store.wiped = True r = client.post( @@ -659,7 +659,7 @@ def test_tokens_page_lists_empty(app_and_client): _login(client) r = client.get("/app/tokens") assert r.status_code == 200 - assert "Aucun token actif" in r.text + assert "No active tokens" in r.text assert "0/3" in r.text # count indicator @@ -672,7 +672,7 @@ def test_tokens_create_requires_name(app_and_client): data={"csrf_token": csrf, "name": "", "totp": "123456"}, ) assert r.status_code == 200 - assert "Le nom est obligatoire" in r.text + assert "Name is required" in r.text def test_tokens_create_requires_totp(app_and_client): @@ -683,7 +683,7 @@ def test_tokens_create_requires_totp(app_and_client): data={"csrf_token": csrf, "name": "Gemini Web", "totp": "999999"}, ) assert r.status_code == 200 - assert "Code 2FA invalide" in r.text + assert "Invalid 2FA code" in r.text def test_tokens_create_success(app_and_client, deps): @@ -694,7 +694,7 @@ def test_tokens_create_success(app_and_client, deps): data={"csrf_token": csrf, "name": "Gemini Web", "totp": "123456"}, ) assert r.status_code == 200 - assert "Nouveau token créé" in r.text + assert "New token created" in r.text assert "Gemini Web" in r.text assert deps.token_store.count_named("c") == 1 @@ -714,7 +714,7 @@ def test_tokens_create_enforces_cap(app_and_client, deps): data={"csrf_token": csrf, "name": "Client 4", "totp": "123456"}, ) assert r.status_code == 200 - assert "Limite atteinte" in r.text or "maximum 3" in r.text + assert "Limit reached" in r.text or "3 active tokens" in r.text assert deps.token_store.count_named("c") == 3 @@ -819,7 +819,7 @@ def test_chat_stream_remote_mode_still_routes_public_url(tmp_path, engine): def test_gemini_engine_rejects_remote_mode(): """GeminiChatEngine yields an ErrorEvent instead of calling the SDK.""" import asyncio - from tarkamcp.dashboard.chat import ( + from beaconmcp.dashboard.chat import ( ErrorEvent, GeminiChatEngine, TurnInput, @@ -856,7 +856,7 @@ def test_needs_confirmation_includes_proxmox_exec(): human approval -- not just SSH, but also the QEMU Guest Agent exec path (``proxmox_exec_command`` + its async twin). """ - from tarkamcp.dashboard.chat import _NEEDS_CONFIRMATION + from beaconmcp.dashboard.chat import _NEEDS_CONFIRMATION assert "proxmox_exec_command" in _NEEDS_CONFIRMATION assert "proxmox_exec_command_async" in _NEEDS_CONFIRMATION assert "ssh_exec_command" in _NEEDS_CONFIRMATION diff --git a/tests/test_dashboard_integration.py b/tests/test_dashboard_integration.py index 773e9b7..7a18c94 100644 --- a/tests/test_dashboard_integration.py +++ b/tests/test_dashboard_integration.py @@ -19,15 +19,15 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -from tarkamcp.dashboard.app import ( +from beaconmcp.dashboard.app import ( BEARER_TTL_SECONDS, DashboardDeps, SESSION_COOKIE, build_dashboard_routes, ) -from tarkamcp.dashboard.csrf import CSRF_COOKIE -from tarkamcp.dashboard.db import Database -from tarkamcp.dashboard.session import SessionStore +from beaconmcp.dashboard.csrf import CSRF_COOKIE +from beaconmcp.dashboard.db import Database +from beaconmcp.dashboard.session import SessionStore # --------------------------------------------------------------------------- @@ -37,7 +37,7 @@ class FakeClientStore: def __init__(self): self.clients = { - "tarkamcp_test": { + "beaconmcp_test": { "secret": "sk_test", "name": "Test Client", "totp": "123456", @@ -85,7 +85,7 @@ def revoke(self, token): @pytest.fixture() def deps(tmp_path, monkeypatch): - monkeypatch.setenv("TARKAMCP_DASHBOARD_DB", str(tmp_path / "dashboard.db")) + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "dashboard.db")) db = Database(tmp_path / "dashboard.db") session_store = SessionStore(db, key=os.urandom(32)) failures: dict[str, tuple[int, float]] = {} @@ -164,7 +164,7 @@ def tokens_only_client(tmp_path): def _login_form(csrf_token: str, **overrides) -> dict: data = { "csrf_token": csrf_token, - "client_id": "tarkamcp_test", + "client_id": "beaconmcp_test", "client_secret": "sk_test", "totp": "123456", "remember": "on", @@ -191,7 +191,7 @@ def test_index_redirects_to_login(client): def test_login_page_renders(client): r = client.get("/app/login") assert r.status_code == 200 - assert "Connexion" in r.text + assert "Sign in" in r.text assert "Client ID" in r.text assert r.cookies.get(CSRF_COOKIE) @@ -200,7 +200,7 @@ def test_login_post_csrf_required(client): r = client.post( "/app/login", data={ - "client_id": "tarkamcp_test", + "client_id": "beaconmcp_test", "client_secret": "sk_test", "totp": "123456", }, @@ -216,7 +216,7 @@ def test_login_post_wrong_credentials(client): data=_login_form(token, client_secret="wrong"), ) assert r.status_code == 401 - assert "Identifiants invalides" in r.text + assert "Invalid credentials" in r.text def test_login_post_wrong_totp(client): @@ -226,7 +226,7 @@ def test_login_post_wrong_totp(client): data=_login_form(token, totp="000000"), ) assert r.status_code == 401 - assert "Code 2FA invalide" in r.text + assert "Invalid 2FA code" in r.text def test_login_post_success(client, deps): @@ -236,9 +236,9 @@ def test_login_post_success(client, deps): assert r.headers["location"] == "/app/chat" assert r.cookies.get(SESSION_COOKIE) # Session persisted - sessions = deps.session_store.list_for_client("tarkamcp_test") + sessions = deps.session_store.list_for_client("beaconmcp_test") assert len(sessions) == 1 - assert sessions[0].mcp_bearer.startswith("bearer_tarkamcp_test_") + assert sessions[0].mcp_bearer.startswith("bearer_beaconmcp_test_") def test_chat_requires_session(client): @@ -252,13 +252,13 @@ def test_chat_accessible_after_login(client): client.post("/app/login", data=_login_form(token)) r = client.get("/app/chat") assert r.status_code == 200 - assert "tarkamcp_test" in r.text + assert "beaconmcp_test" in r.text def test_logout_revokes_bearer(client, deps): token = _csrf(client) client.post("/app/login", data=_login_form(token)) - sessions = deps.session_store.list_for_client("tarkamcp_test") + sessions = deps.session_store.list_for_client("beaconmcp_test") bearer = sessions[0].mcp_bearer # CSRF cookie is rotated on login, fetch the fresh one. @@ -267,7 +267,7 @@ def test_logout_revokes_bearer(client, deps): assert r.status_code == 303 assert r.headers["location"] == "/app/login" assert bearer in deps.token_store.revoked - assert deps.session_store.list_for_client("tarkamcp_test") == [] + assert deps.session_store.list_for_client("beaconmcp_test") == [] def test_refresh_requires_session(client): @@ -287,7 +287,7 @@ def test_refresh_when_bearer_still_valid_redirects_to_chat(client): def test_refresh_when_bearer_expired_renders_form(client, deps): token = _csrf(client) client.post("/app/login", data=_login_form(token)) - sessions = deps.session_store.list_for_client("tarkamcp_test") + sessions = deps.session_store.list_for_client("beaconmcp_test") deps.session_store._db.conn().execute( "UPDATE sessions SET mcp_bearer_expires_at = ? WHERE session_id = ?", (0, sessions[0].session_id), @@ -296,13 +296,13 @@ def test_refresh_when_bearer_expired_renders_form(client, deps): r = client.get("/app/refresh") assert r.status_code == 200 assert "Test Client" in r.text - assert "Code 2FA" in r.text + assert "2FA code" in r.text def test_refresh_post_re_issues_bearer(client, deps): token = _csrf(client) client.post("/app/login", data=_login_form(token)) - sessions = deps.session_store.list_for_client("tarkamcp_test") + sessions = deps.session_store.list_for_client("beaconmcp_test") sid = sessions[0].session_id old_bearer = sessions[0].mcp_bearer deps.session_store._db.conn().execute( @@ -327,7 +327,7 @@ def test_refresh_post_re_issues_bearer(client, deps): def test_refresh_wrong_totp(client, deps): token = _csrf(client) client.post("/app/login", data=_login_form(token)) - sessions = deps.session_store.list_for_client("tarkamcp_test") + sessions = deps.session_store.list_for_client("beaconmcp_test") deps.session_store._db.conn().execute( "UPDATE sessions SET mcp_bearer_expires_at = ? WHERE session_id = ?", (0, sessions[0].session_id), @@ -339,7 +339,7 @@ def test_refresh_wrong_totp(client, deps): data={"csrf_token": new_token, "totp": "999999"}, ) assert r.status_code == 401 - assert "Code 2FA invalide" in r.text + assert "Invalid 2FA code" in r.text def test_login_after_5_failed_totp_locks_out(client, deps): @@ -350,7 +350,7 @@ def test_login_after_5_failed_totp_locks_out(client, deps): r = client.post("/app/login", data=_login_form(token)) assert r.status_code == 429 - assert "Trop de tentatives" in r.text + assert "Too many attempts" in r.text def test_logout_csrf_required(client): @@ -369,7 +369,7 @@ def test_existing_session_skips_login_page(client): def test_existing_session_with_stale_bearer_redirects_to_refresh(client, deps): token = _csrf(client) client.post("/app/login", data=_login_form(token)) - sessions = deps.session_store.list_for_client("tarkamcp_test") + sessions = deps.session_store.list_for_client("beaconmcp_test") deps.session_store._db.conn().execute( "UPDATE sessions SET mcp_bearer_expires_at = ? WHERE session_id = ?", (0, sessions[0].session_id), @@ -401,7 +401,7 @@ def test_tokens_only_login_lands_on_tokens(tokens_only_client): "/app/login", data={ "csrf_token": token, - "client_id": "tarkamcp_test", + "client_id": "beaconmcp_test", "client_secret": "sk_test", "totp": "123456", "remember": "on", @@ -418,7 +418,7 @@ def test_tokens_only_chat_redirects_to_tokens(tokens_only_client): "/app/login", data={ "csrf_token": token, - "client_id": "tarkamcp_test", + "client_id": "beaconmcp_test", "client_secret": "sk_test", "totp": "123456", "remember": "on", @@ -436,7 +436,7 @@ def test_tokens_only_login_page_redirects_when_authenticated(tokens_only_client) "/app/login", data={ "csrf_token": token, - "client_id": "tarkamcp_test", + "client_id": "beaconmcp_test", "client_secret": "sk_test", "totp": "123456", "remember": "on", diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index 0b28313..080db8f 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -1,4 +1,4 @@ -"""Unit tests for the TarkaMCP dashboard module. +"""Unit tests for the BeaconMCP dashboard module. Run with:: @@ -16,8 +16,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -from tarkamcp.dashboard.db import Database -from tarkamcp.dashboard.session import ( +from beaconmcp.dashboard.db import Database +from beaconmcp.dashboard.session import ( SESSION_TTL_SECONDS, SessionStore, load_session_key, @@ -40,13 +40,13 @@ def store(db): # --------------------------------------------------------------------------- def test_load_session_key_missing(monkeypatch): - monkeypatch.delenv("TARKAMCP_SESSION_KEY", raising=False) - with pytest.raises(RuntimeError, match="TARKAMCP_SESSION_KEY"): + monkeypatch.delenv("BEACONMCP_SESSION_KEY", raising=False) + with pytest.raises(RuntimeError, match="BEACONMCP_SESSION_KEY"): load_session_key() def test_load_session_key_invalid_base64(monkeypatch): - monkeypatch.setenv("TARKAMCP_SESSION_KEY", "not!!!base64@@@") + monkeypatch.setenv("BEACONMCP_SESSION_KEY", "not!!!base64@@@") with pytest.raises(RuntimeError, match="not valid base64"): load_session_key() @@ -54,7 +54,7 @@ def test_load_session_key_invalid_base64(monkeypatch): def test_load_session_key_wrong_length(monkeypatch): import base64 - monkeypatch.setenv("TARKAMCP_SESSION_KEY", base64.b64encode(b"too short").decode()) + monkeypatch.setenv("BEACONMCP_SESSION_KEY", base64.b64encode(b"too short").decode()) with pytest.raises(RuntimeError, match="32 bytes"): load_session_key() @@ -63,7 +63,7 @@ def test_load_session_key_ok(monkeypatch): import base64 raw = os.urandom(32) - monkeypatch.setenv("TARKAMCP_SESSION_KEY", base64.b64encode(raw).decode()) + monkeypatch.setenv("BEACONMCP_SESSION_KEY", base64.b64encode(raw).decode()) assert load_session_key() == raw @@ -73,14 +73,14 @@ def test_load_session_key_ok(monkeypatch): def test_create_and_load(store): s = store.create( - client_id="tarkamcp_abc", + client_id="beaconmcp_abc", client_secret="sk_supersecret", mcp_bearer="bearer_xyz", bearer_ttl_seconds=3600, user_agent="pytest", ) assert s.session_id - assert s.client_id == "tarkamcp_abc" + assert s.client_id == "beaconmcp_abc" assert s.mcp_bearer == "bearer_xyz" assert s.bearer_valid() assert not s.is_expired() @@ -88,7 +88,7 @@ def test_create_and_load(store): loaded = store.load(s.session_id) assert loaded is not None assert loaded.session_id == s.session_id - assert loaded.client_id == "tarkamcp_abc" + assert loaded.client_id == "beaconmcp_abc" def test_load_unknown_returns_none(store): @@ -221,14 +221,14 @@ def test_cleanup_expired(store): def test_unwrap_exception_single(): - from tarkamcp.dashboard.chat import _unwrap_exception + from beaconmcp.dashboard.chat import _unwrap_exception err = ValueError("boom") assert _unwrap_exception(err) is err def test_unwrap_exception_simple_group(): - from tarkamcp.dashboard.chat import _unwrap_exception + from beaconmcp.dashboard.chat import _unwrap_exception inner = RuntimeError("real cause") group = ExceptionGroup("task group", [inner]) @@ -236,7 +236,7 @@ def test_unwrap_exception_simple_group(): def test_unwrap_exception_nested_groups(): - from tarkamcp.dashboard.chat import _unwrap_exception + from beaconmcp.dashboard.chat import _unwrap_exception inner = ConnectionError("network") nested = ExceptionGroup("inner", [inner]) @@ -245,7 +245,7 @@ def test_unwrap_exception_nested_groups(): def test_unwrap_exception_prefers_leaf_over_group(): - from tarkamcp.dashboard.chat import _unwrap_exception + from beaconmcp.dashboard.chat import _unwrap_exception leaf = TypeError("t") sibling_group = ExceptionGroup("sibling", [RuntimeError("deep")]) @@ -254,7 +254,7 @@ def test_unwrap_exception_prefers_leaf_over_group(): def test_classify_error_preview_model_permission_denied(): - from tarkamcp.dashboard.chat import _classify_error + from beaconmcp.dashboard.chat import _classify_error err = Exception( "403 PERMISSION_DENIED. The caller does not have permission" @@ -266,7 +266,7 @@ def test_classify_error_preview_model_permission_denied(): def test_classify_error_stable_model_permission_denied(): - from tarkamcp.dashboard.chat import _classify_error + from beaconmcp.dashboard.chat import _classify_error err = Exception("403 PERMISSION_DENIED. caller issue") code, msg = _classify_error(err, "gemini-2.5-flash") @@ -275,7 +275,7 @@ def test_classify_error_stable_model_permission_denied(): def test_classify_error_model_not_found(): - from tarkamcp.dashboard.chat import _classify_error + from beaconmcp.dashboard.chat import _classify_error err = Exception("404 NOT_FOUND. models/foo is not found") code, msg = _classify_error(err, "foo") @@ -283,7 +283,7 @@ def test_classify_error_model_not_found(): def test_classify_error_rate_limit(): - from tarkamcp.dashboard.chat import _classify_error + from beaconmcp.dashboard.chat import _classify_error err = Exception("429 RESOURCE_EXHAUSTED. Quota exceeded") code, _msg = _classify_error(err, "gemini-2.5-flash") @@ -291,7 +291,7 @@ def test_classify_error_rate_limit(): def test_classify_error_generic(): - from tarkamcp.dashboard.chat import _classify_error + from beaconmcp.dashboard.chat import _classify_error err = RuntimeError("boom") code, msg = _classify_error(err, "gemini-2.5-flash") @@ -300,7 +300,7 @@ def test_classify_error_generic(): def test_classify_error_upstream_internal(): - from tarkamcp.dashboard.chat import _classify_error + from beaconmcp.dashboard.chat import _classify_error err = Exception( "500 INTERNAL. {'error': {'code': 500, 'message': 'Internal error encountered.', 'status': 'INTERNAL'}}" @@ -312,7 +312,7 @@ def test_classify_error_upstream_internal(): def test_classify_error_upstream_unavailable(): - from tarkamcp.dashboard.chat import _classify_error + from beaconmcp.dashboard.chat import _classify_error err = Exception("503 UNAVAILABLE. The service is temporarily unavailable") code, _msg = _classify_error(err, "gemini-2.5-flash") @@ -320,7 +320,7 @@ def test_classify_error_upstream_unavailable(): def test_classify_error_upstream_timeout(): - from tarkamcp.dashboard.chat import _classify_error + from beaconmcp.dashboard.chat import _classify_error err = Exception("504 DEADLINE_EXCEEDED") code, _msg = _classify_error(err, "gemini-2.5-flash") @@ -328,7 +328,7 @@ def test_classify_error_upstream_timeout(): def test_token_store_named_issue_and_list(): - from tarkamcp.auth import TokenStore + from beaconmcp.auth import TokenStore ts = TokenStore() t1, _ = ts.issue("cid", name="Gemini Web") @@ -347,7 +347,7 @@ def test_token_store_named_issue_and_list(): def test_token_store_cap_is_three(): - from tarkamcp.auth import TokenCapExceeded, TokenStore + from beaconmcp.auth import TokenCapExceeded, TokenStore ts = TokenStore() for i in range(3): @@ -359,7 +359,7 @@ def test_token_store_cap_is_three(): def test_token_store_cap_frees_after_revoke(): - from tarkamcp.auth import TokenStore + from beaconmcp.auth import TokenStore ts = TokenStore() t1, _ = ts.issue("cid", name="a") @@ -375,7 +375,7 @@ def test_token_store_cap_frees_after_revoke(): def test_token_store_revoke_named_by_prefix_scoped_to_client(): - from tarkamcp.auth import TokenStore + from beaconmcp.auth import TokenStore ts = TokenStore() t_mine, _ = ts.issue("me", name="Mine") @@ -389,7 +389,7 @@ def test_token_store_revoke_named_by_prefix_scoped_to_client(): def test_token_store_revoke_named_requires_min_prefix(): - from tarkamcp.auth import TokenStore + from beaconmcp.auth import TokenStore ts = TokenStore() ts.issue("cid", name="a") @@ -399,7 +399,7 @@ def test_token_store_revoke_named_requires_min_prefix(): def test_mcp_tool_to_declaration_passes_input_schema(): from google.genai import types - from tarkamcp.dashboard.chat import _mcp_tool_to_declaration + from beaconmcp.dashboard.chat import _mcp_tool_to_declaration class FakeMCPTool: name = "proxmox_list_vms" @@ -419,7 +419,7 @@ class FakeMCPTool: def test_mcp_tool_to_declaration_defaults_schema_when_missing(): from google.genai import types - from tarkamcp.dashboard.chat import _mcp_tool_to_declaration + from beaconmcp.dashboard.chat import _mcp_tool_to_declaration class FakeMCPTool: name = "ping" @@ -433,7 +433,7 @@ class FakeMCPTool: def test_mcp_call_result_to_response_flattens_text_content(): - from tarkamcp.dashboard.chat import _mcp_call_result_to_response + from beaconmcp.dashboard.chat import _mcp_call_result_to_response class FakeText: text = "hello" @@ -448,7 +448,7 @@ class FakeResult: def test_mcp_call_result_to_response_marks_error(): - from tarkamcp.dashboard.chat import _mcp_call_result_to_response + from beaconmcp.dashboard.chat import _mcp_call_result_to_response class FakeText: text = "boom" @@ -463,7 +463,7 @@ class FakeResult: def test_is_transient_error_matches_5xx(): - from tarkamcp.dashboard.chat import _is_transient_error + from beaconmcp.dashboard.chat import _is_transient_error assert _is_transient_error(Exception("500 INTERNAL. Internal error")) assert _is_transient_error(Exception("503 UNAVAILABLE")) @@ -475,8 +475,8 @@ def test_is_transient_error_matches_5xx(): def _run_retry_scenario(monkeypatch, fake_run): """Helper: swap in ``fake_run`` on a GeminiChatEngine and drain events.""" import asyncio as _asyncio - from tarkamcp.dashboard import chat as chat_mod - from tarkamcp.dashboard.chat import GeminiChatEngine, TurnInput + from beaconmcp.dashboard import chat as chat_mod + from beaconmcp.dashboard.chat import GeminiChatEngine, TurnInput async def _noop_sleep(_): return None @@ -497,7 +497,7 @@ async def _drain(): def test_gemini_retry_recovers_from_transient_500(monkeypatch): """run() retries transient 5xx errors before surfacing them.""" - from tarkamcp.dashboard.chat import TextDelta + from beaconmcp.dashboard.chat import TextDelta attempts = {"n": 0} @@ -517,7 +517,7 @@ async def fake_run(_turn): def test_gemini_retry_surfaces_after_max_attempts(monkeypatch): - from tarkamcp.dashboard.chat import ErrorEvent + from beaconmcp.dashboard.chat import ErrorEvent attempts = {"n": 0} @@ -535,7 +535,7 @@ async def fake_run(_turn): def test_gemini_retry_skips_on_non_transient(monkeypatch): - from tarkamcp.dashboard.chat import ErrorEvent + from beaconmcp.dashboard.chat import ErrorEvent attempts = {"n": 0} @@ -551,7 +551,7 @@ async def fake_run(_turn): def test_thinking_config_for_gemini_3(): - from tarkamcp.dashboard.chat import GeminiChatEngine + from beaconmcp.dashboard.chat import GeminiChatEngine cfg = GeminiChatEngine._build_thinking_config("gemini-3-flash-preview", "high") assert cfg.thinking_level is not None @@ -560,7 +560,7 @@ def test_thinking_config_for_gemini_3(): def test_thinking_config_for_gemini_2_5(): - from tarkamcp.dashboard.chat import GeminiChatEngine + from beaconmcp.dashboard.chat import GeminiChatEngine cfg = GeminiChatEngine._build_thinking_config("gemini-2.5-flash", "medium") assert cfg.thinking_level is None @@ -569,7 +569,7 @@ def test_thinking_config_for_gemini_2_5(): def test_thinking_config_clamps_gemini_2_5_pro_minimum(): """2.5 Pro cannot disable thinking; budget must clamp to 128+.""" - from tarkamcp.dashboard.chat import GeminiChatEngine + from beaconmcp.dashboard.chat import GeminiChatEngine cfg = GeminiChatEngine._build_thinking_config("gemini-2.5-pro", "minimal") assert cfg.thinking_budget == 128 # clamped up from 0 @@ -580,7 +580,7 @@ def test_thinking_config_clamps_gemini_2_5_pro_minimum(): def test_thinking_config_unknown_effort_defaults_to_low(): - from tarkamcp.dashboard.chat import GeminiChatEngine + from beaconmcp.dashboard.chat import GeminiChatEngine cfg3 = GeminiChatEngine._build_thinking_config("gemini-3.1-pro-preview", "nonsense") # Enum repr contains LOW diff --git a/tests/test_dashboard_usage.py b/tests/test_dashboard_usage.py index bd9f6e8..bdb7245 100644 --- a/tests/test_dashboard_usage.py +++ b/tests/test_dashboard_usage.py @@ -13,19 +13,19 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -from tarkamcp.dashboard.app import DashboardDeps, build_dashboard_routes -from tarkamcp.dashboard.chat import ( +from beaconmcp.dashboard.app import DashboardDeps, build_dashboard_routes +from beaconmcp.dashboard.chat import ( FakeChatEngine, FakeScript, TextDelta, UsageAccumulated, ) -from tarkamcp.dashboard.confirmations import ConfirmationStore -from tarkamcp.dashboard.conversations import ConversationStore -from tarkamcp.dashboard.csrf import CSRF_COOKIE -from tarkamcp.dashboard.db import Database -from tarkamcp.dashboard.session import SessionStore -from tarkamcp.dashboard.usage import Budget, UsageMeter, UsageStore +from beaconmcp.dashboard.confirmations import ConfirmationStore +from beaconmcp.dashboard.conversations import ConversationStore +from beaconmcp.dashboard.csrf import CSRF_COOKIE +from beaconmcp.dashboard.db import Database +from beaconmcp.dashboard.session import SessionStore +from beaconmcp.dashboard.usage import Budget, UsageMeter, UsageStore # --------------------------------------------------------------------------- diff --git a/tests/test_integration.py b/tests/test_integration.py index 4f9fc15..eb8a263 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,5 +1,5 @@ """ -TarkaMCP -- Integration test suite +BeaconMCP -- Integration test suite =================================== Run against real infrastructure once services are back online. @@ -12,7 +12,7 @@ # Run a specific section python tests/test_integration.py --section proxmox python tests/test_integration.py --section ssh - python tests/test_integration.py --section ilo + python tests/test_integration.py --section bmc # Run with a test VM (for destructive tests: start/stop/clone) python tests/test_integration.py --test-vmid 9999 @@ -99,7 +99,7 @@ def summary(self) -> None: def get_tools() -> dict: """Import and return all registered MCP tools.""" - from tarkamcp.server import mcp + from beaconmcp.server import mcp return mcp._tool_manager._tools @@ -299,11 +299,11 @@ def test_proxmox_exec(runner: TestRunner, tools: dict) -> None: # T9: Sync exec -- simple command result = call_tool(tools, "proxmox_exec_command", node="pve1", vmid=vmid, - command="echo TarkaMCP-test", timeout=30) + command="echo BeaconMCP-test", timeout=30) runner.record( f"proxmox_exec_command 'echo' in VM {vmid}", - isinstance(result, dict) and "TarkaMCP-test" in result.get("stdout", ""), - f"Expected stdout containing 'TarkaMCP-test', got: {result}", + isinstance(result, dict) and "BeaconMCP-test" in result.get("stdout", ""), + f"Expected stdout containing 'BeaconMCP-test', got: {result}", result, ) @@ -478,7 +478,7 @@ def test_proxmox_vm_lifecycle(runner: TestRunner, tools: dict, test_vmid: int | # T18: Modify config (change description, harmless) result = call_tool(tools, "proxmox_vm_config", node="pve1", vmid=test_vmid, - updates={"description": "TarkaMCP test VM - safe to delete"}) + updates={"description": "BeaconMCP test VM - safe to delete"}) runner.record( f"proxmox_vm_config (update description) VMID {test_vmid}", isinstance(result, dict) and ("updates_applied" in result or "error" in result), @@ -489,7 +489,7 @@ def test_proxmox_vm_lifecycle(runner: TestRunner, tools: dict, test_vmid: int | # T19: Clone (to VMID test_vmid+1000) clone_id = test_vmid + 1000 result = call_tool(tools, "proxmox_vm_clone", node="pve1", vmid=test_vmid, - newid=clone_id, name="tarkamcp-test-clone") + newid=clone_id, name="beaconmcp-test-clone") runner.record( f"proxmox_vm_clone {test_vmid} -> {clone_id}", isinstance(result, dict) and ("upid" in result or "error" in result), @@ -504,7 +504,7 @@ def test_proxmox_vm_lifecycle(runner: TestRunner, tools: dict, test_vmid: int | # Stop clone if running, then delete call_tool(tools, "proxmox_vm_stop", node="pve1", vmid=clone_id, force=True) time.sleep(5) - from tarkamcp.server import proxmox_client + from beaconmcp.server import proxmox_client proxmox_client.delete("pve1", f"nodes/pve1/{vm_type}/{clone_id}") print(f" Cleaned up clone VMID {clone_id}") @@ -558,7 +558,7 @@ def test_ssh(runner: TestRunner, tools: dict) -> None: ) # T24: SSH host resolution -- VMID format - from tarkamcp.server import ssh_client + from beaconmcp.server import ssh_client resolved = ssh_client.resolve_host("101") runner.record( "SSH host resolution: VMID '101' -> 192.168.1.101", @@ -610,46 +610,46 @@ def test_ssh(runner: TestRunner, tools: dict) -> None: ) -def test_ilo(runner: TestRunner, tools: dict) -> None: - runner.section("iLO Module") +def test_bmc(runner: TestRunner, tools: dict) -> None: + runner.section("BMC Module") - if "ilo_server_info" not in tools: + if "bmc_server_info" not in tools: runner.record( - "iLO tests (skipped: iLO not configured)", + "BMC tests (skipped: no BMC device configured)", True, - "Set ILO_HOST, ILO_USER, ILO_PASSWORD in .env to enable iLO tests", + "Add at least one entry to bmc.devices[] in beaconmcp.yaml to enable BMC tests.", ) return # T27: Server info - result = call_tool(tools, "ilo_server_info") + result = call_tool(tools, "bmc_server_info") runner.record( - "ilo_server_info returns server details", - isinstance(result, dict) and ("product_name" in result or "error" in result), + "bmc_server_info returns server details", + isinstance(result, dict) and ("product_name" in result or "fru" in result or "error" in result), f"Result: {json.dumps(result, default=str)[:200]}", result, ) if isinstance(result, dict) and "error" in result: runner.record( - "iLO tests aborted: cannot reach iLO", + "BMC tests aborted: cannot reach BMC", False, result["error"], ) return # T28: Health status - result = call_tool(tools, "ilo_health_status") + result = call_tool(tools, "bmc_health_status") runner.record( - "ilo_health_status returns health data", + "bmc_health_status returns health data", isinstance(result, dict) and "error" not in result, f"Result type: {type(result).__name__}, keys: {list(result.keys())[:5] if isinstance(result, dict) else 'N/A'}", result if isinstance(result, dict) and "error" in result else None, ) # T29: Power status - result = call_tool(tools, "ilo_power_status") + result = call_tool(tools, "bmc_power_status") runner.record( - "ilo_power_status returns ON/OFF", + "bmc_power_status returns ON/OFF", isinstance(result, dict) and "power_status" in result, f"Result: {result}", result, @@ -657,32 +657,32 @@ def test_ilo(runner: TestRunner, tools: dict) -> None: if isinstance(result, dict) and "power_status" in result: runner.record( "Server power is ON", - result["power_status"].upper() == "ON", + str(result["power_status"]).upper() == "ON", f"power_status = {result['power_status']}", ) # T30: Event log - result = call_tool(tools, "ilo_get_event_log", limit=10) + result = call_tool(tools, "bmc_get_event_log", limit=10) runner.record( - "ilo_get_event_log returns events", + "bmc_get_event_log returns events", isinstance(result, dict) and ("events" in result or "error" in result), f"Total events: {result.get('total', '?')}", result if isinstance(result, dict) and "error" in result else None, ) - # NOTE: We do NOT test power_on/power_off/power_reset in automated tests - # as these are destructive operations. Test them manually. + # NOTE: power_on / power_off / power_reset are destructive; skipped in + # automated runs. Test them manually if needed. runner.record( - "ilo_power_on/off/reset (not tested: destructive)", + "bmc_power_on/off/reset (not tested: destructive)", True, - "Manual testing required. Use: ilo_power_status to verify state first.", + "Manual testing required. Use bmc_power_status to verify state first.", ) def test_mcp_resources(runner: TestRunner) -> None: runner.section("MCP Resources & Prompts") - from tarkamcp.server import mcp, config + from beaconmcp.server import mcp, config # T31: Infrastructure resource resource_fn = None @@ -692,7 +692,7 @@ def test_mcp_resources(runner: TestRunner) -> None: break runner.record( - "tarkamcp://infrastructure resource is registered", + "beaconmcp://infrastructure resource is registered", resource_fn is not None, "Expected a resource matching 'infrastructure'", ) @@ -707,8 +707,8 @@ def test_mcp_resources(runner: TestRunner) -> None: # T33: Prompt is registered prompts = mcp._prompt_manager._prompts runner.record( - "tarkamcp_context prompt is registered", - "tarkamcp_context" in prompts, + "beaconmcp_context prompt is registered", + "beaconmcp_context" in prompts, f"Available prompts: {list(prompts.keys())}", ) @@ -770,8 +770,8 @@ def test_error_handling(runner: TestRunner, tools: dict) -> None: # --------------------------------------------------------------------------- def main() -> None: - parser = argparse.ArgumentParser(description="TarkaMCP integration tests") - parser.add_argument("--section", choices=["proxmox", "ssh", "ilo", "all"], default="all", + parser = argparse.ArgumentParser(description="BeaconMCP integration tests") + parser.add_argument("--section", choices=["proxmox", "ssh", "bmc", "all"], default="all", help="Which section to test") parser.add_argument("--test-vmid", type=int, default=None, help="VMID of a sacrificial test VM for lifecycle tests (start/stop/clone)") @@ -780,7 +780,7 @@ def main() -> None: runner = TestRunner() tools = get_tools() - print(f"\nTarkaMCP Integration Tests") + print(f"\nBeaconMCP Integration Tests") print(f"Tools registered: {len(tools)}") print(f"Section: {args.section}") if args.test_vmid: @@ -799,8 +799,8 @@ def main() -> None: if sections in ("ssh", "all"): test_ssh(runner, tools) - if sections in ("ilo", "all"): - test_ilo(runner, tools) + if sections in ("bmc", "all"): + test_bmc(runner, tools) if sections == "all": test_mcp_resources(runner) From 272555c82cb3ab1366785fa2482e72011534f7a1 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 04:34:40 +0200 Subject: [PATCH 047/155] Quote Proxmox token_id in beaconmcp.yaml.example The token_id value contains '!' and '@', which are YAML-reserved characters when they appear at the start of a scalar token. Without quotes the parser raises ScannerError at load time. Matches what users must do in their own beaconmcp.yaml. Co-Authored-By: Claude Opus 4.7 (1M context) --- beaconmcp.yaml.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index 1632a43..4356ae8 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -35,12 +35,12 @@ proxmox: nodes: - name: pve1 host: pve1.example.com - token_id: root@pam!beaconmcp + token_id: "root@pam!beaconmcp" # quotes required: '!' and '@' are YAML-reserved token_secret: ${PVE1_TOKEN_SECRET} - name: pve2 host: pve2.example.com - token_id: root@pam!beaconmcp + token_id: "root@pam!beaconmcp" token_secret: ${PVE2_TOKEN_SECRET} ssh: From f1360bf7545b43532875f16418128175b448e2ea Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 04:40:30 +0200 Subject: [PATCH 048/155] =?UTF-8?q?Add=20'Generating=E2=80=A6'=20indicator?= =?UTF-8?q?=20to=20the=20dashboard=20chat=20stream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shimmer-text + pulsing-dot indicator is pinned to the bottom of the assistant turn while the SSE stream is open, and removed on turn end. Gives visible feedback during long thinking pauses and between tool calls (Gemini 3 Pro in particular spends tens of seconds reasoning before emitting text or the first function_call). Respects prefers-reduced-motion. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/beaconmcp/dashboard/static/app.css | 51 ++++++++++++++++++++++++++ src/beaconmcp/dashboard/static/chat.js | 11 ++++++ 2 files changed, 62 insertions(+) diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index a0ab4cd..6a36c62 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -559,6 +559,57 @@ a.sidebar-link { cursor: pointer; } letter-spacing: 0.01em; } +/* Streaming indicator: "Generating…" shimmer with a pulsing dot. + Pinned to the bottom of the assistant row during the whole turn; removed + when the SSE stream closes. Gives feedback during long thinking pauses + and between tool calls. */ +.generating-indicator { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.35rem; + font-size: 0.85rem; + color: var(--fg-faint); + user-select: none; +} + +.generating-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + animation: generating-dot-pulse 1.2s ease-in-out infinite; +} + +.generating-label { + background: linear-gradient( + 90deg, + var(--fg-faint) 0%, + var(--fg) 50%, + var(--fg-faint) 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: generating-shimmer 2.2s linear infinite; +} + +@keyframes generating-dot-pulse { + 0%, 100% { opacity: 0.3; transform: scale(0.85); } + 50% { opacity: 1; transform: scale(1); } +} + +@keyframes generating-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .generating-dot { animation: none; opacity: 0.7; } + .generating-label { animation: none; color: var(--fg-faint); background: none; -webkit-text-fill-color: currentColor; } +} + .msg-body.md { line-height: 1.55; font-size: 0.95rem; diff --git a/src/beaconmcp/dashboard/static/chat.js b/src/beaconmcp/dashboard/static/chat.js index afdff3d..3f9b27a 100644 --- a/src/beaconmcp/dashboard/static/chat.js +++ b/src/beaconmcp/dashboard/static/chat.js @@ -765,6 +765,16 @@ async function streamTurn(userText) { const body = row.querySelector(".msg-body"); const toolCardMap = new Map(); + // "Generating…" indicator, pinned to the bottom of the turn so the user + // sees something alive during thinking and between tool calls. Removed + // in the finally block below. + const indicator = document.createElement("div"); + indicator.className = "generating-indicator"; + indicator.innerHTML = + 'Generating…'; + row.append(indicator); + scrollToBottom(); + try { const res = await fetch("/app/api/chat/stream", { method: "POST", @@ -813,6 +823,7 @@ async function streamTurn(userText) { row.append(errDiv); } } finally { + indicator.remove(); state.streaming = false; state.abortController = null; el.send.classList.remove("stop"); From 3dd384e3de03a0fdd95283849ad75d62e560979b Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 04:47:58 +0200 Subject: [PATCH 049/155] Strip :port from Proxmox node host before SSH/BMC tunneling proxmox.nodes[].host often includes an API port (e.g. :443 behind a reverse proxy, :8006 direct). The Proxmox client passes the full host:port to proxmoxer which handles it fine. But SSH tools and the HP iLO SSH jump tunnel resolve the same node name through Config.get_node_host() and feed the result to asyncssh.connect(), which treats "pve1.example.com:443" as a literal DNS label and fails with a resolution error. Strip the trailing :port in get_node_host() so callers get a usable bare hostname. Preserves IPv6 bracket literals (`[::1]:8006` -> `[::1]`) and no-ops when no port is present. The raw .host on the PVENode is untouched so proxmoxer still sees the full authority. Fix surfaces as "Opening SSH connection to pve1.example.com:443, port 22" in the service logs when SSH or bmc_* tools are invoked from the chat. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/beaconmcp/config.py | 32 +++++++++++++++++++++++++++++++- tests/test_config_yaml.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/beaconmcp/config.py b/src/beaconmcp/config.py index 2818b2e..0665ed0 100644 --- a/src/beaconmcp/config.py +++ b/src/beaconmcp/config.py @@ -324,8 +324,18 @@ def get_node(self, name: str) -> PVENode | None: return None def get_node_host(self, name: str) -> str | None: + """Resolve a node name to a bare hostname suitable for SSH. + + ``proxmox.nodes[].host`` may carry a port (e.g. ``pve1.example.com:8006`` + or ``pve1.example.com:443``) for the Proxmox API, but SSH and + BMC-over-SSH-tunnel clients always need the bare hostname — asyncssh + treats the ``host:port`` string as a literal DNS label and fails to + resolve. Strip the port here so every caller gets a usable value. + """ node = self.get_node(name) - return node.host if node else None + if node is None: + return None + return _strip_port(node.host) def get_bmc_device(self, device_id: str) -> BMCDevice | None: for d in self.bmc_devices: @@ -452,3 +462,23 @@ def _bool(value: Any) -> bool: if isinstance(value, bool): return value return str(value).strip().lower() in ("1", "true", "yes", "on") + + +def _strip_port(host: str) -> str: + """Return ``host`` without a trailing ``:port`` component. + + Handles IPv6 bracket literals (``[::1]:8006`` -> ``[::1]``) and regular + hostnames (``pve1.example.com:8006`` -> ``pve1.example.com``). Leaves + the value unchanged when no port is present. + """ + if not host: + return host + if host.startswith("["): + end = host.find("]") + if end != -1: + return host[: end + 1] + return host + head, sep, tail = host.rpartition(":") + if sep and tail.isdigit(): + return head + return host diff --git a/tests/test_config_yaml.py b/tests/test_config_yaml.py index 6913cb4..d482191 100644 --- a/tests/test_config_yaml.py +++ b/tests/test_config_yaml.py @@ -171,6 +171,35 @@ def test_ssh_vmid_to_ip_defaults_none_when_absent( assert cfg.ssh.vmid_to_ip is None +def test_get_node_host_strips_port(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """proxmox.nodes[].host often carries the API port (e.g. :443 behind a reverse + proxy). SSH and BMC-over-SSH-tunnel need the bare hostname.""" + monkeypatch.setenv("PVE1_TOKEN_SECRET", "x") + monkeypatch.setenv("PVE2_TOKEN_SECRET", "y") + path = _write( + tmp_path / "beaconmcp.yaml", + """ + version: 1 + proxmox: + nodes: + - name: pve1 + host: pve1.example.com:443 + token_id: "root@pam!beaconmcp" + token_secret: ${PVE1_TOKEN_SECRET} + - name: pve6 + host: "[::1]:8006" + token_id: "root@pam!beaconmcp" + token_secret: ${PVE2_TOKEN_SECRET} + """, + ) + cfg = Config.load(config_path=path) + assert cfg.get_node_host("pve1") == "pve1.example.com" + assert cfg.get_node_host("pve6") == "[::1]" + assert cfg.get_node_host("missing") is None + # The raw .host value is preserved for proxmoxer which accepts host:port. + assert cfg.pve_nodes[0].host == "pve1.example.com:443" + + def test_redacted_masks_secrets(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PVE1_TOKEN_SECRET", "abcdefghij") path = _write( From d7ab887133cc3ce1a09a08390f84d41af33a5be9 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 05:02:18 +0200 Subject: [PATCH 050/155] =?UTF-8?q?Rename=20indicator=20'Generating?= =?UTF-8?q?=E2=80=A6'=20to=20'Thinking=E2=80=A6'=20and=20slow=20animations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dot pulse: 1.2s -> 2.4s - shimmer: 2.2s -> 4s Also note the localhost-for-the-running-node Proxmox config pattern in the README configuration table. It is the simplest way to avoid routing API calls back through a reverse proxy or Cloudflare tunnel and to keep asyncssh able to reach port 22 (the public FQDN usually does not expose SSH). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- src/beaconmcp/dashboard/static/app.css | 4 ++-- src/beaconmcp/dashboard/static/chat.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f1f6b0b..f54f70a 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ Common keys: |---------|-------| | `server.allowed_hosts` | DNS-rebinding allowlist — **must** include the public FQDN behind your reverse proxy. | | `server.allowed_origins` | CORS allowlist for browser-based MCP clients. | -| `proxmox.nodes[]` | One entry per Proxmox node. Needs an API token per node. | +| `proxmox.nodes[]` | One entry per Proxmox node. Needs an API token per node. For the node BeaconMCP itself runs on, use `host: localhost` — both the API (`:8006`) and SSH (`:22`) are reachable locally without going through the reverse proxy or tunnel. Remote nodes can use `host: :443` if you have a reverse proxy in front of their API. | | `ssh.vmid_to_ip` | Optional template (e.g. `"192.168.1.{id}"`) used by `ssh_exec_command` when the `host` argument is a bare VMID. Omit to disable numeric-ID shortcuts. | | `bmc.devices[]` | Zero or more BMCs. `type` is one of `hp_ilo`, `ipmi`, `idrac` (stub), `supermicro` (stub). `jump_host` is optional — set it to the name of a `proxmox.nodes[]` entry to route the connection over an SSH tunnel. | | `features.dashboard.limits` | Per-5h and per-week USD caps for the Gemini chat. Set to `0` to disable a window. | diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index 6a36c62..ad7d093 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -578,7 +578,7 @@ a.sidebar-link { cursor: pointer; } height: 7px; border-radius: 50%; background: currentColor; - animation: generating-dot-pulse 1.2s ease-in-out infinite; + animation: generating-dot-pulse 2.4s ease-in-out infinite; } .generating-label { @@ -592,7 +592,7 @@ a.sidebar-link { cursor: pointer; } -webkit-background-clip: text; background-clip: text; color: transparent; - animation: generating-shimmer 2.2s linear infinite; + animation: generating-shimmer 4s linear infinite; } @keyframes generating-dot-pulse { diff --git a/src/beaconmcp/dashboard/static/chat.js b/src/beaconmcp/dashboard/static/chat.js index 3f9b27a..d7119f2 100644 --- a/src/beaconmcp/dashboard/static/chat.js +++ b/src/beaconmcp/dashboard/static/chat.js @@ -771,7 +771,7 @@ async function streamTurn(userText) { const indicator = document.createElement("div"); indicator.className = "generating-indicator"; indicator.innerHTML = - 'Generating…'; + 'Thinking…'; row.append(indicator); scrollToBottom(); From d22b09479a2d086e58a614231d8b8a11831dd967 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 05:06:07 +0200 Subject: [PATCH 051/155] Recommend deploying BeaconMCP on the primary Proxmox node Document the recommended deployment pattern (install on one of the Proxmox nodes, typically pve1, and address it as host: localhost) in three places: - README architecture section: highlight the recommendation and the reasons (no reverse proxy or tunnel in the local API/SSH path, BMC jump tunnel works out of the box) - README installation step: mention SSHing to the primary node before running install.sh - beaconmcp.yaml.example: switch the first node from a fictitious FQDN to `host: localhost`, with a block comment explaining when to use it and when a remote FQDN is appropriate Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 +++++- beaconmcp.yaml.example | 13 ++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f54f70a..31901ac 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ Clients (Claude, ChatGPT, Gemini) BeaconMCP runs on any host that can reach the Proxmox API of every declared node and the BMC management network. It speaks MCP over Streamable HTTP and is typically placed behind a reverse proxy with DNS-rebinding protection configured via `server.allowed_hosts` in the YAML. +**Recommended deployment:** run BeaconMCP **directly on one of your Proxmox nodes** (the primary one, conventionally `pve1`). That node becomes addressable as `host: localhost` in the YAML — both the Proxmox API (`:8006`) and SSH (`:22`) are reachable without a reverse proxy or tunnel, which also lets the `bmc_*` SSH-jump tunnel feature (HP iLO on a private management VLAN) work without extra configuration. Remote nodes in the cluster keep using their public FQDN. + --- ## Requirements @@ -72,6 +74,8 @@ BeaconMCP runs on any host that can reach the Proxmox API of every declared node ### 1. Install +SSH to the Proxmox node that will host BeaconMCP (we recommend your primary node — pve1 in typical setups), then: + ```bash git clone https://github.com/Showdown76py/BeaconMCP.git /opt/beaconmcp cd /opt/beaconmcp @@ -222,7 +226,7 @@ Common keys: |---------|-------| | `server.allowed_hosts` | DNS-rebinding allowlist — **must** include the public FQDN behind your reverse proxy. | | `server.allowed_origins` | CORS allowlist for browser-based MCP clients. | -| `proxmox.nodes[]` | One entry per Proxmox node. Needs an API token per node. For the node BeaconMCP itself runs on, use `host: localhost` — both the API (`:8006`) and SSH (`:22`) are reachable locally without going through the reverse proxy or tunnel. Remote nodes can use `host: :443` if you have a reverse proxy in front of their API. | +| `proxmox.nodes[]` | One entry per Proxmox node. Needs an API token per node. For the host BeaconMCP itself runs on, use `host: localhost` — both the API (`:8006`) and SSH (`:22`) are reachable locally without going through any reverse proxy or tunnel. Remote nodes in the cluster use their FQDN (append `:443` if a reverse proxy terminates the API). | | `ssh.vmid_to_ip` | Optional template (e.g. `"192.168.1.{id}"`) used by `ssh_exec_command` when the `host` argument is a bare VMID. Omit to disable numeric-ID shortcuts. | | `bmc.devices[]` | Zero or more BMCs. `type` is one of `hp_ilo`, `ipmi`, `idrac` (stub), `supermicro` (stub). `jump_host` is optional — set it to the name of a `proxmox.nodes[]` entry to route the connection over an SSH tunnel. | | `features.dashboard.limits` | Per-5h and per-week USD caps for the Gemini chat. Set to `0` to disable a window. | diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index 4356ae8..8dfa22b 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -32,14 +32,21 @@ proxmox: verify_ssl: false # One entry per Proxmox node. N nodes supported; the first one is not special. # Each node needs an API token (Datacenter > Permissions > API Tokens). + # + # Recommended deployment: install BeaconMCP directly on one of your + # Proxmox nodes (typically pve1). That node is then addressable as + # "localhost" — the API on :8006 and SSH on :22 are both reachable + # without traversing a reverse proxy or tunnel, which also lets the + # BMC-over-SSH-tunnel feature work out of the box. Remote nodes use + # their public FQDN (with a reverse-proxy port if applicable). nodes: - name: pve1 - host: pve1.example.com - token_id: "root@pam!beaconmcp" # quotes required: '!' and '@' are YAML-reserved + host: localhost # node BeaconMCP runs on + token_id: "root@pam!beaconmcp" # quotes required: '!' and '@' are YAML-reserved token_secret: ${PVE1_TOKEN_SECRET} - name: pve2 - host: pve2.example.com + host: pve2.example.com:443 # reachable remote node (reverse-proxied API) token_id: "root@pam!beaconmcp" token_secret: ${PVE2_TOKEN_SECRET} From 1abb61ce992d251eb2d3a04d04b7f4d832b5b769 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 05:19:07 +0200 Subject: [PATCH 052/155] Switch to Apache 2.0 + Commons Clause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Commons Clause rider sits on top of the existing Apache 2.0 license and removes the "Sell" right — third parties may no longer provide the software (or a hosted/managed version of it) to others for a fee without a separate commercial license. All other rights granted by Apache 2.0 (use, modify, fork, redistribute at no charge) are preserved. Also swap the README license badge from the auto-detected GitHub shield (which would mislabel a non-standard combo) to an explicit Apache 2.0 + Commons Clause badge. Co-Authored-By: Claude Opus 4.7 (1M context) --- LICENSE | 24 ++++++++++++++++++++++++ README.md | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 261eeb9..3956e8e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,27 @@ +"Commons Clause" License Condition v1.0 + +The Software is provided to you by the Licensor under the License, as +defined below, subject to the following condition. + +Without limiting other conditions in the License, the grant of rights +under the License will not include, and the License does not grant to +you, the right to Sell the Software. + +For purposes of the foregoing, "Sell" means practicing any or all of +the rights granted to you under the License to provide to third parties, +for a fee or other consideration (including without limitation fees for +hosting or consulting/support services related to the Software), a +product or service whose value derives, entirely or substantially, from +the functionality of the Software. Any license notice or attribution +required by the License must also include this Commons Clause License +Condition notice. + +Software: TarkaMCP +License: Apache License, Version 2.0 (with Commons Clause) +Licensor: Showdown76py + +------------------------------------------------------------------------------- + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ diff --git a/README.md b/README.md index 31901ac..df92849 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![IPMI](https://img.shields.io/badge/IPMI-2.0-4E5D70)](https://en.wikipedia.org/wiki/Intelligent_Platform_Management_Interface) [![ChatGPT](https://img.shields.io/badge/ChatGPT-Compatible-74AA9C?logo=openai&logoColor=white)](https://chatgpt.com/) [![Gemini](https://img.shields.io/badge/Gemini-Compatible-4285F4?logo=google&logoColor=white)](https://gemini.google.com/) -[![License](https://img.shields.io/github/license/Showdown76py/BeaconMCP)](LICENSE) +[![License](https://img.shields.io/badge/license-Apache_2.0_%2B_Commons_Clause-red)](LICENSE) **Remote MCP server for Proxmox VE clusters and BMC-managed hardware.** @@ -324,4 +324,4 @@ Common errors, their causes, and the fixes that worked are in [docs/troubleshoot ## License -[Apache 2.0](LICENSE) +[Apache 2.0 with Commons Clause](LICENSE) — use, fork, and modification are free, but **reselling the software (including as a hosted service) requires a separate commercial license**. The code remains source-available. From a54701ac77169b88aa2ae8e82c57b1e48affef1e Mon Sep 17 00:00:00 2001 From: Lony <66854264+Showdown76py@users.noreply.github.com> Date: Fri, 17 Apr 2026 05:22:14 +0200 Subject: [PATCH 053/155] Modify LICENSE for software name and copyright Updated software name and copyright year in LICENSE file. --- LICENSE | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 3956e8e..baf3786 100644 --- a/LICENSE +++ b/LICENSE @@ -16,7 +16,7 @@ the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice. -Software: TarkaMCP +Software: BeaconMCP/TarkaMCP License: Apache License, Version 2.0 (with Commons Clause) Licensor: Showdown76py @@ -210,7 +210,7 @@ Licensor: Showdown76py same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 Showdown76py Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From cd9ac476884c90de0a74f632200141b0f8e2c9a6 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 10:35:16 +0200 Subject: [PATCH 054/155] Render GFM tables in the dashboard chat Co-Authored-By: Claude Opus 4.7 (1M context) --- src/beaconmcp/dashboard/static/app.css | 24 +++++++++ src/beaconmcp/dashboard/static/chat.js | 73 ++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index ad7d093..115f40d 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -693,6 +693,30 @@ a.sidebar-link { cursor: pointer; } margin: 1rem 0; } +/* Tables (GFM) */ +.msg-body.md .md-table { + border-collapse: collapse; + margin: 0.5rem 0 0.85rem; + display: block; + overflow-x: auto; + max-width: 100%; + font-size: 0.9rem; +} +.msg-body.md .md-table th, +.msg-body.md .md-table td { + border: 1px solid var(--border); + padding: 0.35rem 0.6rem; + text-align: left; + vertical-align: top; +} +.msg-body.md .md-table thead th { + background: var(--bg-soft); + font-weight: 600; +} +.msg-body.md .md-table tbody tr:nth-child(even) td { + background: color-mix(in srgb, var(--bg-soft) 50%, transparent); +} + /* Tool cards */ .tool-card { diff --git a/src/beaconmcp/dashboard/static/chat.js b/src/beaconmcp/dashboard/static/chat.js index d7119f2..e758cbe 100644 --- a/src/beaconmcp/dashboard/static/chat.js +++ b/src/beaconmcp/dashboard/static/chat.js @@ -78,6 +78,48 @@ function _stripOrdered(line) { return line.replace(/^\s*\d+\.\s+/, ""); } +// GitHub-flavored table row: splits `| a | b |` into ["a", "b"]. Handles +// optional leading/trailing pipes. Escaped pipes (\|) are preserved as "|". +function _splitTableRow(line) { + const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, ""); + const cells = []; + let buf = ""; + for (let k = 0; k < trimmed.length; k += 1) { + const ch = trimmed[k]; + if (ch === "\\" && trimmed[k + 1] === "|") { + buf += "|"; + k += 1; + continue; + } + if (ch === "|") { + cells.push(buf.trim()); + buf = ""; + continue; + } + buf += ch; + } + cells.push(buf.trim()); + return cells; +} + +function _isTableSeparator(line) { + if (!line || !/\|/.test(line)) return false; + const cells = _splitTableRow(line); + if (cells.length === 0) return false; + return cells.every((c) => /^:?-{3,}:?$/.test(c)); +} + +function _tableAlignments(sepLine) { + return _splitTableRow(sepLine).map((c) => { + const left = c.startsWith(":"); + const right = c.endsWith(":"); + if (left && right) return "center"; + if (right) return "right"; + if (left) return "left"; + return null; + }); +} + function renderMarkdown(text) { // 1. Pull fenced code blocks out so nothing munges their contents. const { stripped, blocks } = _extractFences(text); @@ -139,6 +181,37 @@ function renderMarkdown(text) { continue; } + // GFM table: header row followed by a separator row (| --- | --- |) + if (/\|/.test(line) && i + 1 < lines.length && _isTableSeparator(lines[i + 1])) { + flushParagraph(); + const headers = _splitTableRow(line); + const aligns = _tableAlignments(lines[i + 1]); + i += 2; + const rows = []; + while (i < lines.length && /\|/.test(lines[i]) && !/^\s*$/.test(lines[i])) { + rows.push(_splitTableRow(lines[i])); + i += 1; + } + const alignAttr = (idx) => + aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""; + const thead = `${headers + .map((h, idx) => `${_renderInline(escapeHtml(h))}`) + .join("")}`; + const tbody = `${rows + .map( + (r) => + `${r + .map( + (c, idx) => + `${_renderInline(escapeHtml(c))}`, + ) + .join("")}`, + ) + .join("")}`; + out.push(`${thead}${tbody}
`); + continue; + } + // Unordered list: consume consecutive bullet lines if (_isBulletLine(line)) { flushParagraph(); From 27f4c85e393dffdf6492b28f9eca5e22586a8563 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 11:20:45 +0200 Subject: [PATCH 055/155] Split Connecting clients docs per provider and remove TOTP-generation guidance Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 114 +++++++++++++++++++++++++++++------------------------- 1 file changed, 61 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index df92849..f29fd66 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,13 @@ Place BeaconMCP behind a reverse proxy that terminates TLS and forwards the publ ## Connecting clients +> **Security note — always type the TOTP by hand from your phone.** +> The TOTP seed belongs in an authenticator app on a device you physically control (Google Authenticator, Authy, 1Password, Aegis, a YubiKey with OTP, etc.). Do **not** generate codes programmatically with `oathtool` / `pyotp` / a shell alias, and do **not** store the raw seed in a `.env`, a secrets manager, or next to the client secret — doing so collapses the two factors into one and removes the protection TOTP exists to provide. Every flow below is designed so you read a 6-digit code off your phone and type it into either the authorization page or the dashboard. + ### Claude (web, mobile, desktop) +Claude performs the full OAuth 2.1 flow against BeaconMCP, so there is no long-lived bearer to store on its side — you type the TOTP into the authorization page whenever a new token is issued. + 1. **Settings → Integrations → Add custom connector.** 2. Fill in: - **Name:** BeaconMCP @@ -139,69 +144,72 @@ Place BeaconMCP behind a reverse proxy that terminates TLS and forwards the publ - **OAuth Client ID** and **OAuth Client Secret** from `beaconmcp auth create`. 3. **Add.** -On first use, Claude redirects to the BeaconMCP authorization page, which prompts for the 6-digit TOTP code. Tokens last 24 hours; Claude re-prompts at expiry. +On first use (and after each 24-hour token expiry) Claude redirects to the BeaconMCP authorization page. Read the current 6-digit code from your authenticator app and type it in. This is the recommended integration: Claude never holds the TOTP seed, and a leaked session cannot mint a new token without a fresh code from your phone. ### ChatGPT -1. **Settings → Developer Mode → MCP Servers.** -2. URL: `https:///mcp`. -3. Obtain a bearer token either from the dashboard's **API Tokens** page (recommended) or via a direct OAuth token request: +ChatGPT's MCP connector expects a static bearer header, so an OAuth redirect is not an option. Mint the bearer yourself from the dashboard (where you type the TOTP from your phone) and paste it into ChatGPT. + +1. Open `https:///app/login` in a browser, sign in, and type your current TOTP code from your authenticator app. +2. Go to **API Tokens**, click **Create token**, give it a name (e.g. `chatgpt`), and copy the token shown. It is displayed once. +3. In ChatGPT: **Settings → Developer Mode → MCP Servers → Add**. + - **URL:** `https:///mcp` + - **Authorization:** `Bearer ` +4. When the token expires or you no longer need it, revoke it from the same **API Tokens** page. Issue a new one the same way — always via the dashboard, never by scripting the TOTP. + +### Gemini CLI + +Gemini CLI sends a static `Authorization` header with every call, so the bearer is generated the same way as for ChatGPT. + +1. In the dashboard (`/app/tokens`), authenticate with your TOTP from your phone, then create a new token named `gemini-cli`. +2. Register the MCP server: ```bash - TOTP=$(oathtool --totp -b "$TOTP_SECRET") - curl -X POST https:///oauth/token \ - -d "grant_type=client_credentials&client_id=$ID&client_secret=$SECRET&totp=$TOTP" + gemini mcp add beaconmcp \ + --url https:///mcp \ + --header "Authorization: Bearer " ``` -4. Use the returned `access_token` as the bearer. Expires in 24 hours. - -### Gemini CLI +3. Replace the token via the same dashboard flow when it expires — do not bake TOTP generation into a shell alias or wrapper script. + +### Gemini API (google-genai SDK) + +For programmatic Gemini API usage, the BeaconMCP server is passed as a remote MCP tool. The SDK needs an `Authorization` header at call time; obtain the bearer interactively from the dashboard rather than letting the process derive TOTP codes on its own. + +1. Create a dashboard token as in the Gemini CLI section above. +2. Put the resulting bearer in your environment (e.g. `BEACONMCP_TOKEN`) or in your secrets manager. **Do not put the TOTP seed there.** +3. Reference it when invoking the model: + + ```python + import os + from google import genai + + token = os.environ["BEACONMCP_TOKEN"] + + client = genai.Client() + response = client.models.generate_content( + model="gemini-2.0-flash", + contents="List the VMs on pve1", + config={ + "tools": [ + { + "mcp_servers": [ + { + "url": "https:///mcp", + "headers": {"Authorization": f"Bearer {token}"}, + } + ] + } + ] + }, + ) + ``` -```bash -gemini mcp add beaconmcp \ - --url https:///mcp \ - --header "Authorization: Bearer " -``` +4. When the bearer expires, re-issue it through the dashboard. Long-running services should rotate tokens on a schedule (an operator typing the TOTP) rather than embedding the seed. -Issue the token either from the dashboard or from the `curl` snippet above. - -### Gemini API - -```python -import requests, pyotp -from google import genai - -totp = pyotp.TOTP(TOTP_SECRET).now() -token = requests.post( - "https:///oauth/token", - data={ - "grant_type": "client_credentials", - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "totp": totp, - }, -).json()["access_token"] - -client = genai.Client() -response = client.models.generate_content( - model="gemini-2.0-flash", - contents="List the VMs on pve1", - config={ - "tools": [ - { - "mcp_servers": [ - { - "url": "https:///mcp", - "headers": {"Authorization": f"Bearer {token}"}, - } - ] - } - ] - }, -) -``` +### Other MCP-over-HTTP clients -Storing the TOTP seed next to the client secret defeats the second factor. Prefer a secrets manager or a hardware authenticator for production workloads. +Any client that can send a bearer on `https:///mcp` works the same way: create a token from `/app/tokens` after typing your TOTP, configure the client to send `Authorization: Bearer `, revoke from the same page when you are done. If the client natively speaks OAuth 2.1 (like Claude), prefer that flow — it keeps the TOTP prompt at the authorization page instead of relying on a stored bearer. --- From d6bd01c4ba8381e1283c563ba5730a93e6e18f5f Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 11:24:24 +0200 Subject: [PATCH 056/155] Document the TOTP automation escape hatch with prominent warnings Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 + docs/totp-automation.md | 129 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 docs/totp-automation.md diff --git a/README.md b/README.md index f29fd66..dcf056e 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,8 @@ Place BeaconMCP behind a reverse proxy that terminates TLS and forwards the publ > **Security note — always type the TOTP by hand from your phone.** > The TOTP seed belongs in an authenticator app on a device you physically control (Google Authenticator, Authy, 1Password, Aegis, a YubiKey with OTP, etc.). Do **not** generate codes programmatically with `oathtool` / `pyotp` / a shell alias, and do **not** store the raw seed in a `.env`, a secrets manager, or next to the client secret — doing so collapses the two factors into one and removes the protection TOTP exists to provide. Every flow below is designed so you read a 6-digit code off your phone and type it into either the authorization page or the dashboard. +> +> Unattended services (scheduled jobs, CI pipelines) occasionally need machine-held TOTP. That case — with its required precautions and warnings — is covered separately in [docs/totp-automation.md](docs/totp-automation.md). Read it end-to-end before considering automation. ### Claude (web, mobile, desktop) diff --git a/docs/totp-automation.md b/docs/totp-automation.md new file mode 100644 index 0000000..1baddff --- /dev/null +++ b/docs/totp-automation.md @@ -0,0 +1,129 @@ +# Automating the TOTP flow + +> [!CAUTION] +> **Read this page end-to-end before automating anything.** Everything below describes how to derive BeaconMCP's second factor from a machine. Doing so **defeats the purpose of the second factor**: whoever holds the seed *is* the second factor. The recommended setup — the one documented in [README.md](../README.md#connecting-clients) — is to keep the TOTP seed in an authenticator app on a phone you physically control and type the 6-digit code by hand. Automation exists for a narrow set of legitimate cases (unattended services, CI pipelines, scheduled jobs on your own infra). If you are a human at a keyboard, do not automate this. + +--- + +## When automation is *not* appropriate + +Do **not** automate TOTP in any of these situations: + +- You are an interactive user (desktop, CLI session, chat UI). Type the code from your phone. +- The seed would sit on a laptop, a shared workstation, or any machine where the BeaconMCP client secret already lives. That collapses two factors into one. +- The seed would be stored in a `.env` file, a dotfile, a git-tracked secrets file, a shell alias, or a password manager entry next to the client secret. +- The seed would be pasted into a chat client, an LLM prompt, a notebook, or an IDE workspace. +- The automation is a convenience shortcut ("I don't want to pick up my phone"). Pick up your phone. + +If any of these apply, stop reading and go back to the [regular connection flow](../README.md#connecting-clients). + +## When automation is (reluctantly) acceptable + +A very small number of scenarios justify machine-held TOTP: + +- **An unattended service on infrastructure you fully control** that needs to mint BeaconMCP bearers without a human present (e.g. a scheduled backup job that lists VMs before snapshotting, a monitoring daemon that calls read-only tools). +- **CI/CD pipelines** that need an integration test against a staging BeaconMCP instance. Use a *dedicated staging* seed, not the production one. +- **A hardened token-minting broker** that sits on a locked-down host, holds the seed in a KMS / HSM / Vault, and hands out short-lived bearers to other services over mTLS. + +In all three cases the seed is **not** your everyday TOTP seed — it is a separate OAuth client created just for the automation, with its own TOTP, its own access audit, and a revocation plan. + +## If you must automate — do it safely + +> [!WARNING] +> Every rule below is a floor, not a ceiling. The seed is equivalent to a permanent bypass of the second factor. Treat it like a private key. + +### 1. Create a dedicated OAuth client per automation + +```bash +beaconmcp auth create --name "ci-staging" +``` + +- One client per job / service. Never share. +- Label it clearly so you can revoke the right one in an incident. +- Record `client_id` and owner in your inventory; do not record the secret or the seed there. + +### 2. Store the seed in a real secrets backend + +Acceptable: + +- HashiCorp Vault with short-lived dynamic credentials (the seed itself is static, but access to it is leased). +- AWS Secrets Manager / GCP Secret Manager / Azure Key Vault with IAM scoped to the single workload that needs it. +- SOPS-encrypted file with age/gpg keys held by the automation host only. + +Not acceptable: + +- Plain environment variables on a shared host. +- `.env` files committed to git (even private repos). +- CI secret variables that are readable by every pipeline in the project. +- Anywhere a human can cat the value without going through an audit log. + +### 3. Minimise blast radius + +- **Scope the client**: if BeaconMCP grows per-client scopes, use the narrowest one. Today, assume any valid bearer can run any tool — that means the automation can reboot your cluster. Treat it as such. +- **Rate-limit at the reverse proxy**: cap `POST /oauth/token` per source IP so a leaked seed cannot be used to mint thousands of tokens. +- **IP-allowlist at the reverse proxy**: restrict `/oauth/token` to the automation host's egress IP where possible. +- **Log and alert**: every `/oauth/token` call from an automation client should be logged with source IP, user agent, and `client_id`. Alert on anything outside the expected window. + +### 4. Rotate aggressively + +- Rotate the TOTP seed (and ideally the client secret) on a schedule — monthly at minimum, weekly if the automation is internet-exposed. +- Rotate immediately if the automation host is rebuilt, reimaged, restored from backup, or if any operator with access leaves. +- Keep a documented rotation runbook. Seeds that can't be rotated without downtime will not be rotated. + +### 5. Never mix the automation seed with a human seed + +The seed you type from your phone and the seed a script reads from Vault are **different seeds on different OAuth clients**. If a human TOTP seed ever lands on a server, treat the account as compromised: revoke the client, re-enrol the authenticator app, rotate the client secret. + +## Example — unattended service (illustrative) + +The example below shows the *shape* of a safe automation. Before copy-pasting, confirm every item in the checklist above applies to your environment. + +```python +# Runs on a locked-down service host. +# Seed lives in Vault; this process is the only thing with read access. + +import os +import pyotp +import requests + +VAULT = get_vault_client() # your infra +secret = VAULT.read("beaconmcp/ci-staging") # {client_id, client_secret, totp_seed} + +totp = pyotp.TOTP(secret["totp_seed"]).now() +resp = requests.post( + "https://beaconmcp.internal/oauth/token", + data={ + "grant_type": "client_credentials", + "client_id": secret["client_id"], + "client_secret": secret["client_secret"], + "totp": totp, + }, + timeout=5, +) +resp.raise_for_status() +bearer = resp.json()["access_token"] # 24h lifetime + +# Use `bearer` to call /mcp. Do not log it. Do not persist it beyond the job. +``` + +Things this example deliberately does **not** show, because they are your responsibility: + +- How Vault authentication is bootstrapped on the host (AppRole? instance identity? workload identity federation?). +- How the host is hardened (disk encryption, SSH access policy, syslog forwarding). +- How `resp.json()` is scrubbed from any logs your HTTP client produces by default. + +If you are not already confident about all three, you are not ready to automate TOTP. + +## Revocation + +Whenever you suspect exposure — a host reimage, an accidental `git push` of a secrets file, an ex-colleague's laptop, a weird `/oauth/token` entry in the access log: + +```bash +beaconmcp auth revoke +``` + +Then rotate the seed and the client secret, and audit recent tool calls in the BeaconMCP access log. Revocation is cheap. Do it first, investigate second. + +--- + +Back to the normal flow: [Connecting clients](../README.md#connecting-clients). From b7ffa19222941dc207c600589a53756668aa0be7 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 11:56:36 +0200 Subject: [PATCH 057/155] Support OAuth Dynamic Client Registration via slug-gated bootstrap URLs ChatGPT's MCP connector only accepts OAuth with DCR (no static bearer, no pre-provisioned client). Add a narrow DCR path that the dashboard mints on demand: a single-use, short-lived slug URL lets ChatGPT register a derived OAuth client bound to its human owner. Derived clients delegate TOTP to the owner so 2FA never leaves the owner's phone, and revoking the owner cascades to every derived client. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 34 ++- beaconmcp.yaml.example | 6 + src/beaconmcp/__main__.py | 187 +++++++++++- src/beaconmcp/auth.py | 126 +++++++- src/beaconmcp/config.py | 10 + src/beaconmcp/dashboard/app.py | 177 +++++++++++ src/beaconmcp/dashboard/db.py | 22 +- src/beaconmcp/dashboard/dyn_reg.py | 173 +++++++++++ .../dashboard/templates/connectors.html | 146 +++++++++ tests/test_dashboard_unit.py | 2 +- tests/test_dynamic_registration.py | 288 ++++++++++++++++++ 11 files changed, 1138 insertions(+), 33 deletions(-) create mode 100644 src/beaconmcp/dashboard/dyn_reg.py create mode 100644 src/beaconmcp/dashboard/templates/connectors.html create mode 100644 tests/test_dynamic_registration.py diff --git a/README.md b/README.md index dcf056e..46bb814 100644 --- a/README.md +++ b/README.md @@ -150,14 +150,32 @@ On first use (and after each 24-hour token expiry) Claude redirects to the Beaco ### ChatGPT -ChatGPT's MCP connector expects a static bearer header, so an OAuth redirect is not an option. Mint the bearer yourself from the dashboard (where you type the TOTP from your phone) and paste it into ChatGPT. - -1. Open `https:///app/login` in a browser, sign in, and type your current TOTP code from your authenticator app. -2. Go to **API Tokens**, click **Create token**, give it a name (e.g. `chatgpt`), and copy the token shown. It is displayed once. -3. In ChatGPT: **Settings → Developer Mode → MCP Servers → Add**. - - **URL:** `https:///mcp` - - **Authorization:** `Bearer ` -4. When the token expires or you no longer need it, revoke it from the same **API Tokens** page. Issue a new one the same way — always via the dashboard, never by scripting the TOTP. +ChatGPT's Developer Mode connector only accepts **OAuth with Dynamic Client Registration (RFC 7591)** — it will not take a pre-provisioned `client_id` / `client_secret` nor a static bearer header. BeaconMCP supports this by minting a one-off bootstrap URL from the dashboard: the URL lets ChatGPT register a derived OAuth client tied to your account. 2FA is preserved — at authorization time, you still type your own TOTP from your phone; the derived client has no TOTP seed of its own. + +**One-time setup:** + +1. Enable the feature in `beaconmcp.yaml`: + ```yaml + server: + allow_dynamic_registration: true + ``` + Then restart `beaconmcp serve`. + +**To add ChatGPT (from your phone, no laptop needed):** + +1. In your mobile browser, open `https:///app/connectors`, sign in with your TOTP from your authenticator app. +2. Enter a label (e.g. `ChatGPT iPhone`), type your current TOTP, submit. You get a one-off URL of the shape `https:///mcp/c/`. The URL is **single-use** and expires in 15 min. +3. In the ChatGPT app: **Settings → Connectors → Add custom**. + - **Name:** BeaconMCP + - **URL:** paste the `/mcp/c/` URL. + - **Authentication:** OAuth. +4. ChatGPT fetches the OAuth metadata, POSTs to the slug-gated `/oauth/register/c/` — BeaconMCP consumes the slug atomically and mints a derived client scoped to your account. +5. ChatGPT then redirects you to BeaconMCP's authorization page. Type your TOTP from your phone. Token lifetime: 24 h. +6. From now on, ChatGPT auto-refreshes via the authorization code flow. Every 24 h it re-prompts for your TOTP — no re-registration, no new slug. + +**Revocation:** `https:///app/connectors` lists every active derived client. Revoke one and ChatGPT loses access immediately. Revoking your human account cascades to every derived client automatically. + +**Why not a static bearer?** ChatGPT's connector UI has no "Authorization header" field — only "No authentication" or "OAuth" — and the OAuth path strictly requires DCR. The slug-gated bootstrap is the narrow, audit-friendly way to let it in while keeping your TOTP on your phone. ### Gemini CLI diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index 8dfa22b..04ceebe 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -27,6 +27,12 @@ server: - https://gemini.google.com clients_file: /opt/beaconmcp/clients.json session_key: ${BEACONMCP_SESSION_KEY} # optional, generated if omitted + # Enable the OAuth Dynamic Client Registration bootstrap flow used by + # clients that cannot accept a pre-provisioned client_id/secret pair + # (ChatGPT in particular). Each registration is gated by a single-use + # slug minted from the dashboard after you type your TOTP. Off by + # default — turn on only if you need ChatGPT integration. + allow_dynamic_registration: false proxmox: verify_ssl: false diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 0312b71..2e40889 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -494,14 +494,31 @@ async def auth_middleware(request: Request, call_next): ): return await call_next(request) + # Slug-scoped DCR endpoints are public by design — the slug is the + # capability. The downstream handler rejects unknown/expired slugs. + if ( + path.startswith("/.well-known/oauth-protected-resource/mcp/c/") + or path.startswith("/.well-known/oauth-authorization-server/as/") + or path.startswith("/oauth/register/c/") + ): + return await call_next(request) + # Dashboard routes have their own session-based auth. if path.startswith("/app/"): return await call_next(request) # MCP 2025-06-18 + RFC 9728: point unauth'd clients at the resource - # metadata so they can discover the authorization server. + # metadata so they can discover the authorization server. For slug- + # scoped URLs (/mcp/c/) we point at the matching slug-scoped + # metadata so ChatGPT's DCR flow lands on /oauth/register/c/ + # instead of the disabled global /oauth/register. issuer = _issuer(request) - resource_meta = f"{issuer}/.well-known/oauth-protected-resource" + if path.startswith("/mcp/c/") and dyn_reg_store is not None: + resource_meta = ( + f"{issuer}/.well-known/oauth-protected-resource{path}" + ) + else: + resource_meta = f"{issuer}/.well-known/oauth-protected-resource" authorization = request.headers.get("authorization", "") if not authorization.startswith("Bearer "): @@ -532,20 +549,171 @@ async def auth_middleware(request: Request, call_next): finally: current_bearer_token.reset(token_var) - mcp_app = mcp.streamable_http_app() + # OAuth Dynamic Client Registration plumbing. Only engaged when both + # the feature flag is set AND the dashboard is enabled (the slug store + # lives in the dashboard's SQLite db). + from . import dashboard as _dashboard_mod + dyn_reg_store = None + shared_database = None + if config.server.allow_dynamic_registration: + if not _dashboard_mod.is_enabled(): + print( + "ERROR: server.allow_dynamic_registration requires the dashboard " + "(BEACONMCP_DASHBOARD_ENABLED=true). DCR state lives in the " + "dashboard's database.", + file=sys.stderr, + ) + sys.exit(1) + from .dashboard.db import Database as _Database + from .dashboard.dyn_reg import DynamicSlugStore as _DynamicSlugStore + shared_database = _Database() + dyn_reg_store = _DynamicSlugStore(shared_database) + + async def dcr_protected_resource_metadata(request: Request) -> Response: + # RFC 9728 resource metadata served at the slug-scoped path so + # ChatGPT (which uses the pasted URL as the resource) discovers + # the right authorization server. The issuer URL is also slug- + # scoped so the AS metadata can advertise a slug-specific + # registration_endpoint. + slug = request.path_params["slug"] + issuer = _issuer(request) + resource = f"{issuer}/mcp/c/{slug}" + return JSONResponse({ + "resource": resource, + "authorization_servers": [f"{issuer}/as/{slug}"], + "bearer_methods_supported": ["header"], + }) + + async def dcr_oauth_metadata(request: Request) -> Response: + slug = request.path_params["slug"] + issuer = _issuer(request) + return JSONResponse({ + "issuer": f"{issuer}/as/{slug}", + "authorization_endpoint": f"{issuer}/oauth/authorize", + "token_endpoint": f"{issuer}/oauth/token", + "registration_endpoint": f"{issuer}/oauth/register/c/{slug}", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["client_secret_post"], + }) + + async def dcr_register(request: Request) -> Response: + if dyn_reg_store is None: + return JSONResponse({"error": "registration_not_supported"}, status_code=403) + slug = request.path_params["slug"] + row = dyn_reg_store.load(slug) + if row is None: + return JSONResponse( + {"error": "invalid_client_metadata", + "error_description": "unknown bootstrap slug"}, + status_code=404, + ) + try: + body = await request.json() + except Exception: + body = {} + client_name = "ChatGPT connector" + if isinstance(body, dict): + candidate = body.get("client_name") + if isinstance(candidate, str) and candidate.strip(): + client_name = candidate.strip()[:60] + + try: + new_client_id, new_client_secret = client_store.create_dynamic( + owner_client_id=row.owner_client_id, + name=f"{row.label} ({client_name})"[:120], + registration_source=f"chatgpt:{slug}", + ) + except ValueError: + return JSONResponse({"error": "invalid_client_metadata"}, status_code=400) + + try: + dyn_reg_store.consume(slug, new_client_id) + except Exception: + # Lost the race or slug expired between load + consume. Roll + # back the just-created client to keep state consistent. + client_store.revoke(new_client_id) + return JSONResponse( + {"error": "invalid_client_metadata", + "error_description": "bootstrap slug already used"}, + status_code=409, + ) + + # RFC 7591 response. We advertise only the grant and methods we + # actually honor; clients that expected client_credentials here + # should not be using DCR. + return JSONResponse({ + "client_id": new_client_id, + "client_secret": new_client_secret, + "client_id_issued_at": int(row.created_at), + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "redirect_uris": ( + body.get("redirect_uris") + if isinstance(body, dict) and isinstance(body.get("redirect_uris"), list) + else [] + ), + }, status_code=201) + + class _McpSlugRewriteApp: + """ASGI shim that strips ``/mcp/c/`` down to ``/mcp`` before + handing off to the real MCP app. The slug serves only as a URL + alias for clients (ChatGPT) that pasted the bootstrap URL and + have no reason to call a different path after DCR.""" + + def __init__(self, inner): + self._inner = inner + + async def __call__(self, scope, receive, send): + if scope["type"] == "http": + path = scope.get("path", "") + if path.startswith("/mcp/c/"): + remainder = path[len("/mcp/c/"):] + # Drop the slug segment itself; keep whatever follows. + slash = remainder.find("/") + suffix = remainder[slash:] if slash >= 0 else "" + new_path = "/mcp" + suffix + scope = dict(scope) + scope["path"] = new_path + raw = scope.get("raw_path") + if isinstance(raw, bytes): + scope["raw_path"] = new_path.encode("ascii") + await self._inner(scope, receive, send) + + mcp_app = _McpSlugRewriteApp(mcp.streamable_http_app()) # The MCP streamable-HTTP app starts its session manager task group in its # own lifespan. When we Mount it under a parent Starlette, only the parent # app's lifespan runs — so we forward the child's lifespan explicitly, # otherwise requests fail with "Task group is not initialized". + inner_mcp = mcp_app._inner @asynccontextmanager async def lifespan(_app): - async with mcp_app.router.lifespan_context(_app): + async with inner_mcp.router.lifespan_context(_app): yield + dcr_routes: list = [] + if dyn_reg_store is not None: + dcr_routes = [ + Route( + "/.well-known/oauth-protected-resource/mcp/c/{slug}", + dcr_protected_resource_metadata, + ), + Route( + "/.well-known/oauth-authorization-server/as/{slug}", + dcr_oauth_metadata, + ), + Route("/oauth/register/c/{slug}", dcr_register, methods=["POST"]), + ] + # Optional dashboard routes (login + chat panels at /app/*). - dashboard_routes = _build_dashboard_routes(client_store, token_store, totp_locked, - totp_record_failure, totp_record_success) + dashboard_routes = _build_dashboard_routes( + client_store, token_store, totp_locked, + totp_record_failure, totp_record_success, + dyn_reg=dyn_reg_store, shared_database=shared_database, + ) app = Starlette( routes=[ @@ -557,6 +725,7 @@ async def lifespan(_app): Route("/oauth/authorize", oauth_authorize_post, methods=["POST"]), Route("/oauth/token", oauth_token, methods=["POST"]), Route("/oauth/register", oauth_register, methods=["POST"]), + *dcr_routes, *dashboard_routes, Mount("/", app=mcp_app), ], @@ -582,7 +751,8 @@ async def lifespan(_app): def _build_dashboard_routes(client_store, token_store, totp_locked, - totp_record_failure, totp_record_success): + totp_record_failure, totp_record_success, + *, dyn_reg=None, shared_database=None): """Build dashboard routes if enabled. Returns [] when disabled.""" from . import dashboard if not dashboard.is_enabled(): @@ -595,7 +765,7 @@ def _build_dashboard_routes(client_store, token_store, totp_locked, from .dashboard.session import SessionStore from .dashboard.usage import Budget, UsageStore - database = Database() + database = shared_database if shared_database is not None else Database() session_store = SessionStore(database) conversations = ConversationStore(database) confirmations = ConfirmationStore() @@ -652,6 +822,7 @@ def _float_env(name: str, default: float) -> float: usage=usage, mcp_public_url=mcp_public_url, mcp_mode=mcp_mode, + dyn_reg=dyn_reg, ) return build_dashboard_routes(deps) diff --git a/src/beaconmcp/auth.py b/src/beaconmcp/auth.py index 5b3c23c..3023062 100644 --- a/src/beaconmcp/auth.py +++ b/src/beaconmcp/auth.py @@ -5,8 +5,12 @@ - ``authorization_code`` with mandatory PKCE (S256) for browser-based clients such as Claude Web / mobile connectors -Dynamic client registration (RFC 7591) is intentionally NOT supported: clients -must be created out-of-band via ``beaconmcp auth create``. +Dynamic client registration (RFC 7591) is available through a narrow, +opt-in path: the dashboard mints a single-use bootstrap URL that lets a +client (typically ChatGPT) self-register a derived OAuth client bound to +its human owner. The derived client has no independent TOTP seed — 2FA at +``/oauth/authorize`` is verified against the owner's seed so the second +factor never leaves the owner's phone. """ from __future__ import annotations @@ -66,7 +70,18 @@ class Client: client_secret_hash: str name: str created_at: float - totp_secret: str # base32-encoded TOTP seed; plaintext on purpose + # base32-encoded TOTP seed; plaintext on purpose. Empty string for + # dynamically-registered clients, which delegate TOTP verification to + # their owner. + totp_secret: str + # For clients born of an OAuth DCR bootstrap (e.g. ChatGPT), points at + # the human client whose TOTP seed guards /oauth/authorize for this + # client. ``None`` for CLI-provisioned clients (the common case). + owner_client_id: str | None = None + # Free-form tag describing how this client was created. ``None`` for + # CLI-provisioned clients; ``"chatgpt:"`` for DCR-created ones. + # Used by the dashboard to group and revoke derived clients. + registration_source: str | None = None @dataclass @@ -132,17 +147,28 @@ def _load(self) -> None: raise # Clients missing totp_secret predate the 2FA migration and are - # implicitly revoked. We log them, skip them, and rewrite the file - # below so they can't be re-loaded next boot. + # implicitly revoked UNLESS they have an owner_client_id (a derived + # DCR client delegates TOTP to its owner and legitimately has an + # empty seed). We log legacy-pre-2FA rows and drop them; we keep + # dynamic rows. revoked: list[str] = [] for c in data.get("clients", []): - if not c.get("totp_secret"): + has_secret = bool(c.get("totp_secret")) + has_owner = bool(c.get("owner_client_id")) + if not has_secret and not has_owner: revoked.append(f"{c.get('client_id', '?')} ({c.get('name', '?')})") continue try: - self._clients[c["client_id"]] = Client(**c) - except TypeError: - # Unknown fields or missing required fields: treat as revoked. + self._clients[c["client_id"]] = Client( + client_id=c["client_id"], + client_secret_hash=c["client_secret_hash"], + name=c["name"], + created_at=c["created_at"], + totp_secret=c.get("totp_secret", ""), + owner_client_id=c.get("owner_client_id"), + registration_source=c.get("registration_source"), + ) + except (KeyError, TypeError): revoked.append(f"{c.get('client_id', '?')} ({c.get('name', '?')})") if revoked: @@ -164,6 +190,8 @@ def _save(self) -> None: "name": c.name, "created_at": c.created_at, "totp_secret": c.totp_secret, + "owner_client_id": c.owner_client_id, + "registration_source": c.registration_source, } for c in self._clients.values() ] @@ -219,12 +247,25 @@ def verify_totp(self, client_id: str, code: str) -> bool: Uses ``valid_window=1`` so a ±30 s clock drift between the server and the authenticator app is tolerated. + + Dynamic clients (those with ``owner_client_id`` set) delegate + verification to the owner's seed: the human typing the code is + always the account owner, regardless of which client they are + minting a token for. The delegation chain is single-hop — an + owner whose own ``owner_client_id`` is set would be a bug. """ client = self._clients.get(client_id) if not client: return False if not code or not code.isdigit() or len(code) != 6: return False + if client.owner_client_id is not None: + owner = self._clients.get(client.owner_client_id) + if owner is None or not owner.totp_secret: + return False + return pyotp.TOTP(owner.totp_secret).verify(code, valid_window=1) + if not client.totp_secret: + return False return pyotp.TOTP(client.totp_secret).verify(code, valid_window=1) def list_clients(self) -> list[dict[str, Any]]: @@ -234,17 +275,72 @@ def list_clients(self) -> list[dict[str, Any]]: "client_id": c.client_id, "name": c.name, "created_at": c.created_at, + "owner_client_id": c.owner_client_id, + "registration_source": c.registration_source, } for c in self._clients.values() ] + def list_derived(self, owner_client_id: str) -> list[Client]: + """Return every dynamic client delegating TOTP to this owner.""" + return [ + c for c in self._clients.values() + if c.owner_client_id == owner_client_id + ] + def revoke(self, client_id: str) -> bool: - """Revoke a client. Returns True if found and removed.""" - if client_id in self._clients: - del self._clients[client_id] - self._save() - return True - return False + """Revoke a client. Returns True if found and removed. + + Revoking an owner cascades: every derived client is dropped with + it (a derived client can't authenticate without the owner's TOTP + seed anyway — leaving orphaned rows around is pure clutter). + """ + client = self._clients.get(client_id) + if client is None: + return False + del self._clients[client_id] + # Cascade to derived clients when the deleted row was an owner. + if client.owner_client_id is None: + derived = [ + c.client_id for c in self._clients.values() + if c.owner_client_id == client_id + ] + for cid in derived: + del self._clients[cid] + self._save() + return True + + def create_dynamic( + self, + *, + owner_client_id: str, + name: str, + registration_source: str, + ) -> tuple[str, str]: + """Provision a derived OAuth client for DCR. + + Returns ``(client_id, client_secret)``. The client has no TOTP + seed of its own; ``verify_totp`` for this client delegates to + ``owner_client_id``'s seed. The owner MUST already exist. + """ + if owner_client_id not in self._clients: + raise ValueError(f"unknown owner_client_id: {owner_client_id!r}") + client_id = "beaconmcp_" + secrets.token_hex(8) + client_secret = "sk_" + secrets.token_hex(32) + self._clients[client_id] = Client( + client_id=client_id, + client_secret_hash=_hash_secret(client_secret), + name=name, + created_at=time.time(), + totp_secret="", + owner_client_id=owner_client_id, + registration_source=registration_source, + ) + self._save() + return client_id, client_secret + + def get(self, client_id: str) -> Client | None: + return self._clients.get(client_id) class TokenCapExceeded(Exception): diff --git a/src/beaconmcp/config.py b/src/beaconmcp/config.py index 0665ed0..aa7da7c 100644 --- a/src/beaconmcp/config.py +++ b/src/beaconmcp/config.py @@ -63,6 +63,12 @@ class ServerConfig: allowed_origins: list[str] = field(default_factory=list) clients_file: Path = Path("/opt/beaconmcp/clients.json") session_key: str | None = None + # Enables the OAuth Dynamic Client Registration path used by clients + # that cannot accept a pre-provisioned client_id/client_secret pair + # (notably ChatGPT). Still gated by a single-use bootstrap slug minted + # from the dashboard — a human with a TOTP mints every slug, and each + # slug can only register one client. Off by default. + allow_dynamic_registration: bool = False @dataclass @@ -287,6 +293,9 @@ def _build(cls, raw: dict) -> Config: srv_raw.get("clients_file", "/opt/beaconmcp/clients.json") ), session_key=srv_raw.get("session_key"), + allow_dynamic_registration=_bool( + srv_raw.get("allow_dynamic_registration", False) + ), ) feat_raw = raw.get("features") or {} @@ -360,6 +369,7 @@ def mask(value: str) -> str: "allowed_origins": self.server.allowed_origins, "clients_file": str(self.server.clients_file), "session_key": mask(self.server.session_key or ""), + "allow_dynamic_registration": self.server.allow_dynamic_registration, }, "proxmox": { "verify_ssl": self.verify_ssl, diff --git a/src/beaconmcp/dashboard/app.py b/src/beaconmcp/dashboard/app.py index 7d41608..76c068e 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -47,6 +47,7 @@ ConversationStore, ) from .db import Database +from .dyn_reg import DynamicSlugStore, SLUG_TTL_SECONDS from .session import SESSION_TTL_SECONDS, Session, SessionStore from .usage import UsageMeter, UsageStore @@ -86,6 +87,10 @@ class DashboardDeps: # any API key). "remote": pass an McpServer to Gemini with a public # URL so Google's backend calls MCP directly (Gemini 3 native). mcp_mode: str = "local" + # Slug store for OAuth DCR bootstrap URLs (ChatGPT). When unset, the + # /app/connectors page is hidden and the slug-scoped OAuth endpoints + # are not mounted. + dyn_reg: DynamicSlugStore | None = None # --------------------------------------------------------------------------- @@ -498,6 +503,168 @@ async def tokens_create(request: Request) -> Response: just_created={"name": name, "token": token, "ttl": ttl}, ) + # --- ChatGPT connectors (OAuth DCR) ---------------------------------- + + def _connectors_enabled() -> bool: + return deps.dyn_reg is not None + + def _render_connectors_page( + request: Request, + session: Session, + *, + form_error: str | None = None, + form_label: str = "", + just_created: dict[str, Any] | None = None, + ) -> Response: + store = deps.dyn_reg + assert store is not None # checked by caller + now = time.time() + slugs = store.list_for_owner(session.client_id) + pending = [ + { + "slug": s.slug, + "label": s.label, + "expires_in_minutes": max(0, round((s.expires_at - now) / 60)), + } + for s in slugs + if s.used_at is None and s.expires_at > now + ] + derived = deps.client_store.list_derived(session.client_id) # type: ignore[attr-defined] + derived_rows = [ + { + "client_id": c.client_id, + "name": c.name, + "created_at_human": _human_time(c.created_at), + } + for c in derived + ] + return _render( + "connectors.html", + request, + client_id=session.client_id, + client_name=( + deps.client_store.get_name(session.client_id) # type: ignore[attr-defined] + or session.client_id + ), + pending_slugs=pending, + derived_clients=derived_rows, + slug_ttl_minutes=max(1, SLUG_TTL_SECONDS // 60), + form_error=form_error, + form_label=form_label, + just_created=just_created, + locked=deps.totp_locked(session.client_id), + ) + + async def connectors_get(request: Request) -> Response: + if not _connectors_enabled(): + return RedirectResponse("/app/tokens", status_code=302) + session = _load_session(request, deps) + if not session: + return RedirectResponse("/app/login", status_code=302) + if not _bearer_live(deps, session): + return RedirectResponse( + "/app/refresh?next=/app/connectors", status_code=302, + ) + return _render_connectors_page(request, session) + + async def connectors_mint(request: Request) -> Response: + if not _connectors_enabled(): + return RedirectResponse("/app/tokens", status_code=302) + session = _load_session(request, deps) + if not session: + return RedirectResponse("/app/login", status_code=302) + if not _bearer_live(deps, session): + return RedirectResponse( + "/app/refresh?next=/app/connectors", status_code=302, + ) + if not await csrf.verify(request): + return JSONResponse({"error": "csrf"}, status_code=403) + + form = await request.form() + label_raw = form.get("label", "") + label = (label_raw if isinstance(label_raw, str) else "").strip() + totp_raw = form.get("totp", "") + totp_code = (totp_raw if isinstance(totp_raw, str) else "").strip() + + if not label: + return _render_connectors_page( + request, session, form_error="Label is required.", form_label=label, + ) + if len(label) > 60: + return _render_connectors_page( + request, session, + form_error="Label cannot exceed 60 characters.", + form_label=label, + ) + if deps.totp_locked(session.client_id): + return _render_connectors_page( + request, session, + form_error="Too many 2FA attempts; try again in 5 minutes.", + form_label=label, + ) + if not deps.client_store.verify_totp(session.client_id, totp_code): # type: ignore[attr-defined] + deps.totp_record_failure(session.client_id) + return _render_connectors_page( + request, session, form_error="Invalid 2FA code.", form_label=label, + ) + deps.totp_record_success(session.client_id) + + store = deps.dyn_reg + assert store is not None + store.prune_expired() + row = store.mint(owner_client_id=session.client_id, label=label) + + scheme = request.headers.get("x-forwarded-proto", request.url.scheme) + host_hdr = request.headers.get( + "x-forwarded-host", request.headers.get("host", "localhost"), + ) + url = f"{scheme}://{host_hdr}/mcp/c/{row.slug}" + return _render_connectors_page( + request, session, + just_created={"url": url}, + ) + + async def connectors_slug_delete(request: Request) -> Response: + if not _connectors_enabled(): + return RedirectResponse("/app/tokens", status_code=302) + session = _load_session(request, deps) + if not session: + return RedirectResponse("/app/login", status_code=302) + if not _bearer_live(deps, session): + return RedirectResponse( + "/app/refresh?next=/app/connectors", status_code=302, + ) + if not await csrf.verify(request): + return JSONResponse({"error": "csrf"}, status_code=403) + form = await request.form() + slug_raw = form.get("slug", "") + slug = (slug_raw if isinstance(slug_raw, str) else "").strip() + if slug and deps.dyn_reg is not None: + deps.dyn_reg.delete_unused(slug, session.client_id) + return RedirectResponse("/app/connectors", status_code=303) + + async def connectors_revoke(request: Request) -> Response: + if not _connectors_enabled(): + return RedirectResponse("/app/tokens", status_code=302) + session = _load_session(request, deps) + if not session: + return RedirectResponse("/app/login", status_code=302) + if not _bearer_live(deps, session): + return RedirectResponse( + "/app/refresh?next=/app/connectors", status_code=302, + ) + if not await csrf.verify(request): + return JSONResponse({"error": "csrf"}, status_code=403) + form = await request.form() + client_id_raw = form.get("client_id", "") + client_id = (client_id_raw if isinstance(client_id_raw, str) else "").strip() + if client_id: + target = deps.client_store.get(client_id) # type: ignore[attr-defined] + # Only allow revoking clients WE own. + if target is not None and target.owner_client_id == session.client_id: + deps.client_store.revoke(client_id) # type: ignore[attr-defined] + return RedirectResponse("/app/connectors", status_code=303) + async def tokens_revoke(request: Request) -> Response: session = _load_session(request, deps) if not session: @@ -861,6 +1028,10 @@ async def _confirm(req: ToolConfirmRequired) -> bool: Route("/app/tokens", tokens_get, methods=["GET"]), Route("/app/tokens", tokens_create, methods=["POST"]), Route("/app/tokens/revoke", tokens_revoke, methods=["POST"]), + Route("/app/connectors", connectors_get, methods=["GET"]), + Route("/app/connectors/slug", connectors_mint, methods=["POST"]), + Route("/app/connectors/slug/delete", connectors_slug_delete, methods=["POST"]), + Route("/app/connectors/revoke", connectors_revoke, methods=["POST"]), Route("/app/api/conversations", api_conv_list, methods=["GET"]), Route("/app/api/conversations", api_conv_create, methods=["POST"]), Route("/app/api/conversations/{conv_id}", api_conv_detail, methods=["GET"]), @@ -999,6 +1170,12 @@ def _sse(event: str, data: Any) -> bytes: return f"event: {event}\ndata: {payload}\n\n".encode("utf-8") +def _human_time(ts: float) -> str: + """Render a Unix timestamp as a short human-readable date.""" + import datetime as _dt + return _dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") + + def _format_quota_message(block: Any) -> str: """Build a French user-facing sentence for a :class:`BudgetBlock`. diff --git a/src/beaconmcp/dashboard/db.py b/src/beaconmcp/dashboard/db.py index f01d4aa..2278778 100644 --- a/src/beaconmcp/dashboard/db.py +++ b/src/beaconmcp/dashboard/db.py @@ -19,7 +19,7 @@ def db_path() -> Path: return Path(override) if override else DEFAULT_DB_PATH -_LATEST_VERSION = 3 +_LATEST_VERSION = 4 def _migrate(conn: sqlite3.Connection) -> None: @@ -127,6 +127,26 @@ def _migrate(conn: sqlite3.Connection) -> None: """ ) + if version < 4: + # OAuth Dynamic Client Registration bootstrap slugs: one row per + # single-use URL minted from the dashboard for a client that needs + # DCR (e.g. ChatGPT). See ``dashboard.dyn_reg`` for the lifecycle. + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS oauth_dynamic_slugs ( + slug TEXT PRIMARY KEY, + label TEXT NOT NULL, + owner_client_id TEXT NOT NULL, + created_at REAL NOT NULL, + expires_at REAL NOT NULL, + used_at REAL, + resulting_client_id TEXT + ); + CREATE INDEX IF NOT EXISTS idx_oauth_slugs_owner + ON oauth_dynamic_slugs(owner_client_id, created_at DESC); + """ + ) + conn.execute(f"PRAGMA user_version = {_LATEST_VERSION}") conn.commit() diff --git a/src/beaconmcp/dashboard/dyn_reg.py b/src/beaconmcp/dashboard/dyn_reg.py new file mode 100644 index 0000000..9478787 --- /dev/null +++ b/src/beaconmcp/dashboard/dyn_reg.py @@ -0,0 +1,173 @@ +"""Bootstrap slugs for OAuth Dynamic Client Registration. + +ChatGPT (and other MCP clients that hard-wire RFC 7591 DCR) cannot accept +a pre-shared bearer or client_id. This module mints short-lived, single- +use "connector slugs": the dashboard hands the human a one-off URL of the +shape ``https:///mcp/c/``; when the MCP client discovers the +OAuth metadata under that path, it is pointed at a slug-scoped register +endpoint that consumes the slug atomically and provisions a dynamic +client bound to the human who minted the slug. + +The slug itself carries no authority — stealing one only lets an attacker +register a dynamic client, which they still cannot authorize without the +owner's TOTP. The slug's single-use + short-TTL contract is enforced by a +single ``UPDATE ... WHERE used_at IS NULL`` in SQLite so concurrent claims +deterministically have one winner. +""" + +from __future__ import annotations + +import secrets +import sqlite3 +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .db import Database + + +SLUG_TTL_SECONDS = 15 * 60 # 15 min - long enough to copy-paste + DCR, short enough to rot fast. + + +@dataclass +class DynamicSlug: + slug: str + label: str + owner_client_id: str + created_at: float + expires_at: float + used_at: float | None + resulting_client_id: str | None + + +class SlugAlreadyConsumed(Exception): + """Raised when a slug is re-used or expired at claim time.""" + + +class DynamicSlugStore: + """Thin wrapper around the ``oauth_dynamic_slugs`` table.""" + + TTL_SECONDS = SLUG_TTL_SECONDS + + def __init__(self, database: "Database") -> None: + self._db = database + + # --- mint / list / revoke (dashboard side) --------------------------- + + def mint(self, *, owner_client_id: str, label: str) -> DynamicSlug: + """Create and persist a fresh slug for ``owner_client_id``. + + Returns the new row. The slug is a URL-safe token with 32 bytes + of entropy — enough that enumeration is hopeless even if an + attacker can probe the server. + """ + slug = secrets.token_urlsafe(24) + now = time.time() + row = DynamicSlug( + slug=slug, + label=label, + owner_client_id=owner_client_id, + created_at=now, + expires_at=now + self.TTL_SECONDS, + used_at=None, + resulting_client_id=None, + ) + self._db.conn().execute( + "INSERT INTO oauth_dynamic_slugs " + "(slug, label, owner_client_id, created_at, expires_at, " + " used_at, resulting_client_id) " + "VALUES (?, ?, ?, ?, ?, NULL, NULL)", + (row.slug, row.label, row.owner_client_id, row.created_at, row.expires_at), + ) + return row + + def list_for_owner(self, owner_client_id: str) -> list[DynamicSlug]: + cur = self._db.conn().execute( + "SELECT slug, label, owner_client_id, created_at, expires_at, " + " used_at, resulting_client_id " + "FROM oauth_dynamic_slugs " + "WHERE owner_client_id = ? " + "ORDER BY created_at DESC " + "LIMIT 50", + (owner_client_id,), + ) + return [DynamicSlug(**dict(r)) for r in cur.fetchall()] + + def delete_unused(self, slug: str, owner_client_id: str) -> bool: + """Remove a slug that was never consumed (user gave up). Returns True + on success. Consumed slugs survive as an audit trail; revoke the + derived client instead if you want to break access.""" + cur = self._db.conn().execute( + "DELETE FROM oauth_dynamic_slugs " + "WHERE slug = ? AND owner_client_id = ? AND used_at IS NULL", + (slug, owner_client_id), + ) + return cur.rowcount > 0 + + def prune_expired(self) -> int: + """Drop unused, expired rows. Returns the number removed.""" + cur = self._db.conn().execute( + "DELETE FROM oauth_dynamic_slugs " + "WHERE used_at IS NULL AND expires_at < ?", + (time.time(),), + ) + return cur.rowcount + + # --- DCR path (OAuth side) ------------------------------------------- + + def load(self, slug: str) -> DynamicSlug | None: + cur = self._db.conn().execute( + "SELECT slug, label, owner_client_id, created_at, expires_at, " + " used_at, resulting_client_id " + "FROM oauth_dynamic_slugs WHERE slug = ?", + (slug,), + ) + row = cur.fetchone() + return DynamicSlug(**dict(row)) if row else None + + def consume(self, slug: str, resulting_client_id: str) -> DynamicSlug: + """Atomically claim a slug and bind it to a freshly-registered client. + + Uses ``UPDATE ... WHERE used_at IS NULL AND expires_at > now`` + so exactly one concurrent caller wins. Raises + :class:`SlugAlreadyConsumed` if the row is missing, expired, or + already claimed. + """ + now = time.time() + conn = self._db.conn() + conn.execute("BEGIN IMMEDIATE") + try: + cur = conn.execute( + "UPDATE oauth_dynamic_slugs " + "SET used_at = ?, resulting_client_id = ? " + "WHERE slug = ? AND used_at IS NULL AND expires_at > ?", + (now, resulting_client_id, slug, now), + ) + if cur.rowcount != 1: + conn.execute("ROLLBACK") + raise SlugAlreadyConsumed(slug) + conn.execute("COMMIT") + except Exception: + # BEGIN IMMEDIATE may have left us in a transaction on error + # paths other than the ROLLBACK above (e.g. a SQL error). + try: + conn.execute("ROLLBACK") + except sqlite3.OperationalError: + pass + raise + loaded = self.load(slug) + assert loaded is not None # we just wrote it + return loaded + + # --- lookup by resulting client (for /mcp/c/ path rewrite) ------ + + def find_by_client(self, client_id: str) -> DynamicSlug | None: + cur = self._db.conn().execute( + "SELECT slug, label, owner_client_id, created_at, expires_at, " + " used_at, resulting_client_id " + "FROM oauth_dynamic_slugs WHERE resulting_client_id = ?", + (client_id,), + ) + row = cur.fetchone() + return DynamicSlug(**dict(row)) if row else None diff --git a/src/beaconmcp/dashboard/templates/connectors.html b/src/beaconmcp/dashboard/templates/connectors.html new file mode 100644 index 0000000..a669373 --- /dev/null +++ b/src/beaconmcp/dashboard/templates/connectors.html @@ -0,0 +1,146 @@ +{% extends "base.html" %} +{% block title %}ChatGPT connector · BeaconMCP{% endblock %} +{% block body_class %}tokens-page{% endblock %} +{% block body %} +
+
+ + + Back to tokens + +
+

ChatGPT connector

+

+ Signed in as {{ client_name }}. ChatGPT needs OAuth + Dynamic Client Registration; mint a one-off URL below, paste it into + ChatGPT's Add connector dialog, and complete the 2FA prompt + in-app. Each URL is single-use and expires after {{ slug_ttl_minutes }} min. +

+
+
+ + {% if just_created %} +
+

Connector URL ready

+

+ Paste this URL in ChatGPT (Settings → Connectors → Add custom). It can + be used once and dies in {{ slug_ttl_minutes }} min. +

+
+ {{ just_created.url }} + +
+
+ {% endif %} + +
+

New connector

+ {% if form_error %} + + {% endif %} +
+ + + + +
+
+ +
+

Pending URLs

+ {% if pending_slugs %} +
    + {% for s in pending_slugs %} +
  • +
    + {{ s.label }} + {{ s.slug[:10] }}… +
    +
    + expires in {{ s.expires_in_minutes }} min +
    +
    + + + +
    +
  • + {% endfor %} +
+ {% else %} +

No pending URLs.

+ {% endif %} +
+ +
+

Active connectors

+ {% if derived_clients %} +
    + {% for c in derived_clients %} +
  • +
    + {{ c.name }} + {{ c.client_id }} +
    +
    + registered {{ c.created_at_human }} +
    +
    + + + +
    +
  • + {% endfor %} +
+ {% else %} +

No active ChatGPT connectors.

+ {% endif %} +
+
+ + + + +{% endblock %} diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index 080db8f..aaac8e6 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -631,7 +631,7 @@ def test_migration_v1_to_v2_renames_gemini_models(tmp_path): # user_version reflects the migration (latest schema version). ver = db.conn().execute("PRAGMA user_version").fetchone()[0] - assert ver == 3 + assert ver == 4 def test_short_ciphertext_decryption_returns_none(store): diff --git a/tests/test_dynamic_registration.py b/tests/test_dynamic_registration.py new file mode 100644 index 0000000..1eb5571 --- /dev/null +++ b/tests/test_dynamic_registration.py @@ -0,0 +1,288 @@ +"""Tests for OAuth Dynamic Client Registration (ChatGPT connector flow). + +Covers the two correctness-critical paths: + +1. :class:`DynamicSlugStore.consume` is atomic & single-use — concurrent + claims deterministically produce exactly one winner. +2. Derived :class:`Client` rows delegate TOTP verification to their + owner, and owner revocation cascades to all derived rows. +""" + +from __future__ import annotations + +import sys +import threading +import time +from pathlib import Path + +import pyotp +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from beaconmcp.auth import ClientStore +from beaconmcp.dashboard.db import Database +from beaconmcp.dashboard.dyn_reg import ( + SLUG_TTL_SECONDS, + DynamicSlugStore, + SlugAlreadyConsumed, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def db(tmp_path: Path) -> Database: + return Database(tmp_path / "dash.db") + + +@pytest.fixture() +def slug_store(db: Database) -> DynamicSlugStore: + return DynamicSlugStore(db) + + +@pytest.fixture() +def clients(tmp_path: Path) -> ClientStore: + return ClientStore(tmp_path / "clients.json") + + +# --------------------------------------------------------------------------- +# Slug lifecycle +# --------------------------------------------------------------------------- + + +def test_mint_then_consume_records_resulting_client(slug_store: DynamicSlugStore) -> None: + row = slug_store.mint(owner_client_id="owner_1", label="ChatGPT iPhone") + assert row.used_at is None + + claimed = slug_store.consume(row.slug, resulting_client_id="new_client_1") + assert claimed.used_at is not None + assert claimed.resulting_client_id == "new_client_1" + + reloaded = slug_store.load(row.slug) + assert reloaded is not None and reloaded.resulting_client_id == "new_client_1" + + +def test_consume_twice_raises(slug_store: DynamicSlugStore) -> None: + row = slug_store.mint(owner_client_id="owner_1", label="x") + slug_store.consume(row.slug, resulting_client_id="c1") + with pytest.raises(SlugAlreadyConsumed): + slug_store.consume(row.slug, resulting_client_id="c2") + + +def test_consume_expired_slug_rejected( + slug_store: DynamicSlugStore, db: Database, +) -> None: + row = slug_store.mint(owner_client_id="owner_1", label="x") + # Fast-forward expiry by rewriting the row directly. + db.conn().execute( + "UPDATE oauth_dynamic_slugs SET expires_at = ? WHERE slug = ?", + (time.time() - 1, row.slug), + ) + with pytest.raises(SlugAlreadyConsumed): + slug_store.consume(row.slug, resulting_client_id="c1") + + +def test_consume_unknown_slug_rejected(slug_store: DynamicSlugStore) -> None: + with pytest.raises(SlugAlreadyConsumed): + slug_store.consume("not-a-real-slug", resulting_client_id="c1") + + +def test_concurrent_consume_has_exactly_one_winner( + tmp_path: Path, +) -> None: + """Two threads race to claim the same slug; exactly one wins.""" + # Each thread uses its own Database instance (fresh connection) to + # mimic the real HTTP server where handlers run on a thread pool. + db_path = tmp_path / "dash.db" + bootstrap = Database(db_path) + store = DynamicSlugStore(bootstrap) + row = store.mint(owner_client_id="owner_1", label="race") + + barrier = threading.Barrier(10) + wins: list[str] = [] + losses: list[str] = [] + lock = threading.Lock() + + def worker(i: int) -> None: + local_store = DynamicSlugStore(Database(db_path)) + barrier.wait() + try: + local_store.consume(row.slug, resulting_client_id=f"c{i}") + with lock: + wins.append(f"c{i}") + except SlugAlreadyConsumed: + with lock: + losses.append(f"c{i}") + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(wins) == 1, f"expected exactly one winner, got {wins}" + assert len(losses) == 9 + + +def test_delete_unused_only_removes_unconsumed( + slug_store: DynamicSlugStore, +) -> None: + row = slug_store.mint(owner_client_id="owner_1", label="pending") + assert slug_store.delete_unused(row.slug, "owner_1") is True + # Idempotent: second call is a no-op, returns False. + assert slug_store.delete_unused(row.slug, "owner_1") is False + + # Consumed slugs survive as audit trail. + row2 = slug_store.mint(owner_client_id="owner_1", label="used") + slug_store.consume(row2.slug, resulting_client_id="c1") + assert slug_store.delete_unused(row2.slug, "owner_1") is False + + +def test_delete_unused_scoped_to_owner(slug_store: DynamicSlugStore) -> None: + row = slug_store.mint(owner_client_id="owner_A", label="x") + assert slug_store.delete_unused(row.slug, "owner_B") is False + assert slug_store.load(row.slug) is not None + + +def test_prune_expired_drops_only_unused_expired_rows( + slug_store: DynamicSlugStore, db: Database, +) -> None: + # 1 fresh, 1 expired-unused, 1 expired-used. + fresh = slug_store.mint(owner_client_id="o", label="fresh") + expired_unused = slug_store.mint(owner_client_id="o", label="exp-unused") + expired_used = slug_store.mint(owner_client_id="o", label="exp-used") + + slug_store.consume(expired_used.slug, resulting_client_id="c1") + + now = time.time() + db.conn().execute( + "UPDATE oauth_dynamic_slugs SET expires_at = ? WHERE slug IN (?, ?)", + (now - 1, expired_unused.slug, expired_used.slug), + ) + + removed = slug_store.prune_expired() + assert removed == 1 + assert slug_store.load(fresh.slug) is not None + assert slug_store.load(expired_unused.slug) is None + assert slug_store.load(expired_used.slug) is not None + + +def test_ttl_matches_design(slug_store: DynamicSlugStore) -> None: + """Guard against accidental TTL changes — the dashboard UI copy depends + on this value, and a sudden bump would surprise users.""" + row = slug_store.mint(owner_client_id="o", label="x") + delta = row.expires_at - row.created_at + assert abs(delta - SLUG_TTL_SECONDS) < 1.0 + + +# --------------------------------------------------------------------------- +# Dynamic-client TOTP delegation +# --------------------------------------------------------------------------- + + +def test_dynamic_client_totp_delegates_to_owner(clients: ClientStore) -> None: + owner_id, _, owner_seed = clients.create("human") + derived_id, derived_secret = clients.create_dynamic( + owner_client_id=owner_id, + name="ChatGPT (derived)", + registration_source="chatgpt:slug1", + ) + + # Derived client authenticates with its own secret but NOT its own TOTP. + assert clients.verify(derived_id, derived_secret) is True + now_code = pyotp.TOTP(owner_seed).now() + assert clients.verify_totp(derived_id, now_code) is True + # Wrong codes still fail. + assert clients.verify_totp(derived_id, "000000") is False + + +def test_dynamic_client_without_owner_secret_cannot_verify_totp( + clients: ClientStore, +) -> None: + """If the owner is revoked after the derived client is created, TOTP + verification fails closed — a derived client can't outlive its owner.""" + owner_id, _, owner_seed = clients.create("human") + derived_id, _ = clients.create_dynamic( + owner_client_id=owner_id, + name="derived", + registration_source="chatgpt:slug1", + ) + + # Revoke owner; derived row cascades away (verify_totp returns False + # because the client is gone). + clients.revoke(owner_id) + assert clients.verify_totp(derived_id, pyotp.TOTP(owner_seed).now()) is False + + +def test_revoke_owner_cascades_to_derived(clients: ClientStore) -> None: + owner_id, _, _ = clients.create("human") + d1, _ = clients.create_dynamic( + owner_client_id=owner_id, name="chatgpt-1", registration_source="chatgpt:s1", + ) + d2, _ = clients.create_dynamic( + owner_client_id=owner_id, name="chatgpt-2", registration_source="chatgpt:s2", + ) + + assert clients.exists(d1) and clients.exists(d2) + clients.revoke(owner_id) + assert not clients.exists(d1) + assert not clients.exists(d2) + + +def test_revoke_derived_leaves_owner_intact(clients: ClientStore) -> None: + owner_id, _, _ = clients.create("human") + d1, _ = clients.create_dynamic( + owner_client_id=owner_id, name="chatgpt", registration_source="chatgpt:s1", + ) + clients.revoke(d1) + assert clients.exists(owner_id) + assert not clients.exists(d1) + + +def test_list_derived_scoped_to_owner(clients: ClientStore) -> None: + a_id, _, _ = clients.create("owner_A") + b_id, _, _ = clients.create("owner_B") + clients.create_dynamic( + owner_client_id=a_id, name="A-chatgpt", registration_source="chatgpt:s1", + ) + clients.create_dynamic( + owner_client_id=b_id, name="B-chatgpt", registration_source="chatgpt:s2", + ) + a_derived = clients.list_derived(a_id) + assert len(a_derived) == 1 and a_derived[0].name == "A-chatgpt" + + +def test_create_dynamic_requires_existing_owner(clients: ClientStore) -> None: + with pytest.raises(ValueError): + clients.create_dynamic( + owner_client_id="does-not-exist", + name="x", + registration_source="chatgpt:s1", + ) + + +def test_derived_client_round_trips_through_disk( + tmp_path: Path, +) -> None: + """Owner + derived survive a ClientStore reopen (JSON serialization).""" + path = tmp_path / "clients.json" + first = ClientStore(path) + owner_id, _, owner_seed = first.create("human") + derived_id, derived_secret = first.create_dynamic( + owner_client_id=owner_id, + name="chatgpt", + registration_source="chatgpt:s1", + ) + + second = ClientStore(path) + derived = second.get(derived_id) + assert derived is not None + assert derived.owner_client_id == owner_id + assert derived.registration_source == "chatgpt:s1" + # TOTP delegation still works after reload. + assert second.verify_totp(derived_id, pyotp.TOTP(owner_seed).now()) is True + assert second.verify(derived_id, derived_secret) is True From 6b08895cc1479b09bf1e9795222d917255f3479d Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 12:01:38 +0200 Subject: [PATCH 058/155] Link to ChatGPT connectors from the tokens page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discoverability fix — without a link users had to know the /app/connectors URL by heart. Show it as a small note under the MCP URL block, visible only when allow_dynamic_registration is on. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/beaconmcp/dashboard/app.py | 1 + src/beaconmcp/dashboard/templates/tokens.html | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/beaconmcp/dashboard/app.py b/src/beaconmcp/dashboard/app.py index 76c068e..cfd8752 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -1135,6 +1135,7 @@ def _render_tokens_page( mcp_url=mcp_url, locked=deps.totp_locked(session.client_id), chat_enabled=deps.engine is not None, + dcr_enabled=deps.dyn_reg is not None, ) diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index 6b06e29..5f52d84 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -28,6 +28,13 @@

MCP server URL

+ {% if dcr_enabled %} +

+ Using ChatGPT? ChatGPT cannot accept a bearer token — it requires + OAuth Dynamic Client Registration. Mint a one-off connector URL on + the ChatGPT connectors page instead. +

+ {% endif %} {% if just_created %} From 8a44ef935da809049baf245178a03a0f4e7502da Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 12:06:38 +0200 Subject: [PATCH 059/155] Promote the ChatGPT connectors link to a proper CTA section The muted-small note was too discreet to notice. Turn it into a distinct card with an accent border and a primary-button link. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/beaconmcp/dashboard/static/app.css | 24 +++++++++++++++++++ src/beaconmcp/dashboard/templates/tokens.html | 20 +++++++++++----- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index 115f40d..5f6925c 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -1204,6 +1204,30 @@ a.sidebar-link { cursor: pointer; } border-radius: var(--radius-sm); padding: 0.35rem 0.55rem; } + +.chatgpt-cta { + border-left: 3px solid var(--accent, #e57000); +} +.chatgpt-cta p { + margin: 0.25rem 0 1rem; + font-size: 0.95rem; + line-height: 1.5; +} + +.btn-primary-link { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.6rem 1rem; + background: var(--accent, #e57000); + color: #fff; + border-radius: var(--radius-sm); + font-weight: 500; + text-decoration: none; + transition: filter 120ms ease; +} +.btn-primary-link:hover { filter: brightness(1.08); } +.btn-primary-link:active { filter: brightness(0.95); } .token-endpoint code { flex: 1; font-family: var(--font-mono); diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index 5f52d84..ef30583 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -28,14 +28,22 @@

MCP server URL

- {% if dcr_enabled %} -

- Using ChatGPT? ChatGPT cannot accept a bearer token — it requires - OAuth Dynamic Client Registration. Mint a one-off connector URL on - the ChatGPT connectors page instead. + + + {% if dcr_enabled %} +

+

Using ChatGPT?

+

+ ChatGPT's connector UI cannot accept a bearer token — it requires + OAuth Dynamic Client Registration. Mint a one-off connector URL + instead; it's single-use, expires in 15 min, and stays 2FA-gated.

- {% endif %} + + Open ChatGPT connectors + +
+ {% endif %} {% if just_created %}
From a190b515579e814b2fa756ec6cc510200c489324 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 12:14:20 +0200 Subject: [PATCH 060/155] Reorganise /app/tokens around auth methods and platforms Single flat form made it hard to know which flow applies to which client. Split the page into three method cards (OAuth pre-registered, OAuth DCR, Bearer) with per-platform snippets, and add a CSS-only tab toggle for an alternate by-platform view that points back at the relevant method section. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/beaconmcp/dashboard/static/app.css | 132 ++++++ src/beaconmcp/dashboard/templates/tokens.html | 376 +++++++++++++----- 2 files changed, 410 insertions(+), 98 deletions(-) diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index 5f6925c..7920122 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -1228,6 +1228,138 @@ a.sidebar-link { cursor: pointer; } } .btn-primary-link:hover { filter: brightness(1.08); } .btn-primary-link:active { filter: brightness(0.95); } + +/* Tabbed views on /app/tokens (CSS-only toggle via sibling radios). */ +.views { margin-top: 1.5rem; } +.views > input[type=radio] { + position: absolute; + opacity: 0; + pointer-events: none; +} +.view-tabs { + display: inline-flex; + gap: 0.25rem; + padding: 0.25rem; + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + margin-bottom: 1rem; +} +.view-tab { + padding: 0.4rem 0.9rem; + font-size: 0.9rem; + border-radius: calc(var(--radius-sm) - 2px); + cursor: pointer; + color: var(--fg-muted); + user-select: none; + transition: color 120ms ease, background 120ms ease; +} +.view-tab:hover { color: var(--fg); } +.views .view { display: none; } +#view-method:checked ~ .view-by-method { display: block; } +#view-platform:checked ~ .view-by-platform { display: block; } +#view-method:checked ~ .view-tabs [for=view-method], +#view-platform:checked ~ .view-tabs [for=view-platform] { + background: var(--bg); + color: var(--fg); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); +} + +/* Auth-method cards */ +.method-card { } +.method-head { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.5rem; +} +.method-head h2 { margin: 0; } +.method-badge { + display: inline-block; + padding: 0.15rem 0.55rem; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--fg-muted); +} +.method-badge-oauth { background: rgba(92, 147, 219, 0.14); border-color: rgba(92, 147, 219, 0.45); color: #5c93db; } +.method-badge-dcr { background: rgba(229, 112, 0, 0.14); border-color: rgba(229, 112, 0, 0.45); color: #e57000; } +.method-badge-bearer { background: rgba(140, 140, 140, 0.14); color: var(--fg-muted); } +.method-subhead { + margin: 1.25rem 0 0.5rem; + font-size: 0.95rem; + color: var(--fg-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.method-howto { + margin-top: 1rem; + border-top: 1px solid var(--border); + padding-top: 0.75rem; +} +.method-howto > summary { + cursor: pointer; + color: var(--fg-muted); + font-size: 0.9rem; + list-style: none; +} +.method-howto > summary::-webkit-details-marker { display: none; } +.method-howto > summary::before { + content: "▸ "; + display: inline-block; + transition: transform 120ms ease; +} +.method-howto[open] > summary::before { transform: rotate(90deg); } +.method-howto ol { padding-left: 1.2rem; margin: 0.75rem 0; } +.method-howto li { margin: 0.5rem 0; } +.method-howto h4 { + margin: 1rem 0 0.35rem; + font-size: 0.9rem; + color: var(--fg-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.method-howto pre { + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.55rem 0.75rem; + overflow-x: auto; + font-size: 0.85rem; + margin: 0.3rem 0; +} +.method-howto pre code { background: none; padding: 0; } + +/* Platform view */ +.platform-card h2 { + display: flex; + align-items: baseline; + gap: 0.6rem; + margin-bottom: 0.5rem; +} +.platform-method { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 0.15rem 0.55rem; + border-radius: 999px; + background: var(--bg-soft); + color: var(--fg-muted); + border: 1px solid var(--border); +} +.platform-card pre { + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.55rem 0.75rem; + overflow-x: auto; + font-size: 0.85rem; +} +.platform-card pre code { background: none; padding: 0; } .token-endpoint code { flex: 1; font-family: var(--font-mono); diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index ef30583..75c40c8 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}API tokens · BeaconMCP{% endblock %} +{% block title %}API access · BeaconMCP{% endblock %} {% block body_class %}tokens-page{% endblock %} {% block body %}
@@ -11,11 +11,11 @@ {% endif %}
-

API tokens

+

API access

- Signed in as {{ client_name }}. Use these tokens to - connect BeaconMCP from an external MCP client (Gemini web, ChatGPT, - Claude Desktop, etc.). Each token expires after 24 h. + Signed in as {{ client_name }}. Pick the client you + want to connect and follow the matching flow. Every flow keeps 2FA + on your phone — BeaconMCP never stores a TOTP seed on a machine.

@@ -28,107 +28,278 @@

MCP server URL

-
- - {% if dcr_enabled %} -
-

Using ChatGPT?

-

- ChatGPT's connector UI cannot accept a bearer token — it requires - OAuth Dynamic Client Registration. Mint a one-off connector URL - instead; it's single-use, expires in 15 min, and stays 2FA-gated. -

- - Open ChatGPT connectors - - -
- {% endif %} - - {% if just_created %} -
-

New token created

-

- Token for {{ just_created.name }}. Copy it now — it - will not be shown again. -

-
- {{ just_created.token }} - -
-

- In your MCP client's HTTP header: Authorization: Bearer {{ just_created.token[:8] }}… +

+ Bearer-based and pre-registered-OAuth clients both point here. ChatGPT + connectors get a slug-scoped alias of this URL when minted below.

- {% endif %} -
-
-

Create a token ({{ count }}/{{ cap }})

-
+
+ + + - {% if form_error %} - - {% endif %} + {# =========================================================== #} + {# VIEW: BY METHOD #} + {# =========================================================== #} +
- {% if can_create %} -
- - - - -
- {% else %} -

- You reached the limit of {{ cap }} active tokens. Revoke an existing - token before creating a new one. -

- {% endif %} -
+ {# ---------- OAuth (pre-registered) ---------- #} +
+
+ OAuth 2.1 +

Pre-registered client

+
+

+ For clients that support full OAuth 2.1 with a user-provided + client_id and client_secret. BeaconMCP + handles the authorization code + PKCE flow and prompts for your + TOTP at every 24 h refresh. +

+

Supported: Claude (web, iOS, Android, desktop).

+
+ How to set one up +
    +
  1. On the BeaconMCP server, run: +
    beaconmcp auth create --name "Claude iPhone"
    + The CLI prints the client_id, client_secret, and a TOTP QR code. + Scan the QR into your authenticator app immediately. +
  2. +
  3. In Claude: Settings → Integrations → Add custom connector.
  4. +
  5. URL: {{ mcp_url }}. Paste client_id and client_secret.
  6. +
  7. Claude redirects to this server's authorization page — type your TOTP code there, not in the CLI.
  8. +
+
+
-
-

Active tokens

- {% if tokens %} -
    - {% for t in tokens %} -
  • -
    - {{ t.name }} - {{ t.prefix }}… + {# ---------- OAuth DCR ---------- #} + {% if dcr_enabled %} +
    +
    + OAuth + DCR +

    Dynamic client registration

    -
    - expires in {{ t.expires_in_hours }} h +

    + For clients that only accept OAuth with a dynamically-registered + client (RFC 7591) — they discover everything from a URL and don't + let you paste credentials. BeaconMCP gates DCR behind a single-use + bootstrap URL you mint here; the resulting client is bound to your + account and has no TOTP seed of its own (2FA delegates to yours). +

    +

    Supported: ChatGPT (Developer Mode).

    + + Open ChatGPT connectors + + +
    + {% endif %} + + {# ---------- Bearer tokens ---------- #} +
    +
    + Bearer +

    Static bearer tokens

    +
    +

    + For clients that attach a fixed Authorization: Bearer … + header on every call. Each token lasts 24 h and is scoped to your + account. TOTP is verified once, at creation time. +

    +

    + Supported: Gemini Web, Gemini CLI, + Google Antigravity, any MCP-over-HTTP client + without an OAuth client of its own. +

    + + {% if just_created %} +
    +

    New token: {{ just_created.name }}

    +

    Copy it now — it will not be shown again.

    +
    + {{ just_created.token }} + +
    -
    + {% endif %} + + {% if form_error %} + + {% endif %} + +

    Create a token ({{ count }}/{{ cap }})

    + {% if can_create %} + - - + + +
    -
  • - {% endfor %} -
- {% else %} -

No active tokens yet.

- {% endif %} -
+ {% else %} +

+ You reached the limit of {{ cap }} active tokens. Revoke one before creating another. +

+ {% endif %} + +

Active tokens

+ {% if tokens %} +
    + {% for t in tokens %} +
  • +
    + {{ t.name }} + {{ t.prefix }}… +
    +
    + expires in {{ t.expires_in_hours }} h +
    +
    + + + +
    +
  • + {% endfor %} +
+ {% else %} +

No active tokens yet.

+ {% endif %} + +
+ Usage snippets +

Gemini CLI

+
gemini mcp add beaconmcp \
+  --url {{ mcp_url }} \
+  --header "Authorization: Bearer <token>"
+

Gemini Web

+

+ Tools → Extensions → Custom MCP → paste + {{ mcp_url }} and Bearer <token> + in the Authorization field. +

+

Google Antigravity

+

+ In your project's .antigravity/mcp.json, add the + server with the authorization field set to + Bearer <token> and url set to + {{ mcp_url }}. +

+

Generic (any MCP-over-HTTP client)

+

+ Send every request to {{ mcp_url }} with + Authorization: Bearer <token>. The token + dies after 24 h — revoke & re-issue from here. +

+
+ + + + + {# =========================================================== #} + {# VIEW: BY PLATFORM #} + {# =========================================================== #} +
+ +
+

Claude OAuth pre-registered

+

+ Claude's custom connector accepts a user-provided + client_id and client_secret and runs a + full OAuth authorization code + PKCE flow against BeaconMCP. +

+

+ Head to the pre-registered OAuth section for the CLI command. +

+
+ + {% if dcr_enabled %} +
+

ChatGPT OAuth + DCR

+

+ ChatGPT's connector UI only exposes "No auth" and "OAuth" — no + bearer field — and its OAuth path requires Dynamic Client + Registration. Mint a single-use bootstrap URL that pins DCR to + your account. +

+ + Mint a connector URL + + +
+ {% endif %} + +
+

Gemini Web Bearer

+

+ Gemini's custom-MCP panel takes a URL and an Authorization header. + Create a bearer token, paste + Bearer <token> in the header field. +

+

+ Create a bearer token → +

+
+ +
+

Gemini CLI Bearer

+

+ Register the server with a bearer header. +

+
gemini mcp add beaconmcp \
+  --url {{ mcp_url }} \
+  --header "Authorization: Bearer <token>"
+

+ Create a bearer token → +

+
+ +
+

Google Antigravity Bearer

+

+ Add BeaconMCP to Antigravity's MCP config file with + Authorization: Bearer <token>. The bearer + lives in your workspace-level MCP config — treat it like any + secret. +

+

+ Create a bearer token → +

+
+ +
+

Other MCP-HTTP clients Bearer

+

+ Any client that can send Authorization: Bearer … on + an HTTP POST works. Mint a token and point it at + {{ mcp_url }}. +

+

+ Create a bearer token → +

+
+ +
+

Active tokens

const value = btn.getAttribute("data-copy") || ""; try { await navigator.clipboard.writeText(value); - const original = btn.textContent; + const html = btn.innerHTML; btn.textContent = "Copied ✓"; - setTimeout(() => { btn.innerHTML = original; }, 1500); + setTimeout(() => { btn.innerHTML = html; }, 1500); } catch (err) { console.error(err); } }); } + + // Clicking an in-page anchor (#method-bearer) from the By-platform + // view should also flip the tab back to By-method so the target is + // visible. Without this the click scrolls to a hidden element. + for (const a of document.querySelectorAll('a[href^="#method-"]')) { + a.addEventListener("click", () => { + document.getElementById("view-method").checked = true; + }); + } {% endblock %} From 72a390d781245521240925261e4c5d1bb7678a5d Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 12:18:10 +0200 Subject: [PATCH 061/155] Polish /app/tokens with nested tabs, hero endpoint, and logo pills Replace the stacked cards with a proper two-level tabbed interface: outer switch for 'by method' vs 'by platform', inner tabs for each option within. Each card shows a colored badge for the auth method, a logo pill row for supported clients, and a split layout for the bearer create-form + active-tokens list. Snippets for Gemini CLI / Web / Antigravity / generic live in a single Usage-snippets drawer. All tab switching is CSS-only (sibling-radio pattern) to keep the page interactive without JS. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/beaconmcp/dashboard/static/app.css | 347 ++++++++--- src/beaconmcp/dashboard/templates/tokens.html | 562 ++++++++++-------- 2 files changed, 598 insertions(+), 311 deletions(-) diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index 7920122..77deddb 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -1229,137 +1229,348 @@ a.sidebar-link { cursor: pointer; } .btn-primary-link:hover { filter: brightness(1.08); } .btn-primary-link:active { filter: brightness(0.95); } -/* Tabbed views on /app/tokens (CSS-only toggle via sibling radios). */ -.views { margin-top: 1.5rem; } +/* ---------------------------------------------------------------- + * /app/tokens redesign — nested CSS-only tabs, hero endpoint, + * platform/method cards with logo pills and snippet gallery. + * ---------------------------------------------------------------- */ + +.mcp-endpoint-hero { + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--accent, #e57000) 8%, var(--bg)) 0%, + var(--bg-soft) 100% + ); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.25rem; + margin: 1.5rem 0; +} +.mcp-endpoint-hero-label { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-muted); + margin-bottom: 0.4rem; +} +.mcp-endpoint-hero-row { + display: flex; + gap: 0.5rem; + align-items: stretch; +} +.mcp-endpoint-hero-row code { + flex: 1; + font-family: var(--font-mono); + font-size: 0.95rem; + font-weight: 500; + overflow-x: auto; + white-space: nowrap; + padding: 0.45rem 0.6rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +/* Outer view switch (Method / Platform) */ +.views { margin-top: 1.5rem; position: relative; } .views > input[type=radio] { position: absolute; opacity: 0; pointer-events: none; } -.view-tabs { +.view-switch { display: inline-flex; gap: 0.25rem; - padding: 0.25rem; + padding: 0.3rem; background: var(--bg-soft); border: 1px solid var(--border); - border-radius: var(--radius-sm); - margin-bottom: 1rem; + border-radius: 999px; + margin-bottom: 1.25rem; } .view-tab { - padding: 0.4rem 0.9rem; + padding: 0.5rem 1.1rem; font-size: 0.9rem; - border-radius: calc(var(--radius-sm) - 2px); + font-weight: 500; + border-radius: 999px; cursor: pointer; color: var(--fg-muted); user-select: none; - transition: color 120ms ease, background 120ms ease; + transition: color 120ms ease, background 120ms ease, box-shadow 120ms ease; } .view-tab:hover { color: var(--fg); } .views .view { display: none; } #view-method:checked ~ .view-by-method { display: block; } #view-platform:checked ~ .view-by-platform { display: block; } -#view-method:checked ~ .view-tabs [for=view-method], -#view-platform:checked ~ .view-tabs [for=view-platform] { +#view-method:checked ~ .view-switch [for=view-method], +#view-platform:checked ~ .view-switch [for=view-platform] { background: var(--bg); color: var(--fg); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); } -/* Auth-method cards */ -.method-card { } -.method-head { +/* Inner tabs (OAuth / DCR / Bearer or per-platform) */ +.view > input[type=radio] { + position: absolute; + opacity: 0; + pointer-events: none; +} +.inner-tabs { display: flex; + flex-wrap: wrap; + gap: 0.25rem; + border-bottom: 1px solid var(--border); + margin-bottom: 1.25rem; + padding-bottom: 0; +} +.inner-tab { + display: inline-flex; align-items: center; - gap: 0.6rem; - margin-bottom: 0.5rem; + gap: 0.5rem; + padding: 0.6rem 0.9rem; + font-size: 0.9rem; + color: var(--fg-muted); + cursor: pointer; + user-select: none; + border-bottom: 2px solid transparent; + transition: color 120ms ease, border-color 120ms ease; + margin-bottom: -1px; } -.method-head h2 { margin: 0; } -.method-badge { +.inner-tab:hover { color: var(--fg); } +.tab-badge { display: inline-block; - padding: 0.15rem 0.55rem; - font-size: 0.72rem; - font-weight: 600; + padding: 0.15rem 0.5rem; + font-size: 0.68rem; + font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; border-radius: 999px; + border: 1px solid currentColor; + background: transparent; + line-height: 1.4; +} +.tab-badge-oauth { color: #5c93db; } +.tab-badge-dcr { color: #e57000; } +.tab-badge-bearer { color: var(--fg-muted); } + +/* Panels (hidden until their radio is checked) */ +.tab-panel { display: none; } +#tab-oauth:checked ~ .tab-panel[data-panel=oauth], +#tab-dcr:checked ~ .tab-panel[data-panel=dcr], +#tab-bearer:checked ~ .tab-panel[data-panel=bearer], +#p-claude:checked ~ .tab-panel[data-panel=claude], +#p-chatgpt:checked ~ .tab-panel[data-panel=chatgpt], +#p-gemini-web:checked ~ .tab-panel[data-panel=gemini-web], +#p-gemini-cli:checked ~ .tab-panel[data-panel=gemini-cli], +#p-antigravity:checked ~ .tab-panel[data-panel=antigravity], +#p-other:checked ~ .tab-panel[data-panel=other] { + display: block; +} + +/* Active-tab underline + color */ +#tab-oauth:checked ~ .inner-tabs [for=tab-oauth], +#tab-dcr:checked ~ .inner-tabs [for=tab-dcr], +#tab-bearer:checked ~ .inner-tabs [for=tab-bearer], +#p-claude:checked ~ .inner-tabs [for=p-claude], +#p-chatgpt:checked ~ .inner-tabs [for=p-chatgpt], +#p-gemini-web:checked ~ .inner-tabs [for=p-gemini-web], +#p-gemini-cli:checked ~ .inner-tabs [for=p-gemini-cli], +#p-antigravity:checked ~ .inner-tabs [for=p-antigravity], +#p-other:checked ~ .inner-tabs [for=p-other] { + color: var(--fg); + border-bottom-color: var(--accent, #e57000); + font-weight: 600; +} + +/* Card layout shared by method and platform panels */ +.card { + background: var(--bg); border: 1px solid var(--border); - color: var(--fg-muted); + border-radius: var(--radius); + padding: 1.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); } -.method-badge-oauth { background: rgba(92, 147, 219, 0.14); border-color: rgba(92, 147, 219, 0.45); color: #5c93db; } -.method-badge-dcr { background: rgba(229, 112, 0, 0.14); border-color: rgba(229, 112, 0, 0.45); color: #e57000; } -.method-badge-bearer { background: rgba(140, 140, 140, 0.14); color: var(--fg-muted); } -.method-subhead { - margin: 1.25rem 0 0.5rem; - font-size: 0.95rem; +.card-head { + display: flex; + gap: 1rem; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 1rem; + flex-wrap: wrap; +} +.card-title h2 { + margin: 0 0 0.25rem; + font-size: 1.15rem; + display: inline-flex; + align-items: center; + gap: 0.6rem; +} +.card-title p { margin: 0; font-size: 0.9rem; } +.card-logos { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; +} +.logo-pill { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.3rem 0.65rem; + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 999px; + font-size: 0.82rem; color: var(--fg-muted); - text-transform: uppercase; - letter-spacing: 0.05em; } -.method-howto { +.logo-pill svg { flex-shrink: 0; } +.logo-pill-plus { + font-style: italic; + background: transparent; + border-style: dashed; +} + +/* Step list for OAuth how-to */ +.steps { + padding-left: 1.3rem; + margin: 0; + counter-reset: step; +} +.steps li { + margin: 0.75rem 0; + padding-left: 0.25rem; + line-height: 1.5; +} +.steps pre { + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.55rem 0.75rem; + overflow-x: auto; + font-size: 0.85rem; + margin: 0.5rem 0; +} +.steps pre code { background: none; padding: 0; } + +/* Bearer card split layout: form on the left, token list on the right */ +.card-split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; margin-top: 1rem; - border-top: 1px solid var(--border); - padding-top: 0.75rem; } -.method-howto > summary { +@media (max-width: 760px) { + .card-split { grid-template-columns: 1fr; } +} +.subhead { + margin: 0 0 0.75rem; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-muted); + display: flex; + align-items: center; + gap: 0.5rem; +} +.counter { + font-size: 0.75rem; + padding: 0.1rem 0.45rem; + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--fg-muted); + text-transform: none; + letter-spacing: 0; +} + +/* Success banner for just-created tokens */ +.alert { + padding: 1rem 1.1rem; + border-radius: var(--radius-sm); + margin-bottom: 1rem; +} +.alert strong { display: block; font-size: 0.95rem; } +.alert p { margin: 0.25rem 0 0.75rem; font-size: 0.9rem; } +.alert-success { + background: color-mix(in srgb, #2a9d5a 14%, var(--bg)); + border: 1px solid color-mix(in srgb, #2a9d5a 45%, var(--border)); +} + +/* Snippets gallery */ +.howto { + margin-top: 1.25rem; + border-top: 1px dashed var(--border); + padding-top: 1rem; +} +.howto > summary { cursor: pointer; color: var(--fg-muted); font-size: 0.9rem; list-style: none; + user-select: none; } -.method-howto > summary::-webkit-details-marker { display: none; } -.method-howto > summary::before { +.howto > summary::-webkit-details-marker { display: none; } +.howto > summary::before { content: "▸ "; display: inline-block; transition: transform 120ms ease; + margin-right: 0.25rem; } -.method-howto[open] > summary::before { transform: rotate(90deg); } -.method-howto ol { padding-left: 1.2rem; margin: 0.75rem 0; } -.method-howto li { margin: 0.5rem 0; } -.method-howto h4 { - margin: 1rem 0 0.35rem; - font-size: 0.9rem; - color: var(--fg-muted); +.howto[open] > summary::before { transform: rotate(90deg); } +.snippets { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem; + margin-top: 1rem; +} +@media (max-width: 760px) { + .snippets { grid-template-columns: 1fr; } +} +.snippet h4 { + margin: 0 0 0.4rem; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; text-transform: uppercase; - letter-spacing: 0.05em; + color: var(--fg-muted); } -.method-howto pre { +.snippet p { margin: 0; font-size: 0.9rem; } +.snippet pre { background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius-sm); - padding: 0.55rem 0.75rem; + padding: 0.6rem 0.8rem; overflow-x: auto; - font-size: 0.85rem; - margin: 0.3rem 0; + font-size: 0.82rem; + margin: 0.35rem 0 0; } -.method-howto pre code { background: none; padding: 0; } +.snippet pre code { background: none; padding: 0; } -/* Platform view */ -.platform-card h2 { - display: flex; - align-items: baseline; - gap: 0.6rem; - margin-bottom: 0.5rem; -} -.platform-method { - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.04em; +/* Method tag on platform cards */ +.method-tag { + display: inline-block; + padding: 0.18rem 0.55rem; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.05em; text-transform: uppercase; - padding: 0.15rem 0.55rem; border-radius: 999px; - background: var(--bg-soft); - color: var(--fg-muted); - border: 1px solid var(--border); + border: 1px solid currentColor; + vertical-align: middle; } -.platform-card pre { +.method-tag-oauth { color: #5c93db; } +.method-tag-dcr { color: #e57000; } +.method-tag-bearer { color: var(--fg-muted); } + +.card pre { background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius-sm); - padding: 0.55rem 0.75rem; + padding: 0.6rem 0.8rem; overflow-x: auto; font-size: 0.85rem; } -.platform-card pre code { background: none; padding: 0; } +.card pre code { background: none; padding: 0; } .token-endpoint code { flex: 1; font-family: var(--font-mono); diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index 75c40c8..dfda866 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -13,291 +13,376 @@

API access

- Signed in as {{ client_name }}. Pick the client you - want to connect and follow the matching flow. Every flow keeps 2FA - on your phone — BeaconMCP never stores a TOTP seed on a machine. + Signed in as {{ client_name }}. Pick your client and + follow the matching flow — every flow keeps your TOTP on your phone.

-
-

MCP server URL

-
+
+
MCP server URL
+
{{ mcp_url }}
-

- Bearer-based and pre-registered-OAuth clients both point here. ChatGPT - connectors get a slug-scoped alias of this URL when minted below. -

- +
- {# =========================================================== #} - {# VIEW: BY METHOD #} - {# =========================================================== #} + {# ============================================================ #} + {# VIEW: BY METHOD (3 nested tabs) #} + {# ============================================================ #}
+ + + + - {# ---------- OAuth (pre-registered) ---------- #} -
-
- OAuth 2.1 -

Pre-registered client

-
-

- For clients that support full OAuth 2.1 with a user-provided - client_id and client_secret. BeaconMCP - handles the authorization code + PKCE flow and prompts for your - TOTP at every 24 h refresh. -

-

Supported: Claude (web, iOS, Android, desktop).

-
- How to set one up -
    -
  1. On the BeaconMCP server, run: + {# ---------- Panel: OAuth ---------- #} +
    +
    +
    +
    +

    OAuth 2.1 with a pre-registered client

    +

    Works for clients that let you paste client_id + client_secret and follow a redirect.

    +
    +
    + + + Claude + +
    +
    +
      +
    1. + On the BeaconMCP server:
      beaconmcp auth create --name "Claude iPhone"
      - The CLI prints the client_id, client_secret, and a TOTP QR code. - Scan the QR into your authenticator app immediately. + The CLI shows a client_id, a client_secret, and a TOTP QR code (scan it now — the secret is printed once).
    2. In Claude: Settings → Integrations → Add custom connector.
    3. -
    4. URL: {{ mcp_url }}. Paste client_id and client_secret.
    5. -
    6. Claude redirects to this server's authorization page — type your TOTP code there, not in the CLI.
    7. +
    8. URL: {{ mcp_url }}. Paste the client credentials.
    9. +
    10. Claude redirects you to this server's authorization page — type the 6-digit code from your authenticator app. Token refreshes after 24 h.
    -
-
+ +
- {# ---------- OAuth DCR ---------- #} + {# ---------- Panel: DCR ---------- #} {% if dcr_enabled %} -
-
- OAuth + DCR -

Dynamic client registration

-
-

- For clients that only accept OAuth with a dynamically-registered - client (RFC 7591) — they discover everything from a URL and don't - let you paste credentials. BeaconMCP gates DCR behind a single-use - bootstrap URL you mint here; the resulting client is bound to your - account and has no TOTP seed of its own (2FA delegates to yours). -

-

Supported: ChatGPT (Developer Mode).

- - Open ChatGPT connectors - - -
+
+
+
+
+

OAuth Dynamic Client Registration

+

For clients that auto-register — ChatGPT's connector UI has no "bearer" field and does DCR behind the scenes.

+
+
+ + + ChatGPT + +
+
+

+ ChatGPT needs a single-use bootstrap URL to complete DCR. BeaconMCP + mints one after a TOTP challenge; the registered client is pinned + to your account and has no TOTP seed of its own — 2FA delegates + to yours, so a leaked client can't authorize anything without + your phone. +

+ + Open ChatGPT connectors + + +
+
{% endif %} - {# ---------- Bearer tokens ---------- #} -
-
- Bearer -

Static bearer tokens

-
-

- For clients that attach a fixed Authorization: Bearer … - header on every call. Each token lasts 24 h and is scoped to your - account. TOTP is verified once, at creation time. -

-

- Supported: Gemini Web, Gemini CLI, - Google Antigravity, any MCP-over-HTTP client - without an OAuth client of its own. -

+ {# ---------- Panel: Bearer ---------- #} +
+
+
+
+

Static bearer tokens

+

For clients that attach Authorization: Bearer … to every call — most REST-only MCP integrations.

+
+
+ + + Gemini + + + + Antigravity + + + any REST client +
+
- {% if just_created %} -
-

New token: {{ just_created.name }}

-

Copy it now — it will not be shown again.

-
- {{ just_created.token }} - + {% if just_created %} +
+ New token: {{ just_created.name }} +

Copy it now — it will not be shown again.

+
+ {{ just_created.token }} + +
-
- {% endif %} + {% endif %} - {% if form_error %} - - {% endif %} - -

Create a token ({{ count }}/{{ cap }})

- {% if can_create %} -
- - - - -
- {% else %} -

- You reached the limit of {{ cap }} active tokens. Revoke one before creating another. -

- {% endif %} + {% if form_error %} + + {% endif %} -

Active tokens

- {% if tokens %} -
    - {% for t in tokens %} -
  • -
    - {{ t.name }} - {{ t.prefix }}… +
    +
    +

    Create a token {{ count }} / {{ cap }}

    + {% if can_create %} +
    + + + + +
    + {% else %} +

    + You reached the limit of {{ cap }} active tokens. Revoke one before creating another. +

    + {% endif %}
    -
    - expires in {{ t.expires_in_hours }} h + +
    +

    Active tokens

    + {% if tokens %} +
      + {% for t in tokens %} +
    • +
      + {{ t.name }} + {{ t.prefix }}… +
      +
      + expires in {{ t.expires_in_hours }} h +
      +
      + + + +
      +
    • + {% endfor %} +
    + {% else %} +

    No active tokens yet.

    + {% endif %}
    -
    - - - -
    -
  • - {% endfor %} -
- {% else %} -

No active tokens yet.

- {% endif %} +
-
- Usage snippets -

Gemini CLI

+
+ Usage snippets for each client +
+
+

Gemini CLI

gemini mcp add beaconmcp \
   --url {{ mcp_url }} \
   --header "Authorization: Bearer <token>"
-

Gemini Web

-

- Tools → Extensions → Custom MCP → paste - {{ mcp_url }} and Bearer <token> - in the Authorization field. -

-

Google Antigravity

-

- In your project's .antigravity/mcp.json, add the - server with the authorization field set to - Bearer <token> and url set to - {{ mcp_url }}. -

-

Generic (any MCP-over-HTTP client)

-

- Send every request to {{ mcp_url }} with - Authorization: Bearer <token>. The token - dies after 24 h — revoke & re-issue from here. -

-
-
- +
+
+

Gemini Web

+

Tools → Extensions → Custom MCP. Paste the URL and Bearer <token> in the Authorization field.

+
+
+

Google Antigravity

+

In .antigravity/mcp.json, add:

+
{
+  "servers": {
+    "beaconmcp": {
+      "url": "{{ mcp_url }}",
+      "authorization": "Bearer <token>"
+    }
+  }
+}
+
+
+

Generic HTTP client

+

Any MCP-HTTP client: POST to {{ mcp_url }} with Authorization: Bearer <token>. Token expires after 24 h.

+
+ + + + - {# =========================================================== #} - {# VIEW: BY PLATFORM #} - {# =========================================================== #} + {# ============================================================ #} + {# VIEW: BY PLATFORM (nested tabs, one card per platform) #} + {# ============================================================ #}
+ + {% if dcr_enabled %} + + {% endif %} + + + + + -
-

Claude OAuth pre-registered

-

- Claude's custom connector accepts a user-provided - client_id and client_secret and runs a - full OAuth authorization code + PKCE flow against BeaconMCP. -

-

- Head to the pre-registered OAuth section for the CLI command. -

-
+
+
+
+
+

Claude

+ OAuth 2.1 +
+
+

+ Claude's custom connector takes a client_id and + client_secret and runs the OAuth 2.1 authorization + code flow against BeaconMCP. Provision credentials on the server + with beaconmcp auth create, then paste them into + Settings → Integrations → Add custom connector. +

+
+
{% if dcr_enabled %} -
-

ChatGPT OAuth + DCR

-

- ChatGPT's connector UI only exposes "No auth" and "OAuth" — no - bearer field — and its OAuth path requires Dynamic Client - Registration. Mint a single-use bootstrap URL that pins DCR to - your account. -

- - Mint a connector URL - - -
+
+
+
+
+

ChatGPT

+ OAuth + DCR +
+
+

+ ChatGPT's connector UI only exposes OAuth (no bearer field) and + requires Dynamic Client Registration. Mint a one-off bootstrap + URL below — single-use, 15 min TTL, pins the registered client + to your account. +

+ + Mint a connector URL + + +
+
{% endif %} -
-

Gemini Web Bearer

-

- Gemini's custom-MCP panel takes a URL and an Authorization header. - Create a bearer token, paste - Bearer <token> in the header field. -

-

- Create a bearer token → -

-
+
+
+
+
+

Gemini Web

+ Bearer +
+
+

+ In Gemini's custom-MCP panel, paste {{ mcp_url }} + and Bearer <token> in the Authorization + field. Create a token from the Bearer tab. +

+
+
-
-

Gemini CLI Bearer

-

- Register the server with a bearer header. -

+
+
+
+
+

Gemini CLI

+ Bearer +
+
gemini mcp add beaconmcp \
   --url {{ mcp_url }} \
   --header "Authorization: Bearer <token>"
-

- Create a bearer token → -

-
+

Generate the token from the Bearer tab.

+ +
-
-

Google Antigravity Bearer

-

- Add BeaconMCP to Antigravity's MCP config file with - Authorization: Bearer <token>. The bearer - lives in your workspace-level MCP config — treat it like any - secret. -

-

- Create a bearer token → -

-
- -
-

Other MCP-HTTP clients Bearer

-

- Any client that can send Authorization: Bearer … on - an HTTP POST works. Mint a token and point it at - {{ mcp_url }}. -

-

- Create a bearer token → -

-
+
+
+
+
+

Google Antigravity

+ Bearer +
+
+

Add to .antigravity/mcp.json:

+
{
+  "servers": {
+    "beaconmcp": {
+      "url": "{{ mcp_url }}",
+      "authorization": "Bearer <token>"
+    }
+  }
+}
+
+
+
+
+
+
+

Other MCP-HTTP clients

+ Bearer +
+
+

+ Any client that can send an Authorization: Bearer … + header on an HTTP POST works. Point it at {{ mcp_url }} + and attach a token from the Bearer tab. +

+
+
@@ -324,14 +409,5 @@

Other MCP-HTTP clients Bearer

} }); } - - // Clicking an in-page anchor (#method-bearer) from the By-platform - // view should also flip the tab back to By-method so the target is - // visible. Without this the click scrolls to a hidden element. - for (const a of document.querySelectorAll('a[href^="#method-"]')) { - a.addEventListener("click", () => { - document.getElementById("view-method").checked = true; - }); - } {% endblock %} From 9fe380b16e30130d477b86dff453fbaa6c5aeb36 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 12:35:53 +0200 Subject: [PATCH 062/155] Group client variants under parent platforms; add Mistral/OpenCode/VS Code/Cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform view on /app/tokens now nests variants under their parent — Claude has Web/Mobile vs Desktop, ChatGPT Web/Mobile, Gemini CLI/Web/ Antigravity, Mistral Le Chat/Vibe — using CSS-only sub-tabs. Four bearer-only platforms are new: Mistral (Le Chat + Vibe), OpenCode, VS Code, Cursor, each with their config snippet. README picks up the same additions. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 86 +++++ src/beaconmcp/dashboard/static/app.css | 107 +++++- src/beaconmcp/dashboard/templates/tokens.html | 343 ++++++++++++------ 3 files changed, 420 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index 46bb814..b9b848b 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,92 @@ For programmatic Gemini API usage, the BeaconMCP server is passed as a remote MC 4. When the bearer expires, re-issue it through the dashboard. Long-running services should rotate tokens on a schedule (an operator typing the TOTP) rather than embedding the seed. +### Mistral + +Mistral's clients (Le Chat web/mobile, Mistral Vibe) use a static bearer header, same as Gemini. + +**Le Chat** — *Settings → Connectors → Add custom MCP server* (Pro / Enterprise plans): +- URL: `https:///mcp` +- Auth: Bearer token → paste your dashboard-issued token. + +**Mistral Vibe** — add BeaconMCP to the Vibe config file: + +```json +// ~/.mistral/vibe/config.json +{ + "mcp": { + "servers": { + "beaconmcp": { + "url": "https:///mcp", + "headers": { "Authorization": "Bearer " } + } + } + } +} +``` + +Cross-check against the Vibe docs — the schema has been iterating. + +### OpenCode + +OpenCode reads MCP servers from `opencode.json` (or `~/.config/opencode/opencode.json`): + +```json +{ + "mcp": { + "beaconmcp": { + "type": "remote", + "url": "https:///mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +Use `"type": "remote"` for a hosted BeaconMCP; the `local` type is for stdio-based servers. + +### VS Code + +VS Code's built-in MCP client picks up servers from workspace or user settings: + +```json +// .vscode/mcp.json (or settings.json → "mcp.servers") +{ + "servers": { + "beaconmcp": { + "type": "http", + "url": "https:///mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +Verify with *Command Palette → MCP: List Servers*. If you rely on GitHub Copilot's MCP integration, field names may differ — check the extension's readme. + +### Cursor + +Cursor reads MCP servers from `.cursor/mcp.json` (per-project) or `~/.cursor/mcp.json` (global): + +```json +{ + "mcpServers": { + "beaconmcp": { + "url": "https:///mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +Reload the Cursor window after editing; the server shows up under *Settings → Cursor Settings → MCP Servers* with a live status indicator. + ### Other MCP-over-HTTP clients Any client that can send a bearer on `https:///mcp` works the same way: create a token from `/app/tokens` after typing your TOTP, configure the client to send `Authorization: Bearer `, revoke from the same page when you are done. If the client natively speaks OAuth 2.1 (like Claude), prefer that flow — it keeps the TOTP prompt at the authorization page instead of relying on a stored bearer. diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index 77deddb..26d56ce 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -1356,31 +1356,110 @@ a.sidebar-link { cursor: pointer; } .tab-panel { display: none; } #tab-oauth:checked ~ .tab-panel[data-panel=oauth], #tab-dcr:checked ~ .tab-panel[data-panel=dcr], -#tab-bearer:checked ~ .tab-panel[data-panel=bearer], -#p-claude:checked ~ .tab-panel[data-panel=claude], -#p-chatgpt:checked ~ .tab-panel[data-panel=chatgpt], -#p-gemini-web:checked ~ .tab-panel[data-panel=gemini-web], -#p-gemini-cli:checked ~ .tab-panel[data-panel=gemini-cli], -#p-antigravity:checked ~ .tab-panel[data-panel=antigravity], -#p-other:checked ~ .tab-panel[data-panel=other] { +#tab-bearer:checked ~ .tab-panel[data-panel=bearer] { display: block; } -/* Active-tab underline + color */ +/* Active-tab underline + color (inner: method or platform) */ #tab-oauth:checked ~ .inner-tabs [for=tab-oauth], #tab-dcr:checked ~ .inner-tabs [for=tab-dcr], #tab-bearer:checked ~ .inner-tabs [for=tab-bearer], -#p-claude:checked ~ .inner-tabs [for=p-claude], -#p-chatgpt:checked ~ .inner-tabs [for=p-chatgpt], -#p-gemini-web:checked ~ .inner-tabs [for=p-gemini-web], -#p-gemini-cli:checked ~ .inner-tabs [for=p-gemini-cli], -#p-antigravity:checked ~ .inner-tabs [for=p-antigravity], -#p-other:checked ~ .inner-tabs [for=p-other] { +#p-claude:checked ~ .inner-tabs [for=p-claude], +#p-chatgpt:checked ~ .inner-tabs [for=p-chatgpt], +#p-gemini:checked ~ .inner-tabs [for=p-gemini], +#p-mistral:checked ~ .inner-tabs [for=p-mistral], +#p-opencode:checked ~ .inner-tabs [for=p-opencode], +#p-vscode:checked ~ .inner-tabs [for=p-vscode], +#p-cursor:checked ~ .inner-tabs [for=p-cursor], +#p-other:checked ~ .inner-tabs [for=p-other] { color: var(--fg); border-bottom-color: var(--accent, #e57000); font-weight: 600; } +/* Replace old platform-tab selector set */ +#p-claude:checked ~ .tab-panel[data-panel=claude], +#p-chatgpt:checked ~ .tab-panel[data-panel=chatgpt], +#p-gemini:checked ~ .tab-panel[data-panel=gemini], +#p-mistral:checked ~ .tab-panel[data-panel=mistral], +#p-opencode:checked ~ .tab-panel[data-panel=opencode], +#p-vscode:checked ~ .tab-panel[data-panel=vscode], +#p-cursor:checked ~ .tab-panel[data-panel=cursor], +#p-other:checked ~ .tab-panel[data-panel=other] { + display: block; +} + +/* Third-level sub-tabs (e.g. Claude: Web/Mobile · Desktop) */ +.sub-tabs { + display: inline-flex; + gap: 0.15rem; + padding: 0.2rem; + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + margin: 0.75rem 0 1rem; +} +.sub-tab { + padding: 0.35rem 0.75rem; + font-size: 0.85rem; + color: var(--fg-muted); + border-radius: calc(var(--radius-sm) - 2px); + cursor: pointer; + user-select: none; + transition: color 120ms ease, background 120ms ease; +} +.sub-tab:hover { color: var(--fg); } +.sub-panel { display: none; } + +/* Claude variants */ +#cv-web:checked ~ .sub-panel[data-sub=claude-web], +#cv-desktop:checked ~ .sub-panel[data-sub=claude-desktop] { display: block; } +#cv-web:checked ~ .sub-tabs [for=cv-web], +#cv-desktop:checked ~ .sub-tabs [for=cv-desktop] { + background: var(--bg); + color: var(--fg); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +/* ChatGPT variants */ +#gv-web:checked ~ .sub-panel[data-sub=chatgpt-web], +#gv-mobile:checked ~ .sub-panel[data-sub=chatgpt-mobile] { display: block; } +#gv-web:checked ~ .sub-tabs [for=gv-web], +#gv-mobile:checked ~ .sub-tabs [for=gv-mobile] { + background: var(--bg); + color: var(--fg); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +/* Gemini variants */ +#gem-cli:checked ~ .sub-panel[data-sub=gem-cli], +#gem-web:checked ~ .sub-panel[data-sub=gem-web], +#gem-antigravity:checked ~ .sub-panel[data-sub=gem-antigravity] { display: block; } +#gem-cli:checked ~ .sub-tabs [for=gem-cli], +#gem-web:checked ~ .sub-tabs [for=gem-web], +#gem-antigravity:checked ~ .sub-tabs [for=gem-antigravity] { + background: var(--bg); + color: var(--fg); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +/* Mistral variants */ +#mi-lechat:checked ~ .sub-panel[data-sub=mi-lechat], +#mi-vibe:checked ~ .sub-panel[data-sub=mi-vibe] { display: block; } +#mi-lechat:checked ~ .sub-tabs [for=mi-lechat], +#mi-vibe:checked ~ .sub-tabs [for=mi-vibe] { + background: var(--bg); + color: var(--fg); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +/* Hide inline radios used by sub-tabs */ +.tab-panel > input[type=radio] { + position: absolute; + opacity: 0; + pointer-events: none; +} + /* Card layout shared by method and platform panels */ .card { background: var(--bg); diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index dfda866..a5050d6 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -38,7 +38,7 @@

API access

{# ============================================================ #} - {# VIEW: BY METHOD (3 nested tabs) #} + {# VIEW: BY METHOD #} {# ============================================================ #}
@@ -70,10 +70,7 @@

OAuth 2.1 with a pre-registered client

Works for clients that let you paste client_id + client_secret and follow a redirect.

- - - Claude - + Claude
    @@ -82,7 +79,7 @@

    OAuth 2.1 with a pre-registered client

    beaconmcp auth create --name "Claude iPhone"
    The CLI shows a client_id, a client_secret, and a TOTP QR code (scan it now — the secret is printed once). -
  1. In Claude: Settings → Integrations → Add custom connector.
  2. +
  3. In your Claude client: Settings → Integrations → Add custom connector.
  4. URL: {{ mcp_url }}. Paste the client credentials.
  5. Claude redirects you to this server's authorization page — type the 6-digit code from your authenticator app. Token refreshes after 24 h.
@@ -96,21 +93,16 @@

OAuth 2.1 with a pre-registered client

OAuth Dynamic Client Registration

-

For clients that auto-register — ChatGPT's connector UI has no "bearer" field and does DCR behind the scenes.

+

For clients that auto-register via a URL with no credentials field.

- - - ChatGPT - + ChatGPT

- ChatGPT needs a single-use bootstrap URL to complete DCR. BeaconMCP - mints one after a TOTP challenge; the registered client is pinned - to your account and has no TOTP seed of its own — 2FA delegates - to yours, so a leaked client can't authorize anything without - your phone. + A one-off bootstrap URL gates DCR on your account. The derived + client has no TOTP seed of its own — 2FA delegates to yours, so + a leaked client can't authorize anything without your phone.

Open ChatGPT connectors @@ -126,18 +118,15 @@

OAuth Dynamic Client Registration

Static bearer tokens

-

For clients that attach Authorization: Bearer … to every call — most REST-only MCP integrations.

+

For clients that attach Authorization: Bearer … to every call.

- - - Gemini - - - - Antigravity - - + any REST client + Gemini + Mistral + VS Code + Cursor + OpenCode + + REST
@@ -168,7 +157,7 @@

Create a token {{ count }} / {{ cap }} @@ -218,64 +207,38 @@

Active tokens

-
- Usage snippets for each client -
-
-

Gemini CLI

-
gemini mcp add beaconmcp \
-  --url {{ mcp_url }} \
-  --header "Authorization: Bearer <token>"
-
-
-

Gemini Web

-

Tools → Extensions → Custom MCP. Paste the URL and Bearer <token> in the Authorization field.

-
-
-

Google Antigravity

-

In .antigravity/mcp.json, add:

-
{
-  "servers": {
-    "beaconmcp": {
-      "url": "{{ mcp_url }}",
-      "authorization": "Bearer <token>"
-    }
-  }
-}
-
-
-

Generic HTTP client

-

Any MCP-HTTP client: POST to {{ mcp_url }} with Authorization: Bearer <token>. Token expires after 24 h.

-
-
-
+

+ Concrete setup per client is in the By platform + tab. +

{# ============================================================ #} - {# VIEW: BY PLATFORM (nested tabs, one card per platform) #} + {# VIEW: BY PLATFORM — with nested sub-tabs for variants #} {# ============================================================ #}
- {% if dcr_enabled %} - - {% endif %} - - - + {% if dcr_enabled %}{% endif %} + + + + + + {# ---- Claude (sub-tabs: Web/Mobile, Desktop) ---- #}
@@ -284,16 +247,54 @@

Claude

OAuth 2.1
-

- Claude's custom connector takes a client_id and - client_secret and runs the OAuth 2.1 authorization - code flow against BeaconMCP. Provision credentials on the server - with beaconmcp auth create, then paste them into - Settings → Integrations → Add custom connector. +

+ Claude uses OAuth with a user-provided client. Same CLI command + creates credentials for every surface — what changes is where + you paste them.

+ + + + + +
+
    +
  1. On the server:
    beaconmcp auth create --name "Claude Web"
  2. +
  3. claude.ai or iOS/Android app → Settings → Integrations → Add custom connector.
  4. +
  5. URL: {{ mcp_url }}. Paste client_id / client_secret.
  6. +
  7. Type your TOTP on the authorization page when Claude redirects you.
  8. +
+
+
+

Claude Desktop loads MCP servers from a local JSON config.

+
// ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)
+// %APPDATA%\Claude\claude_desktop_config.json (Windows)
+{
+  "mcpServers": {
+    "beaconmcp": {
+      "command": "npx",
+      "args": [
+        "-y", "mcp-remote",
+        "{{ mcp_url }}",
+        "--oauth"
+      ]
+    }
+  }
+}
+

+ mcp-remote is the community OAuth-to-stdio proxy. + It triggers the same authorization code flow as the web app + (TOTP prompt in your browser). Pre-provision the client with + beaconmcp auth create first. +

+
+ {# ---- ChatGPT (sub-tabs: Web, Mobile) ---- #} {% if dcr_enabled %}
@@ -303,71 +304,208 @@

ChatGPT

OAuth + DCR
-

- ChatGPT's connector UI only exposes OAuth (no bearer field) and - requires Dynamic Client Registration. Mint a one-off bootstrap - URL below — single-use, 15 min TTL, pins the registered client - to your account. +

+ ChatGPT's Developer Mode connector UI only exposes "No auth" and + "OAuth", and the OAuth path requires Dynamic Client Registration. + Mint a single-use bootstrap URL to pin DCR to your account.

Mint a connector URL + + + + +
+
    +
  1. Settings → Connectors → Developer Mode → Add custom connector.
  2. +
  3. Paste the slug URL you minted above. Auth: OAuth.
  4. +
  5. ChatGPT runs DCR, then redirects you — type your TOTP.
  6. +
+
+
+
    +
  1. ChatGPT app → profile → Settings → Connectors → Add custom.
  2. +
  3. Same slug URL, same OAuth toggle, same TOTP prompt in the in-app browser.
  4. +
  5. Mint a fresh slug per device if you want independent revocation.
  6. +
+
{% endif %} -
+ {# ---- Gemini (sub-tabs: Web, CLI, Antigravity) ---- #} +
-

Gemini Web

+

Gemini

Bearer
-

- In Gemini's custom-MCP panel, paste {{ mcp_url }} - and Bearer <token> in the Authorization - field. Create a token from the Bearer tab. -

+

Every Gemini surface takes a static bearer header. One token covers all three.

+ + + + +
+
gemini mcp add beaconmcp \
+  --url {{ mcp_url }} \
+  --header "Authorization: Bearer <token>"
+

Mint the bearer from the bearer tab above, or the By method view.

+
+
+

gemini.google.com → Tools → Extensions → Custom MCP. Paste {{ mcp_url }} and set the Authorization header to Bearer <token>.

+
+
+

Add BeaconMCP to your Antigravity MCP config:

+
// .antigravity/mcp.json
+{
+  "servers": {
+    "beaconmcp": {
+      "url": "{{ mcp_url }}",
+      "authorization": "Bearer <token>"
+    }
+  }
+}
+
-
+ {# ---- Mistral (sub-tabs: Le Chat, Vibe) ---- #} +
-

Gemini CLI

+

Mistral

Bearer
-
gemini mcp add beaconmcp \
-  --url {{ mcp_url }} \
-  --header "Authorization: Bearer <token>"
-

Generate the token from the Bearer tab.

+ + + +
+

In Le Chat: Settings → Connectors → Add custom MCP server.

+
    +
  • URL: {{ mcp_url }}
  • +
  • Authentication: Bearer token → paste <token>
  • +
+

Custom connectors are on Le Chat Pro / Enterprise plans; on the free tier the panel may be hidden.

+
+
+

Mistral Vibe reads MCP servers from its config file.

+
// ~/.mistral/vibe/config.json
+{
+  "mcp": {
+    "servers": {
+      "beaconmcp": {
+        "url": "{{ mcp_url }}",
+        "headers": { "Authorization": "Bearer <token>" }
+      }
+    }
+  }
+}
+

Cross-check with the Vibe docs — the schema has been iterating.

+
-
+ {# ---- OpenCode ---- #} +
-

Google Antigravity

+

OpenCode

Bearer
-

Add to .antigravity/mcp.json:

+

Add the server to your OpenCode config (opencode.json or ~/.config/opencode/opencode.json):

{
+  "mcp": {
+    "beaconmcp": {
+      "type": "remote",
+      "url": "{{ mcp_url }}",
+      "headers": {
+        "Authorization": "Bearer <token>"
+      }
+    }
+  }
+}
+

OpenCode supports "type": "local" too; use remote for a hosted BeaconMCP over HTTPS.

+
+
+ + {# ---- VS Code ---- #} +
+
+
+
+

VS Code

+ Bearer +
+
+

VS Code's built-in MCP client reads from workspace or user settings.

+
// .vscode/mcp.json (or settings.json → "mcp.servers")
+{
   "servers": {
     "beaconmcp": {
+      "type": "http",
       "url": "{{ mcp_url }}",
-      "authorization": "Bearer <token>"
+      "headers": {
+        "Authorization": "Bearer <token>"
+      }
+    }
+  }
+}
+

+ Open the Command Palette → MCP: List Servers to verify + the connection. If you're using GitHub Copilot's MCP integration, + the field names may differ — check the extension's readme. +

+
+
+ + {# ---- Cursor ---- #} +
+
+
+
+

Cursor

+ Bearer +
+
+

Add BeaconMCP to Cursor's MCP config:

+
// ~/.cursor/mcp.json (global) or .cursor/mcp.json (per project)
+{
+  "mcpServers": {
+    "beaconmcp": {
+      "url": "{{ mcp_url }}",
+      "headers": {
+        "Authorization": "Bearer <token>"
+      }
     }
   }
 }
+

+ Reload the Cursor window after editing; the server appears under + Settings → Cursor Settings → MCP Servers with a live + status indicator. +

+ {# ---- Other ---- #}
@@ -377,9 +515,10 @@

Other MCP-HTTP clients

- Any client that can send an Authorization: Bearer … - header on an HTTP POST works. Point it at {{ mcp_url }} - and attach a token from the Bearer tab. + Any client that can send Authorization: Bearer … on + HTTP POST works. Mint a token, point the client at + {{ mcp_url }}, and attach the bearer on every + request. Tokens live 24 h.

From 4256aff8a79f5a8a51c50dd182005900f3574beb Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 12:44:03 +0200 Subject: [PATCH 063/155] Correct MCP config schemas after verifying against each vendor's docs - Mistral Vibe: TOML, not JSON; path ./.vibe/config.toml or ~/.vibe/config.toml; entries are [[mcp_servers]] arrays with transport/url/headers fields. - Antigravity: top-level 'mcpServers', HTTP field is 'serverUrl' (not 'url'), file lives at ~/.gemini/antigravity/mcp_config.json. - OpenCode: supports OAuth DCR natively, so the slug URL works as an alternative to bearer when allow_dynamic_registration is on. VS Code and Cursor schemas were already correct; verified against code.visualstudio.com and cursor.com docs. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 55 ++++++++++---- src/beaconmcp/dashboard/templates/tokens.html | 72 +++++++++++++------ 2 files changed, 90 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index b9b848b..abbc3ed 100644 --- a/README.md +++ b/README.md @@ -227,31 +227,55 @@ For programmatic Gemini API usage, the BeaconMCP server is passed as a remote MC 4. When the bearer expires, re-issue it through the dashboard. Long-running services should rotate tokens on a schedule (an operator typing the TOTP) rather than embedding the seed. -### Mistral +### Google Antigravity -Mistral's clients (Le Chat web/mobile, Mistral Vibe) use a static bearer header, same as Gemini. +Antigravity reads MCP servers from `~/.gemini/antigravity/mcp_config.json` (macOS / Linux) or `%USERPROFILE%\.gemini\antigravity\mcp_config.json` (Windows). The top-level key is `mcpServers` and the HTTP URL field is **`serverUrl`** (not `url`): -**Le Chat** — *Settings → Connectors → Add custom MCP server* (Pro / Enterprise plans): -- URL: `https:///mcp` -- Auth: Bearer token → paste your dashboard-issued token. +```json +{ + "mcpServers": { + "beaconmcp": { + "serverUrl": "https:///mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` -**Mistral Vibe** — add BeaconMCP to the Vibe config file: +If the native HTTP transport misbehaves, fall back to the `mcp-remote` proxy: ```json -// ~/.mistral/vibe/config.json { - "mcp": { - "servers": { - "beaconmcp": { - "url": "https:///mcp", - "headers": { "Authorization": "Bearer " } - } + "mcpServers": { + "beaconmcp": { + "command": "npx", + "args": [ + "-y", "mcp-remote", + "https:///mcp", + "--header", "Authorization: Bearer " + ] } } } ``` -Cross-check against the Vibe docs — the schema has been iterating. +### Mistral + +**Le Chat** — *Settings → Connectors → Add custom MCP server*. Le Chat auto-detects the auth method supported by the server; pick Bearer, paste your dashboard-issued token, and set the URL to `https:///mcp`. Custom connectors are on Le Chat Pro / Enterprise plans. + +**Mistral Vibe** — Vibe reads its config from `./.vibe/config.toml` (per-project) or `~/.vibe/config.toml` (global). **TOML format**, not JSON: + +```toml +[[mcp_servers]] +name = "beaconmcp" +transport = "http" +url = "https:///mcp" +headers = { "Authorization" = "Bearer " } +``` + +`transport` accepts `"http"`, `"streamable-http"`, or `"stdio"`. Each server is its own `[[mcp_servers]]` array entry. ### OpenCode @@ -263,6 +287,7 @@ OpenCode reads MCP servers from `opencode.json` (or `~/.config/opencode/opencode "beaconmcp": { "type": "remote", "url": "https:///mcp", + "enabled": true, "headers": { "Authorization": "Bearer " } @@ -271,7 +296,7 @@ OpenCode reads MCP servers from `opencode.json` (or `~/.config/opencode/opencode } ``` -Use `"type": "remote"` for a hosted BeaconMCP; the `local` type is for stdio-based servers. +OpenCode also natively supports OAuth with Dynamic Client Registration. If you enable `allow_dynamic_registration` on the server, you can point OpenCode at a slug URL (`https:///mcp/c/` minted from `/app/connectors`) with `"oauth": true` and skip the bearer entirely. Tokens are stashed in `~/.local/share/opencode/mcp-auth.json`. ### VS Code diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index a5050d6..3d82860 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -366,16 +366,23 @@

Gemini

gemini.google.com → Tools → Extensions → Custom MCP. Paste {{ mcp_url }} and set the Authorization header to Bearer <token>.

-

Add BeaconMCP to your Antigravity MCP config:

-
// .antigravity/mcp.json
-{
-  "servers": {
+            

Edit ~/.gemini/antigravity/mcp_config.json (macOS / Linux) or %USERPROFILE%\.gemini\antigravity\mcp_config.json (Windows):

+
{
+  "mcpServers": {
     "beaconmcp": {
-      "url": "{{ mcp_url }}",
-      "authorization": "Bearer <token>"
+      "serverUrl": "{{ mcp_url }}",
+      "headers": {
+        "Authorization": "Bearer <token>"
+      }
     }
   }
 }
+

+ Antigravity uses serverUrl (not url) and + the top-level key is mcpServers. Reload the IDE + after editing. If the native HTTP transport misbehaves, fall + back to npx mcp-remote with --header. +

@@ -404,19 +411,17 @@

Mistral

Custom connectors are on Le Chat Pro / Enterprise plans; on the free tier the panel may be hidden.

-

Mistral Vibe reads MCP servers from its config file.

-
// ~/.mistral/vibe/config.json
-{
-  "mcp": {
-    "servers": {
-      "beaconmcp": {
-        "url": "{{ mcp_url }}",
-        "headers": { "Authorization": "Bearer <token>" }
-      }
-    }
-  }
-}
-

Cross-check with the Vibe docs — the schema has been iterating.

+

Mistral Vibe reads MCP servers from ./.vibe/config.toml (per-project) or ~/.vibe/config.toml (global). The config is TOML, not JSON.

+
[[mcp_servers]]
+name = "beaconmcp"
+transport = "http"
+url = "{{ mcp_url }}"
+headers = { "Authorization" = "Bearer <token>" }
+

+ transport accepts "http", + "streamable-http", or "stdio". Each + server is its own [[mcp_servers]] array entry. +

@@ -427,22 +432,45 @@

Mistral

OpenCode

- Bearer + Bearer or OAuth DCR
-

Add the server to your OpenCode config (opencode.json or ~/.config/opencode/opencode.json):

+

Add the server to opencode.json (or ~/.config/opencode/opencode.json):

{
   "mcp": {
     "beaconmcp": {
       "type": "remote",
       "url": "{{ mcp_url }}",
+      "enabled": true,
       "headers": {
         "Authorization": "Bearer <token>"
       }
     }
   }
 }
-

OpenCode supports "type": "local" too; use remote for a hosted BeaconMCP over HTTPS.

+ {% if dcr_enabled %} +

+ OAuth alternative: OpenCode implements full + OAuth with Dynamic Client Registration. You can skip the bearer + by pointing it at a slug URL instead — it will auto-register + and prompt for your TOTP on first use. +

+
{
+  "mcp": {
+    "beaconmcp": {
+      "type": "remote",
+      "url": "https://<your-host>/mcp/c/<slug>",
+      "enabled": true,
+      "oauth": true
+    }
+  }
+}
+

+ Mint the slug from the ChatGPT + connectors page — same flow, different client. Tokens land + in ~/.local/share/opencode/mcp-auth.json. +

+ {% endif %} From 24f7e395261c1db80fac693d0b770e136dce00c6 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 12:52:50 +0200 Subject: [PATCH 064/155] Tighten /app/tokens and split client docs into docs/clients.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CSS: harden the hide rule for tab/sub-tab radios (was showing native circles in some browsers because opacity:0 alone wasn't enough). - OpenCode: surface OAuth + DCR as the supported path and drop the bearer fallback from the UI. The bearer variant was redundant now that OpenCode ships proper DCR support. - Rename /app/connectors from "ChatGPT connector" to "OAuth connectors" — ChatGPT and OpenCode both flow through it. - Chat sidebar CTA: "Tokens API" -> "Accès API" to match the page's new broader scope. - README: keep only the Claude flow inline; move ChatGPT, Gemini (CLI/Web/Antigravity/API), Mistral, OpenCode, VS Code, Cursor to docs/clients.md. The dashboard is the primary reference; this is the offline companion. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 196 +------------ docs/clients.md | 269 ++++++++++++++++++ src/beaconmcp/dashboard/static/app.css | 20 +- src/beaconmcp/dashboard/templates/chat.html | 4 +- .../dashboard/templates/connectors.html | 26 +- src/beaconmcp/dashboard/templates/tokens.html | 48 ++-- 6 files changed, 326 insertions(+), 237 deletions(-) create mode 100644 docs/clients.md diff --git a/README.md b/README.md index abbc3ed..583a9b4 100644 --- a/README.md +++ b/README.md @@ -146,201 +146,11 @@ Claude performs the full OAuth 2.1 flow against BeaconMCP, so there is no long-l - **OAuth Client ID** and **OAuth Client Secret** from `beaconmcp auth create`. 3. **Add.** -On first use (and after each 24-hour token expiry) Claude redirects to the BeaconMCP authorization page. Read the current 6-digit code from your authenticator app and type it in. This is the recommended integration: Claude never holds the TOTP seed, and a leaked session cannot mint a new token without a fresh code from your phone. +On first use (and after each 24-hour token expiry) Claude redirects to the BeaconMCP authorization page. Read the current 6-digit code from your authenticator app and type it in. Claude never holds the TOTP seed, and a leaked session cannot mint a new token without a fresh code from your phone. -### ChatGPT +### Other clients -ChatGPT's Developer Mode connector only accepts **OAuth with Dynamic Client Registration (RFC 7591)** — it will not take a pre-provisioned `client_id` / `client_secret` nor a static bearer header. BeaconMCP supports this by minting a one-off bootstrap URL from the dashboard: the URL lets ChatGPT register a derived OAuth client tied to your account. 2FA is preserved — at authorization time, you still type your own TOTP from your phone; the derived client has no TOTP seed of its own. - -**One-time setup:** - -1. Enable the feature in `beaconmcp.yaml`: - ```yaml - server: - allow_dynamic_registration: true - ``` - Then restart `beaconmcp serve`. - -**To add ChatGPT (from your phone, no laptop needed):** - -1. In your mobile browser, open `https:///app/connectors`, sign in with your TOTP from your authenticator app. -2. Enter a label (e.g. `ChatGPT iPhone`), type your current TOTP, submit. You get a one-off URL of the shape `https:///mcp/c/`. The URL is **single-use** and expires in 15 min. -3. In the ChatGPT app: **Settings → Connectors → Add custom**. - - **Name:** BeaconMCP - - **URL:** paste the `/mcp/c/` URL. - - **Authentication:** OAuth. -4. ChatGPT fetches the OAuth metadata, POSTs to the slug-gated `/oauth/register/c/` — BeaconMCP consumes the slug atomically and mints a derived client scoped to your account. -5. ChatGPT then redirects you to BeaconMCP's authorization page. Type your TOTP from your phone. Token lifetime: 24 h. -6. From now on, ChatGPT auto-refreshes via the authorization code flow. Every 24 h it re-prompts for your TOTP — no re-registration, no new slug. - -**Revocation:** `https:///app/connectors` lists every active derived client. Revoke one and ChatGPT loses access immediately. Revoking your human account cascades to every derived client automatically. - -**Why not a static bearer?** ChatGPT's connector UI has no "Authorization header" field — only "No authentication" or "OAuth" — and the OAuth path strictly requires DCR. The slug-gated bootstrap is the narrow, audit-friendly way to let it in while keeping your TOTP on your phone. - -### Gemini CLI - -Gemini CLI sends a static `Authorization` header with every call, so the bearer is generated the same way as for ChatGPT. - -1. In the dashboard (`/app/tokens`), authenticate with your TOTP from your phone, then create a new token named `gemini-cli`. -2. Register the MCP server: - - ```bash - gemini mcp add beaconmcp \ - --url https:///mcp \ - --header "Authorization: Bearer " - ``` - -3. Replace the token via the same dashboard flow when it expires — do not bake TOTP generation into a shell alias or wrapper script. - -### Gemini API (google-genai SDK) - -For programmatic Gemini API usage, the BeaconMCP server is passed as a remote MCP tool. The SDK needs an `Authorization` header at call time; obtain the bearer interactively from the dashboard rather than letting the process derive TOTP codes on its own. - -1. Create a dashboard token as in the Gemini CLI section above. -2. Put the resulting bearer in your environment (e.g. `BEACONMCP_TOKEN`) or in your secrets manager. **Do not put the TOTP seed there.** -3. Reference it when invoking the model: - - ```python - import os - from google import genai - - token = os.environ["BEACONMCP_TOKEN"] - - client = genai.Client() - response = client.models.generate_content( - model="gemini-2.0-flash", - contents="List the VMs on pve1", - config={ - "tools": [ - { - "mcp_servers": [ - { - "url": "https:///mcp", - "headers": {"Authorization": f"Bearer {token}"}, - } - ] - } - ] - }, - ) - ``` - -4. When the bearer expires, re-issue it through the dashboard. Long-running services should rotate tokens on a schedule (an operator typing the TOTP) rather than embedding the seed. - -### Google Antigravity - -Antigravity reads MCP servers from `~/.gemini/antigravity/mcp_config.json` (macOS / Linux) or `%USERPROFILE%\.gemini\antigravity\mcp_config.json` (Windows). The top-level key is `mcpServers` and the HTTP URL field is **`serverUrl`** (not `url`): - -```json -{ - "mcpServers": { - "beaconmcp": { - "serverUrl": "https:///mcp", - "headers": { - "Authorization": "Bearer " - } - } - } -} -``` - -If the native HTTP transport misbehaves, fall back to the `mcp-remote` proxy: - -```json -{ - "mcpServers": { - "beaconmcp": { - "command": "npx", - "args": [ - "-y", "mcp-remote", - "https:///mcp", - "--header", "Authorization: Bearer " - ] - } - } -} -``` - -### Mistral - -**Le Chat** — *Settings → Connectors → Add custom MCP server*. Le Chat auto-detects the auth method supported by the server; pick Bearer, paste your dashboard-issued token, and set the URL to `https:///mcp`. Custom connectors are on Le Chat Pro / Enterprise plans. - -**Mistral Vibe** — Vibe reads its config from `./.vibe/config.toml` (per-project) or `~/.vibe/config.toml` (global). **TOML format**, not JSON: - -```toml -[[mcp_servers]] -name = "beaconmcp" -transport = "http" -url = "https:///mcp" -headers = { "Authorization" = "Bearer " } -``` - -`transport` accepts `"http"`, `"streamable-http"`, or `"stdio"`. Each server is its own `[[mcp_servers]]` array entry. - -### OpenCode - -OpenCode reads MCP servers from `opencode.json` (or `~/.config/opencode/opencode.json`): - -```json -{ - "mcp": { - "beaconmcp": { - "type": "remote", - "url": "https:///mcp", - "enabled": true, - "headers": { - "Authorization": "Bearer " - } - } - } -} -``` - -OpenCode also natively supports OAuth with Dynamic Client Registration. If you enable `allow_dynamic_registration` on the server, you can point OpenCode at a slug URL (`https:///mcp/c/` minted from `/app/connectors`) with `"oauth": true` and skip the bearer entirely. Tokens are stashed in `~/.local/share/opencode/mcp-auth.json`. - -### VS Code - -VS Code's built-in MCP client picks up servers from workspace or user settings: - -```json -// .vscode/mcp.json (or settings.json → "mcp.servers") -{ - "servers": { - "beaconmcp": { - "type": "http", - "url": "https:///mcp", - "headers": { - "Authorization": "Bearer " - } - } - } -} -``` - -Verify with *Command Palette → MCP: List Servers*. If you rely on GitHub Copilot's MCP integration, field names may differ — check the extension's readme. - -### Cursor - -Cursor reads MCP servers from `.cursor/mcp.json` (per-project) or `~/.cursor/mcp.json` (global): - -```json -{ - "mcpServers": { - "beaconmcp": { - "url": "https:///mcp", - "headers": { - "Authorization": "Bearer " - } - } - } -} -``` - -Reload the Cursor window after editing; the server shows up under *Settings → Cursor Settings → MCP Servers* with a live status indicator. - -### Other MCP-over-HTTP clients - -Any client that can send a bearer on `https:///mcp` works the same way: create a token from `/app/tokens` after typing your TOTP, configure the client to send `Authorization: Bearer `, revoke from the same page when you are done. If the client natively speaks OAuth 2.1 (like Claude), prefer that flow — it keeps the TOTP prompt at the authorization page instead of relying on a stored bearer. +Full setup for **ChatGPT**, **Gemini** (CLI / Web / Antigravity / API), **Mistral** (Le Chat + Vibe), **OpenCode**, **VS Code**, and **Cursor** lives in [docs/clients.md](docs/clients.md). The dashboard's `/app/tokens` page shows the same snippets interactively. --- diff --git a/docs/clients.md b/docs/clients.md new file mode 100644 index 0000000..a1c3afa --- /dev/null +++ b/docs/clients.md @@ -0,0 +1,269 @@ +# Client setup + +BeaconMCP exposes a single MCP endpoint (`https:///mcp`) and three +auth paths the dashboard helps you drive: + +- **OAuth 2.1 (pre-registered client)** — Claude only. See the main + [README](../README.md#connecting-clients) for that flow. +- **OAuth + Dynamic Client Registration** — ChatGPT, OpenCode. Requires + `server.allow_dynamic_registration: true` in `beaconmcp.yaml`. +- **Static bearer token** — Gemini (Web/CLI/Antigravity), Mistral, VS Code, + Cursor, any HTTP-only MCP client. + +> **Security note — always type the TOTP by hand from your phone.** +> The TOTP seed belongs in an authenticator app on a device you physically +> control. Do **not** generate codes programmatically with `oathtool` / +> `pyotp` / a shell alias, and do **not** store the raw seed in a `.env` or +> a secrets manager. Every flow below is designed so you read a 6-digit +> code off your phone. Unattended-service automation is covered separately +> in [totp-automation.md](totp-automation.md). + +The dashboard's [`/app/tokens`](../src/beaconmcp/dashboard/templates/tokens.html) +page presents the same information with copy-pasteable snippets per platform +— this document is the offline reference. + +--- + +## ChatGPT (OAuth + DCR) + +ChatGPT's Developer Mode connector only accepts **OAuth with Dynamic Client +Registration (RFC 7591)** — it will not take a pre-provisioned +`client_id` / `client_secret` nor a static bearer header. BeaconMCP supports +this by minting a one-off bootstrap URL from the dashboard: the URL lets +ChatGPT register a derived OAuth client tied to your account. 2FA is +preserved — at authorization time, you still type your own TOTP from your +phone; the derived client has no TOTP seed of its own. + +**One-time setup:** + +1. Enable the feature in `beaconmcp.yaml`: + ```yaml + server: + allow_dynamic_registration: true + ``` + Then restart `beaconmcp serve`. + +**To add ChatGPT (from your phone, no laptop needed):** + +1. In your mobile browser, open `https:///app/connectors`, sign in with your TOTP from your authenticator app. +2. Enter a label (e.g. `ChatGPT iPhone`), type your current TOTP, submit. You get a one-off URL of the shape `https:///mcp/c/`. The URL is **single-use** and expires in 15 min. +3. In the ChatGPT app: **Settings → Connectors → Add custom**. + - **Name:** BeaconMCP + - **URL:** paste the `/mcp/c/` URL. + - **Authentication:** OAuth. +4. ChatGPT fetches the OAuth metadata, POSTs to the slug-gated `/oauth/register/c/` — BeaconMCP consumes the slug atomically and mints a derived client scoped to your account. +5. ChatGPT then redirects you to BeaconMCP's authorization page. Type your TOTP from your phone. Token lifetime: 24 h. +6. From now on, ChatGPT auto-refreshes via the authorization code flow. Every 24 h it re-prompts for your TOTP — no re-registration, no new slug. + +**Revocation:** `https:///app/connectors` lists every active derived client. Revoke one and ChatGPT loses access immediately. Revoking your human account cascades to every derived client automatically. + +**Why not a static bearer?** ChatGPT's connector UI has no "Authorization header" field — only "No authentication" or "OAuth" — and the OAuth path strictly requires DCR. The slug-gated bootstrap is the narrow, audit-friendly way to let it in while keeping your TOTP on your phone. + +--- + +## OpenCode (OAuth + DCR) + +OpenCode natively handles OAuth with Dynamic Client Registration. Point it +at a slug URL minted from `/app/connectors` and it auto-registers on first +use. Requires `server.allow_dynamic_registration: true`. + +```json +// opencode.json (or ~/.config/opencode/opencode.json) +{ + "mcp": { + "beaconmcp": { + "type": "remote", + "url": "https:///mcp/c/", + "enabled": true, + "oauth": true + } + } +} +``` + +Auth tokens land in `~/.local/share/opencode/mcp-auth.json`. Connector +URLs are single-use and expire in 15 min — mint a fresh one per install. + +--- + +## Gemini + +### Gemini CLI + +Gemini CLI sends a static `Authorization` header with every call. Create a +token from `/app/tokens`, then: + +```bash +gemini mcp add beaconmcp \ + --url https:///mcp \ + --header "Authorization: Bearer " +``` + +Replace the token via the dashboard flow when it expires — do not bake TOTP +generation into a shell alias or wrapper script. + +### Gemini Web + +In Gemini's custom-MCP panel (*Tools → Extensions → Custom MCP*), paste +`https:///mcp` and set the Authorization header to +`Bearer `. + +### Gemini API (google-genai SDK) + +For programmatic Gemini API usage, BeaconMCP is passed as a remote MCP +tool. Obtain the bearer interactively from the dashboard rather than +letting the process derive TOTP codes on its own. + +```python +import os +from google import genai + +token = os.environ["BEACONMCP_TOKEN"] + +client = genai.Client() +response = client.models.generate_content( + model="gemini-2.0-flash", + contents="List the VMs on pve1", + config={ + "tools": [ + { + "mcp_servers": [ + { + "url": "https:///mcp", + "headers": {"Authorization": f"Bearer {token}"}, + } + ] + } + ] + }, +) +``` + +Long-running services should rotate tokens on a schedule (an operator +typing the TOTP) rather than embedding the seed. + +### Google Antigravity + +Antigravity reads MCP servers from `~/.gemini/antigravity/mcp_config.json` +(macOS / Linux) or `%USERPROFILE%\.gemini\antigravity\mcp_config.json` +(Windows). The top-level key is `mcpServers` and the HTTP URL field is +**`serverUrl`** (not `url`): + +```json +{ + "mcpServers": { + "beaconmcp": { + "serverUrl": "https:///mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +If the native HTTP transport misbehaves, fall back to the `mcp-remote` +proxy with command-based config: + +```json +{ + "mcpServers": { + "beaconmcp": { + "command": "npx", + "args": [ + "-y", "mcp-remote", + "https:///mcp", + "--header", "Authorization: Bearer " + ] + } + } +} +``` + +--- + +## Mistral + +### Le Chat + +*Settings → Connectors → Add custom MCP server*. Le Chat auto-detects the +auth method supported by the server; pick Bearer, paste your +dashboard-issued token, and set the URL to `https:///mcp`. +Custom connectors are on Le Chat Pro / Enterprise plans. + +### Mistral Vibe + +Vibe reads its config from `./.vibe/config.toml` (per-project) or +`~/.vibe/config.toml` (global). **TOML format**, not JSON: + +```toml +[[mcp_servers]] +name = "beaconmcp" +transport = "http" +url = "https:///mcp" +headers = { "Authorization" = "Bearer " } +``` + +`transport` accepts `"http"`, `"streamable-http"`, or `"stdio"`. Each +server is its own `[[mcp_servers]]` array entry. + +--- + +## VS Code + +VS Code's built-in MCP client picks up servers from workspace or user +settings: + +```json +// .vscode/mcp.json (or settings.json → "mcp.servers") +{ + "servers": { + "beaconmcp": { + "type": "http", + "url": "https:///mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +Verify with *Command Palette → MCP: List Servers*. If you rely on GitHub +Copilot's MCP integration, field names may differ — check the extension's +readme. + +--- + +## Cursor + +Cursor reads MCP servers from `.cursor/mcp.json` (per-project) or +`~/.cursor/mcp.json` (global): + +```json +{ + "mcpServers": { + "beaconmcp": { + "url": "https:///mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +Reload the Cursor window after editing; the server shows up under +*Settings → Cursor Settings → MCP Servers* with a live status indicator. + +--- + +## Other MCP-over-HTTP clients + +Any client that can send a bearer on `https:///mcp` works the +same way: create a token from `/app/tokens` after typing your TOTP, +configure the client to send `Authorization: Bearer `, revoke from +the same page when you are done. If the client natively speaks OAuth 2.1 +(like Claude) or OAuth + DCR (like ChatGPT / OpenCode), prefer those flows +— they keep the TOTP prompt at the authorization page instead of relying +on a stored bearer. diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index 26d56ce..2e1bde8 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -1453,11 +1453,21 @@ a.sidebar-link { cursor: pointer; } box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); } -/* Hide inline radios used by sub-tabs */ -.tab-panel > input[type=radio] { - position: absolute; - opacity: 0; - pointer-events: none; +/* Hide inline radios used by every tab layer (outer, inner, sub). The + visually-hidden pattern keeps them focusable for keyboard users but + invisible; prior version relied on opacity:0 alone which left some + browsers rendering the native control outline. */ +.views input[type=radio] { + position: absolute !important; + opacity: 0 !important; + pointer-events: none !important; + width: 1px !important; + height: 1px !important; + margin: 0 !important; + padding: 0 !important; + border: 0 !important; + clip: rect(0 0 0 0) !important; + overflow: hidden !important; } /* Card layout shared by method and platform panels */ diff --git a/src/beaconmcp/dashboard/templates/chat.html b/src/beaconmcp/dashboard/templates/chat.html index fb2688a..6d56dfc 100644 --- a/src/beaconmcp/dashboard/templates/chat.html +++ b/src/beaconmcp/dashboard/templates/chat.html @@ -50,8 +50,8 @@ diff --git a/src/beaconmcp/dashboard/templates/connectors.html b/src/beaconmcp/dashboard/templates/connectors.html index a669373..f44c2e4 100644 --- a/src/beaconmcp/dashboard/templates/connectors.html +++ b/src/beaconmcp/dashboard/templates/connectors.html @@ -1,20 +1,22 @@ {% extends "base.html" %} -{% block title %}ChatGPT connector · BeaconMCP{% endblock %} +{% block title %}OAuth connectors · BeaconMCP{% endblock %} {% block body_class %}tokens-page{% endblock %} {% block body %}
- Back to tokens + Back to API access
-

ChatGPT connector

+

OAuth connectors

- Signed in as {{ client_name }}. ChatGPT needs OAuth - Dynamic Client Registration; mint a one-off URL below, paste it into - ChatGPT's Add connector dialog, and complete the 2FA prompt - in-app. Each URL is single-use and expires after {{ slug_ttl_minutes }} min. + Signed in as {{ client_name }}. Clients that use + OAuth Dynamic Client Registration (RFC 7591) — ChatGPT, OpenCode, + and similar — can't accept a pre-provisioned client_id + or a bearer header. Mint a single-use bootstrap URL here, paste it + into the client, and complete the 2FA prompt in-app. Each URL is + single-use and expires after {{ slug_ttl_minutes }} min.

@@ -23,8 +25,10 @@

ChatGPT connector

Connector URL ready

- Paste this URL in ChatGPT (Settings → Connectors → Add custom). It can - be used once and dies in {{ slug_ttl_minutes }} min. + Paste this URL in your OAuth-DCR client (ChatGPT: Settings → + Connectors → Add custom; OpenCode: opencode.json + with "oauth": true). Single-use, dies in + {{ slug_ttl_minutes }} min.

{{ just_created.url }} @@ -46,7 +50,7 @@

New connector

@@ -115,7 +119,7 @@

Active connectors

{% endfor %} {% else %} -

No active ChatGPT connectors.

+

No active OAuth connectors.

{% endif %}
diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index 3d82860..2c5e5be 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -97,6 +97,7 @@

OAuth Dynamic Client Registration

ChatGPT + OpenCode

@@ -105,7 +106,7 @@

OAuth Dynamic Client Registration

a leaked client can't authorize anything without your phone.

- Open ChatGPT connectors + Open OAuth connectors @@ -125,7 +126,6 @@

Static bearer tokens

Mistral VS Code Cursor - OpenCode + REST @@ -432,30 +432,17 @@

Mistral

OpenCode

- Bearer or OAuth DCR + OAuth + DCR
-

Add the server to opencode.json (or ~/.config/opencode/opencode.json):

-
{
-  "mcp": {
-    "beaconmcp": {
-      "type": "remote",
-      "url": "{{ mcp_url }}",
-      "enabled": true,
-      "headers": {
-        "Authorization": "Bearer <token>"
-      }
-    }
-  }
-}
- {% if dcr_enabled %}

- OAuth alternative: OpenCode implements full - OAuth with Dynamic Client Registration. You can skip the bearer - by pointing it at a slug URL instead — it will auto-register - and prompt for your TOTP on first use. + OpenCode natively handles OAuth with Dynamic Client Registration. + Point it at a connector URL minted from the dashboard and it + auto-registers + prompts for your TOTP the first time.

-
{
+          {% if dcr_enabled %}
+
// opencode.json (or ~/.config/opencode/opencode.json)
+{
   "mcp": {
     "beaconmcp": {
       "type": "remote",
@@ -465,10 +452,19 @@ 

OpenCode

} } }
-

- Mint the slug from the ChatGPT - connectors page — same flow, different client. Tokens land - in ~/.local/share/opencode/mcp-auth.json. + + Mint a connector URL + + +

+ Auth tokens land in ~/.local/share/opencode/mcp-auth.json. + Connector URLs are single-use and expire in 15 min — mint a + fresh one per install. +

+ {% else %} +

+ Enable server.allow_dynamic_registration: true in + beaconmcp.yaml and restart the server to use OpenCode.

{% endif %} From e0e8755d53e1196f912eea980de9e76f7f96cb9e Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 12:59:36 +0200 Subject: [PATCH 065/155] Add Perplexity as an OAuth + DCR platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perplexity's custom-connector UI auto-discovers the auth method from the MCP server's .well-known metadata and runs DCR against a hosted registration_endpoint — exactly the path our slug flow already exposes. Dashboard gets a new platform tab, docs/clients.md gets a dedicated section, README lists it in the client index. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- docs/clients.md | 27 ++++++++++- src/beaconmcp/dashboard/static/app.css | 18 +++---- src/beaconmcp/dashboard/templates/tokens.html | 47 +++++++++++++++++++ 4 files changed, 83 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 583a9b4..7298b2e 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ On first use (and after each 24-hour token expiry) Claude redirects to the Beaco ### Other clients -Full setup for **ChatGPT**, **Gemini** (CLI / Web / Antigravity / API), **Mistral** (Le Chat + Vibe), **OpenCode**, **VS Code**, and **Cursor** lives in [docs/clients.md](docs/clients.md). The dashboard's `/app/tokens` page shows the same snippets interactively. +Full setup for **ChatGPT**, **Perplexity**, **Gemini** (CLI / Web / Antigravity / API), **Mistral** (Le Chat + Vibe), **OpenCode**, **VS Code**, and **Cursor** lives in [docs/clients.md](docs/clients.md). The dashboard's `/app/tokens` page shows the same snippets interactively. --- diff --git a/docs/clients.md b/docs/clients.md index a1c3afa..ef5a516 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -5,8 +5,8 @@ auth paths the dashboard helps you drive: - **OAuth 2.1 (pre-registered client)** — Claude only. See the main [README](../README.md#connecting-clients) for that flow. -- **OAuth + Dynamic Client Registration** — ChatGPT, OpenCode. Requires - `server.allow_dynamic_registration: true` in `beaconmcp.yaml`. +- **OAuth + Dynamic Client Registration** — ChatGPT, Perplexity, OpenCode. + Requires `server.allow_dynamic_registration: true` in `beaconmcp.yaml`. - **Static bearer token** — Gemini (Web/CLI/Antigravity), Mistral, VS Code, Cursor, any HTTP-only MCP client. @@ -61,6 +61,29 @@ phone; the derived client has no TOTP seed of its own. --- +## Perplexity (OAuth + DCR) + +Perplexity's custom connector auto-discovers the auth method from the MCP +server's `.well-known` metadata. With +`server.allow_dynamic_registration: true`, point it at a slug URL and +Perplexity completes DCR + the OAuth consent flow automatically. + +Requires Perplexity **Pro**, **Max**, or **Enterprise** — custom connectors +aren't on the free tier. + +1. Mint a connector URL from `https:///app/connectors` (single-use, 15 min TTL). +2. In Perplexity: **Settings → Connectors → Add custom**. Tick *"I understand custom connectors can introduce risks"*. +3. Fill in: + - **Name:** BeaconMCP + - **Description:** (optional) + - **MCP Server URL:** paste the `/mcp/c/` URL. +4. Perplexity auto-detects OAuth, runs DCR against the slug-gated `/oauth/register/c/`, then redirects you to BeaconMCP's authorization page. Type your TOTP from your phone. +5. Bearer lifetime: 24 h. Perplexity refreshes via the authorization code flow on its own — you re-type the TOTP each rotation. + +Revoke from `https:///app/connectors` like any other DCR-derived client. + +--- + ## OpenCode (OAuth + DCR) OpenCode natively handles OAuth with Dynamic Client Registration. Point it diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index 2e1bde8..d9b87a2 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -1364,14 +1364,15 @@ a.sidebar-link { cursor: pointer; } #tab-oauth:checked ~ .inner-tabs [for=tab-oauth], #tab-dcr:checked ~ .inner-tabs [for=tab-dcr], #tab-bearer:checked ~ .inner-tabs [for=tab-bearer], -#p-claude:checked ~ .inner-tabs [for=p-claude], -#p-chatgpt:checked ~ .inner-tabs [for=p-chatgpt], -#p-gemini:checked ~ .inner-tabs [for=p-gemini], -#p-mistral:checked ~ .inner-tabs [for=p-mistral], -#p-opencode:checked ~ .inner-tabs [for=p-opencode], -#p-vscode:checked ~ .inner-tabs [for=p-vscode], -#p-cursor:checked ~ .inner-tabs [for=p-cursor], -#p-other:checked ~ .inner-tabs [for=p-other] { +#p-claude:checked ~ .inner-tabs [for=p-claude], +#p-chatgpt:checked ~ .inner-tabs [for=p-chatgpt], +#p-perplexity:checked ~ .inner-tabs [for=p-perplexity], +#p-gemini:checked ~ .inner-tabs [for=p-gemini], +#p-mistral:checked ~ .inner-tabs [for=p-mistral], +#p-opencode:checked ~ .inner-tabs [for=p-opencode], +#p-vscode:checked ~ .inner-tabs [for=p-vscode], +#p-cursor:checked ~ .inner-tabs [for=p-cursor], +#p-other:checked ~ .inner-tabs [for=p-other] { color: var(--fg); border-bottom-color: var(--accent, #e57000); font-weight: 600; @@ -1380,6 +1381,7 @@ a.sidebar-link { cursor: pointer; } /* Replace old platform-tab selector set */ #p-claude:checked ~ .tab-panel[data-panel=claude], #p-chatgpt:checked ~ .tab-panel[data-panel=chatgpt], +#p-perplexity:checked ~ .tab-panel[data-panel=perplexity], #p-gemini:checked ~ .tab-panel[data-panel=gemini], #p-mistral:checked ~ .tab-panel[data-panel=mistral], #p-opencode:checked ~ .tab-panel[data-panel=opencode], diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index 2c5e5be..6ba3e0e 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -98,6 +98,7 @@

OAuth Dynamic Client Registration

ChatGPT OpenCode + Perplexity

@@ -221,6 +222,7 @@

Active tokens

{% if dcr_enabled %}{% endif %} + @@ -230,6 +232,7 @@

Active tokens

{% endif %} + {# ---- Perplexity ---- #} +
+
+
+
+

Perplexity

+ OAuth + DCR +
+
+

+ Perplexity's custom connector auto-discovers the auth method + from the MCP server's .well-known metadata. Paste + a slug URL and Perplexity handles DCR, then runs you through + the OAuth consent flow (where you type your TOTP). +

+ {% if dcr_enabled %} +
    +
  1. Mint a connector URL from OAuth connectors (single-use, 15 min).
  2. +
  3. In Perplexity: Settings → Connectors → Add custom. Tick "I understand custom connectors can introduce risks".
  4. +
  5. Fill in: +
      +
    • Name: BeaconMCP
    • +
    • Description: (optional)
    • +
    • MCP Server URL: paste the /mcp/c/<slug> URL
    • +
    +
  6. +
  7. Perplexity detects OAuth, runs DCR, then redirects you to BeaconMCP's authorization page. Type your TOTP.
  8. +
+

+ Requires Perplexity Pro, Max, or Enterprise — custom connectors aren't on the free tier. +

+ + Mint a connector URL + + + {% else %} +

+ Enable server.allow_dynamic_registration: true in + beaconmcp.yaml and restart the server to use Perplexity. +

+ {% endif %} +
+
+ {# ---- Gemini (sub-tabs: Web, CLI, Antigravity) ---- #}
From fa92205a5959374c963266a3f2654151df99168c Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 13:05:11 +0200 Subject: [PATCH 066/155] Mistral Le Chat: correct menu path, prefer OAuth DCR, document CORS allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Le Chat menu path is Intelligence -> Connecteurs -> Ajouter un connecteur -> Connecteur MCP personnalisé (not Settings -> Connectors). - Le Chat supports OAuth 2.1 via auto-detection, so it joins ChatGPT / Perplexity / OpenCode on the DCR path when allow_dynamic_registration is on. Keeps bearer as a fallback. - Every browser MCP client needs its origin in server.allowed_origins — made that explicit in the tokens page hero, docs/clients.md, the README, and beaconmcp.yaml.example (which now lists chatgpt.com, chat.mistral.ai, perplexity.ai alongside claude.ai and gemini). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 + beaconmcp.yaml.example | 8 +++- docs/clients.md | 40 +++++++++++++++++-- src/beaconmcp/dashboard/static/app.css | 14 +++++++ src/beaconmcp/dashboard/templates/tokens.html | 29 ++++++++++++-- 5 files changed, 84 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7298b2e..d6d404c 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,8 @@ Claude performs the full OAuth 2.1 flow against BeaconMCP, so there is no long-l On first use (and after each 24-hour token expiry) Claude redirects to the BeaconMCP authorization page. Read the current 6-digit code from your authenticator app and type it in. Claude never holds the TOTP seed, and a leaked session cannot mint a new token without a fresh code from your phone. +**Important — CORS allowlist.** Every browser-based MCP client (Claude Web, ChatGPT, Le Chat, Perplexity, Gemini Web) sends a CORS preflight before it can reach `/mcp`. Add each client's origin to `server.allowed_origins` in `beaconmcp.yaml` (see [`beaconmcp.yaml.example`](beaconmcp.yaml.example)). Desktop and CLI clients don't need this. + ### Other clients Full setup for **ChatGPT**, **Perplexity**, **Gemini** (CLI / Web / Antigravity / API), **Mistral** (Le Chat + Vibe), **OpenCode**, **VS Code**, and **Cursor** lives in [docs/clients.md](docs/clients.md). The dashboard's `/app/tokens` page shows the same snippets interactively. diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index 04ceebe..053e8a3 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -20,10 +20,16 @@ server: - "127.0.0.1:*" - "localhost:*" - "[::1]:*" - # CORS origin allowlist. + # CORS origin allowlist. Browser-based MCP clients send a CORS preflight + # before calling /mcp; their origin MUST be listed here. Desktop / CLI + # clients (Claude Desktop, Gemini CLI, Cursor, VS Code, Mistral Vibe, + # OpenCode, …) are NOT browser-based and don't need an entry. allowed_origins: - https://claude.ai + - https://chatgpt.com - https://chat.openai.com + - https://chat.mistral.ai + - https://www.perplexity.ai - https://gemini.google.com clients_file: /opt/beaconmcp/clients.json session_key: ${BEACONMCP_SESSION_KEY} # optional, generated if omitted diff --git a/docs/clients.md b/docs/clients.md index ef5a516..41130be 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -18,6 +18,26 @@ auth paths the dashboard helps you drive: > code off your phone. Unattended-service automation is covered separately > in [totp-automation.md](totp-automation.md). +> **CORS allowlist — required for every web client.** +> Browser-based MCP clients (Claude Web, ChatGPT Web, Le Chat, Perplexity, +> Gemini Web) fire a CORS preflight before they can reach `/mcp`. If the +> request origin is missing from `server.allowed_origins` in +> `beaconmcp.yaml`, every call fails silently with a browser console +> error. Add each web client's origin explicitly: +> +> ```yaml +> server: +> allowed_origins: +> - https://claude.ai +> - https://chatgpt.com +> - https://chat.mistral.ai +> - https://www.perplexity.ai +> - https://gemini.google.com +> ``` +> +> Desktop / CLI clients (Claude Desktop, Gemini CLI, Cursor, VS Code, +> Mistral Vibe, OpenCode) are not browser-based and don't need an entry. + The dashboard's [`/app/tokens`](../src/beaconmcp/dashboard/templates/tokens.html) page presents the same information with copy-pasteable snippets per platform — this document is the offline reference. @@ -209,10 +229,22 @@ proxy with command-based config: ### Le Chat -*Settings → Connectors → Add custom MCP server*. Le Chat auto-detects the -auth method supported by the server; pick Bearer, paste your -dashboard-issued token, and set the URL to `https:///mcp`. -Custom connectors are on Le Chat Pro / Enterprise plans. +*Intelligence → Connecteurs → Ajouter un connecteur → Connecteur MCP +personnalisé*. Le Chat **auto-detects** the auth method from the server's +`.well-known` metadata. Two supported paths: + +- **OAuth 2.1 (recommended)** — enable `allow_dynamic_registration: true` + on the server and paste a slug URL minted from `/app/connectors` + (`https:///mcp/c/`). Le Chat runs DCR + the OAuth + consent flow; you type your TOTP on the authorization page. +- **Bearer** — paste `https:///mcp` and a token from + `/app/tokens`. + +Custom connectors are on Le Chat Pro / Enterprise. The free tier may hide +the panel entirely. + +**CORS:** add `https://chat.mistral.ai` to `server.allowed_origins` +(see the allowlist note at the top of this file). ### Mistral Vibe diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index d9b87a2..bad2426 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -1270,6 +1270,20 @@ a.sidebar-link { cursor: pointer; } border: 1px solid var(--border); border-radius: var(--radius-sm); } +.mcp-endpoint-hero-note { + margin: 0.85rem 0 0; + font-size: 0.85rem; + line-height: 1.5; + color: var(--fg-muted); +} +.mcp-endpoint-hero-note strong { color: var(--fg); } +.mcp-endpoint-hero-note code { + font-size: 0.82rem; + padding: 0.05rem 0.3rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; +} /* Outer view switch (Method / Platform) */ .views { margin-top: 1.5rem; position: relative; } diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index 6ba3e0e..430a278 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -27,6 +27,16 @@

API access

+

+ Heads up — browser clients need their origin allowlisted. + Web UIs (Claude, ChatGPT, Le Chat, Perplexity, Gemini Web) send CORS + preflights before they can reach /mcp. Add their origin + to server.allowed_origins in beaconmcp.yaml + — e.g. https://claude.ai, https://chatgpt.com, + https://chat.mistral.ai, https://www.perplexity.ai, + https://gemini.google.com. Desktop / CLI clients don't + need this. +

@@ -97,8 +107,9 @@

OAuth Dynamic Client Registration

ChatGPT - OpenCode + Mistral Perplexity + OpenCode

@@ -440,6 +451,7 @@

Gemini

Mistral

+ OAuth + DCR Bearer
@@ -450,12 +462,21 @@

Mistral

-

In Le Chat: Settings → Connectors → Add custom MCP server.

+

In Le Chat: Intelligence → Connecteurs → Ajouter un connecteur → Connecteur MCP personnalisé. Le Chat auto-detects the auth method from the server's .well-known metadata.

+ {% if dcr_enabled %} +

Recommended — OAuth 2.1 (with DCR):

+
    +
  1. Mint a connector URL from OAuth connectors.
  2. +
  3. Paste the /mcp/c/<slug> URL as the connection server.
  4. +
  5. Le Chat detects OAuth, runs DCR, and redirects you to BeaconMCP's authorization page — type your TOTP.
  6. +
+

Alternative — Bearer:

+ {% endif %}
  • URL: {{ mcp_url }}
  • -
  • Authentication: Bearer token → paste <token>
  • +
  • Authentication: Bearer token → paste a token minted from the bearer tab.
-

Custom connectors are on Le Chat Pro / Enterprise plans; on the free tier the panel may be hidden.

+

Custom connectors are on Le Chat Pro / Enterprise plans; on the free tier the panel may be hidden. Add https://chat.mistral.ai to server.allowed_origins in beaconmcp.yaml so the browser's CORS preflight succeeds.

Mistral Vibe reads MCP servers from ./.vibe/config.toml (per-project) or ~/.vibe/config.toml (global). The config is TOML, not JSON.

From 498e94e3395888591dadff3768e93c517baf1110 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 13:14:02 +0200 Subject: [PATCH 067/155] Mistral Le Chat: pure OAuth 2.1 on /mcp; expandable CORS note; flag Vibe as unverified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Le Chat: drop the DCR/Bearer split. The user-visible flow is simply OAuth 2.1 on the bare /mcp URL, matching Claude's behaviour. Moved the Mistral logo to the OAuth pre-registered card accordingly. - Hero endpoint card: turn the CORS allowlist note into a
accordion with a cleaner explainer and YAML snippet. The tight, badge-cluttered layout was pushing real signal off-screen. - Mistral Vibe: mark the bearer snippet as unverified — the user has confirmed Le Chat works, Vibe hasn't been tested against a live instance yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/clients.md | 30 ++++--- src/beaconmcp/dashboard/static/app.css | 59 ++++++++++++++ src/beaconmcp/dashboard/templates/tokens.html | 79 ++++++++++++------- 3 files changed, 127 insertions(+), 41 deletions(-) diff --git a/docs/clients.md b/docs/clients.md index 41130be..4196341 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -227,27 +227,33 @@ proxy with command-based config: ## Mistral -### Le Chat +### Le Chat (OAuth 2.1) -*Intelligence → Connecteurs → Ajouter un connecteur → Connecteur MCP -personnalisé*. Le Chat **auto-detects** the auth method from the server's -`.well-known` metadata. Two supported paths: +Le Chat speaks OAuth 2.1 natively. Same flow as Claude — point it at +the bare `/mcp` URL and it handles the rest. -- **OAuth 2.1 (recommended)** — enable `allow_dynamic_registration: true` - on the server and paste a slug URL minted from `/app/connectors` - (`https:///mcp/c/`). Le Chat runs DCR + the OAuth - consent flow; you type your TOTP on the authorization page. -- **Bearer** — paste `https:///mcp` and a token from - `/app/tokens`. +1. In Le Chat: *Intelligence → Connecteurs → Ajouter un connecteur → Connecteur MCP personnalisé*. +2. Fill in: + - **Name:** BeaconMCP + - **Description:** (optional) + - **MCP Server URL:** `https:///mcp` +3. Validate. Le Chat discovers the OAuth metadata and redirects you to + BeaconMCP's authorization page — type your TOTP from your phone. + Token lifetime: 24 h; Le Chat refreshes via the authorization code + flow on its own. -Custom connectors are on Le Chat Pro / Enterprise. The free tier may hide -the panel entirely. +Custom connectors are on Le Chat Pro / Enterprise; the free tier may +hide the panel. **CORS:** add `https://chat.mistral.ai` to `server.allowed_origins` (see the allowlist note at the top of this file). ### Mistral Vibe +> ⚠ Unverified — Vibe's bearer support hasn't been tested against a +> live BeaconMCP instance. If it doesn't work out of the box, check the +> latest Vibe docs (the schema has been iterating fast) and report back. + Vibe reads its config from `./.vibe/config.toml` (per-project) or `~/.vibe/config.toml` (global). **TOML format**, not JSON: diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index bad2426..c43f74c 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -1285,6 +1285,65 @@ a.sidebar-link { cursor: pointer; } border-radius: 4px; } +/* Expandable info panel inside the hero card (CORS allowlist explainer) */ +.hero-details { + margin-top: 0.85rem; + border-top: 1px dashed var(--border); + padding-top: 0.6rem; +} +.hero-details > summary { + display: inline-flex; + align-items: center; + gap: 0.45rem; + cursor: pointer; + list-style: none; + font-size: 0.85rem; + font-weight: 500; + color: var(--fg-muted); + padding: 0.2rem 0.1rem; + user-select: none; + transition: color 120ms ease; +} +.hero-details > summary:hover { color: var(--fg); } +.hero-details > summary::-webkit-details-marker { display: none; } +.hero-details > summary::after { + content: "▸"; + font-size: 0.75rem; + margin-left: 0.25rem; + transition: transform 120ms ease; + display: inline-block; +} +.hero-details[open] > summary::after { transform: rotate(90deg); } +.hero-details-body { + padding: 0.5rem 0.1rem 0.25rem; + font-size: 0.88rem; + line-height: 1.55; + color: var(--fg-muted); +} +.hero-details-body p { margin: 0 0 0.75rem; } +.hero-details-body strong { color: var(--fg); font-weight: 600; } +.hero-details-body code { + font-size: 0.82rem; + padding: 0.05rem 0.3rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; +} +.hero-details-body pre { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.6rem 0.75rem; + overflow-x: auto; + font-size: 0.82rem; + margin: 0.25rem 0 0.75rem; +} +.hero-details-body pre code { + background: none; + border: none; + padding: 0; +} + /* Outer view switch (Method / Platform) */ .views { margin-top: 1.5rem; position: relative; } .views > input[type=radio] { diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index 430a278..bd72ad0 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -27,16 +27,35 @@

API access

-

- Heads up — browser clients need their origin allowlisted. - Web UIs (Claude, ChatGPT, Le Chat, Perplexity, Gemini Web) send CORS - preflights before they can reach /mcp. Add their origin - to server.allowed_origins in beaconmcp.yaml - — e.g. https://claude.ai, https://chatgpt.com, - https://chat.mistral.ai, https://www.perplexity.ai, - https://gemini.google.com. Desktop / CLI clients don't - need this. -

+
+ + + Browser clients need their origin allowlisted + +
+

+ A browser calling a different host has to pass a CORS preflight. + Web UIs (Claude, ChatGPT, + Le Chat, Perplexity, + Gemini Web) all do this before they can + reach /mcp. If the origin isn't listed, every + request fails silently in the browser console. +

+

Add the origins you plan to use to beaconmcp.yaml:

+
server:
+  allowed_origins:
+    - https://claude.ai
+    - https://chatgpt.com
+    - https://chat.mistral.ai
+    - https://www.perplexity.ai
+    - https://gemini.google.com
+

+ Desktop apps and CLI clients (Claude Desktop, Gemini CLI, + Cursor, VS Code, Mistral Vibe, OpenCode) don't run in a browser + and don't need an entry here. +

+
+
@@ -81,6 +100,7 @@

OAuth 2.1 with a pre-registered client

Claude + Mistral
    @@ -107,7 +127,6 @@

    OAuth Dynamic Client Registration

    ChatGPT - Mistral Perplexity OpenCode
    @@ -451,8 +470,6 @@

    Gemini

    Mistral

    - OAuth + DCR - Bearer
    @@ -462,24 +479,29 @@

    Mistral

    -

    In Le Chat: Intelligence → Connecteurs → Ajouter un connecteur → Connecteur MCP personnalisé. Le Chat auto-detects the auth method from the server's .well-known metadata.

    - {% if dcr_enabled %} -

    Recommended — OAuth 2.1 (with DCR):

    +

    OAuth 2.1

    +

    + Le Chat speaks OAuth 2.1 natively. In the app: + Intelligence → Connecteurs → Ajouter un connecteur → Connecteur MCP personnalisé. +

      -
    1. Mint a connector URL from OAuth connectors.
    2. -
    3. Paste the /mcp/c/<slug> URL as the connection server.
    4. -
    5. Le Chat detects OAuth, runs DCR, and redirects you to BeaconMCP's authorization page — type your TOTP.
    6. +
    7. Name: BeaconMCP
    8. +
    9. MCP Server URL: {{ mcp_url }}
    10. +
    11. Validate. Le Chat discovers the OAuth metadata and redirects you to BeaconMCP's authorization page — type your TOTP from your phone. Token lifetime: 24 h.
    -

    Alternative — Bearer:

    - {% endif %} -
      -
    • URL: {{ mcp_url }}
    • -
    • Authentication: Bearer token → paste a token minted from the bearer tab.
    • -
    -

    Custom connectors are on Le Chat Pro / Enterprise plans; on the free tier the panel may be hidden. Add https://chat.mistral.ai to server.allowed_origins in beaconmcp.yaml so the browser's CORS preflight succeeds.

    +

    + Custom connectors are on Le Chat Pro / Enterprise plans; on the free tier the panel may be hidden. + Add https://chat.mistral.ai to server.allowed_origins so the browser CORS preflight succeeds. +

    -

    Mistral Vibe reads MCP servers from ./.vibe/config.toml (per-project) or ~/.vibe/config.toml (global). The config is TOML, not JSON.

    +

    Bearer (unverified)

    +

    + Vibe's bearer path hasn't been tested against a live + BeaconMCP. If the snippet below fails, check the latest + Vibe docs — the schema has been iterating fast. +

    +

    Vibe reads ./.vibe/config.toml (per-project) or ~/.vibe/config.toml (global). TOML format, not JSON.

    [[mcp_servers]]
     name = "beaconmcp"
     transport = "http"
    @@ -487,8 +509,7 @@ 

    Mistral

    headers = { "Authorization" = "Bearer <token>" }

    transport accepts "http", - "streamable-http", or "stdio". Each - server is its own [[mcp_servers]] array entry. + "streamable-http", or "stdio".

    From 021edd320ad6650de1b827ccc38545119b2eceff Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 13:35:01 +0200 Subject: [PATCH 068/155] Refine client coverage: add Codex, drop Perplexity, promote OAuth DCR everywhere - ChatGPT: add Codex CLI as a third sub-tab with codex mcp login flow, config.toml snippet, and mcp_oauth_callback_url note for remote devboxes. - Perplexity: remove the platform tab. Perplexity's CTO announced in March 2026 that the company is deprecating MCP in favor of REST + a code-mode execution model. Docs note the deprecation instead of a broken integration guide. - Gemini: demote Web to 'not supported yet' (consumer web/mobile/macOS have no custom-MCP panel). CLI now surfaces OAuth + DCR as the recommended path (settings.json with httpUrl + /mcp auth). Antigravity keeps its bearer config. - Cursor & VS Code: both handle OAuth 2.1 natively with DCR. Promote OAuth + DCR as the primary method; keep bearer as a fallback. Cursor surfaces a Connect button; VS Code uses its native Authentication Provider + OS keychain. - DCR logo row on the OAuth+DCR card now lists every supported client. docs/clients.md mirrors the dashboard changes; README client index picks up Codex, drops Perplexity. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- docs/clients.md | 143 ++++++++++---- src/beaconmcp/dashboard/static/app.css | 8 +- src/beaconmcp/dashboard/templates/tokens.html | 174 +++++++++++------- 4 files changed, 217 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index d6d404c..7465a92 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ On first use (and after each 24-hour token expiry) Claude redirects to the Beaco ### Other clients -Full setup for **ChatGPT**, **Perplexity**, **Gemini** (CLI / Web / Antigravity / API), **Mistral** (Le Chat + Vibe), **OpenCode**, **VS Code**, and **Cursor** lives in [docs/clients.md](docs/clients.md). The dashboard's `/app/tokens` page shows the same snippets interactively. +Full setup for **ChatGPT** (Web / Mobile / Codex CLI), **Gemini** (CLI / Antigravity / API), **Mistral** (Le Chat + Vibe), **OpenCode**, **VS Code**, and **Cursor** lives in [docs/clients.md](docs/clients.md). The dashboard's `/app/tokens` page shows the same snippets interactively. Perplexity is deprecating MCP (March 2026) and is no longer supported. --- diff --git a/docs/clients.md b/docs/clients.md index 4196341..1229255 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -5,8 +5,9 @@ auth paths the dashboard helps you drive: - **OAuth 2.1 (pre-registered client)** — Claude only. See the main [README](../README.md#connecting-clients) for that flow. -- **OAuth + Dynamic Client Registration** — ChatGPT, Perplexity, OpenCode. - Requires `server.allow_dynamic_registration: true` in `beaconmcp.yaml`. +- **OAuth + Dynamic Client Registration** — ChatGPT (Web / Mobile / Codex), + Gemini CLI, OpenCode, Cursor, VS Code. Requires + `server.allow_dynamic_registration: true` in `beaconmcp.yaml`. - **Static bearer token** — Gemini (Web/CLI/Antigravity), Mistral, VS Code, Cursor, any HTTP-only MCP client. @@ -81,26 +82,33 @@ phone; the derived client has no TOTP seed of its own. --- -## Perplexity (OAuth + DCR) +## Perplexity (not supported) -Perplexity's custom connector auto-discovers the auth method from the MCP -server's `.well-known` metadata. With -`server.allow_dynamic_registration: true`, point it at a slug URL and -Perplexity completes DCR + the OAuth consent flow automatically. +> ⚠ Perplexity is deprecating MCP. In March 2026, Perplexity's CTO +> announced that the company is moving to direct REST APIs and a +> "Code Mode" execution model, citing OAuth / DCR friction and +> context-window waste from MCP tool schemas. No setup instructions +> here — there is no working integration to document. -Requires Perplexity **Pro**, **Max**, or **Enterprise** — custom connectors -aren't on the free tier. +## ChatGPT Codex (OAuth + DCR, terminal/IDE) -1. Mint a connector URL from `https:///app/connectors` (single-use, 15 min TTL). -2. In Perplexity: **Settings → Connectors → Add custom**. Tick *"I understand custom connectors can introduce risks"*. -3. Fill in: - - **Name:** BeaconMCP - - **Description:** (optional) - - **MCP Server URL:** paste the `/mcp/c/` URL. -4. Perplexity auto-detects OAuth, runs DCR against the slug-gated `/oauth/register/c/`, then redirects you to BeaconMCP's authorization page. Type your TOTP from your phone. -5. Bearer lifetime: 24 h. Perplexity refreshes via the authorization code flow on its own — you re-type the TOTP each rotation. +Codex is OpenAI's terminal/IDE MCP client. It speaks full OAuth 2.1 and +catches the redirect on an ephemeral local port. + +1. Add BeaconMCP to Codex's `config.toml`: + ```toml + [mcp_servers.beaconmcp] + url = "https:///mcp/c/" + ``` +2. Run `codex mcp login beaconmcp`. Codex binds a loopback listener and + opens your browser on BeaconMCP's authorization page. +3. Type your TOTP. Codex caches the token locally and refreshes on its + own. -Revoke from `https:///app/connectors` like any other DCR-derived client. +**Remote dev environments** (Codespaces, SSH container): set +`mcp_oauth_callback_url` in `config.toml` to your ingress URL so the +redirect hits the right host instead of localhost. A matching port can +be pinned via `mcp_oauth_callback_port`. --- @@ -131,10 +139,26 @@ URLs are single-use and expire in 15 min — mint a fresh one per install. ## Gemini -### Gemini CLI +### Gemini CLI (OAuth + DCR, recommended) -Gemini CLI sends a static `Authorization` header with every call. Create a -token from `/app/tokens`, then: +Gemini CLI speaks full OAuth 2.1 with Dynamic Client Registration — drop +a remote URL in `settings.json`, run `/mcp auth `, and the CLI +opens a browser to BeaconMCP's authorization page (TOTP prompt). + +```json +// ~/.gemini/settings.json +{ + "mcpServers": { + "beaconmcp": { + "httpUrl": "https:///mcp/c/" + } + } +} +``` + +Then: `/mcp auth beaconmcp`. Mint the slug from `/app/connectors`. + +Bearer header is also supported if you prefer it: ```bash gemini mcp add beaconmcp \ @@ -142,14 +166,12 @@ gemini mcp add beaconmcp \ --header "Authorization: Bearer " ``` -Replace the token via the dashboard flow when it expires — do not bake TOTP -generation into a shell alias or wrapper script. - -### Gemini Web +### Gemini Web / Mobile / macOS native app (not supported yet) -In Gemini's custom-MCP panel (*Tools → Extensions → Custom MCP*), paste -`https:///mcp` and set the Authorization header to -`Bearer `. +Gemini's consumer web UI (gemini.google.com), the iOS / Android apps, and +the new macOS native app do **not** expose a custom-MCP connector today. +The only Gemini surfaces that can reach BeaconMCP are **Gemini CLI** and +**Antigravity**. ### Gemini API (google-genai SDK) @@ -270,13 +292,35 @@ server is its own `[[mcp_servers]]` array entry. --- -## VS Code +## VS Code (OAuth + DCR) -VS Code's built-in MCP client picks up servers from workspace or user -settings: +VS Code routes MCP authentication through its native Authentication +Provider system — the same flow used for your GitHub / Microsoft Entra +logins. It reads `WWW-Authenticate`, shows a toast to Allow, catches +the redirect on the `vscode://` (or `vscode-insiders://`) OS URI scheme, +and stores the resulting token in your OS keychain. + +**Recommended — OAuth + DCR:** ```json // .vscode/mcp.json (or settings.json → "mcp.servers") +{ + "servers": { + "beaconmcp": { + "type": "http", + "url": "https:///mcp/c/" + } + } +} +``` + +Mint the slug from `/app/connectors`. VS Code prompts you to sign in the +first time the server is used; revoke anytime from the Accounts menu +(profile icon, bottom left). + +**Alternative — Bearer:** + +```json { "servers": { "beaconmcp": { @@ -290,16 +334,35 @@ settings: } ``` -Verify with *Command Palette → MCP: List Servers*. If you rely on GitHub -Copilot's MCP integration, field names may differ — check the extension's -readme. +Verify with *Command Palette → MCP: List Servers*. --- -## Cursor +## Cursor (OAuth + DCR) + +Cursor is a first-class OAuth 2.1 client since v1.0. Drop a remote URL +in `mcp.json`, Cursor surfaces a blue *Connect* button in +*Settings → Tools & MCP* when it detects the 401, pops a browser for +consent (PKCE + DCR are automatic), and catches the redirect via the +`cursor://` OS scheme (or a loopback fallback). -Cursor reads MCP servers from `.cursor/mcp.json` (per-project) or -`~/.cursor/mcp.json` (global): +**Recommended — OAuth + DCR:** + +```json +// ~/.cursor/mcp.json (global) or .cursor/mcp.json (per project) +{ + "mcpServers": { + "beaconmcp": { + "url": "https:///mcp/c/" + } + } +} +``` + +Mint the slug from `/app/connectors`. Click *Connect* in Cursor's +settings when it surfaces the "Needs authentication" state. + +**Alternative — Bearer:** ```json { @@ -307,15 +370,15 @@ Cursor reads MCP servers from `.cursor/mcp.json` (per-project) or "beaconmcp": { "url": "https:///mcp", "headers": { - "Authorization": "Bearer " + "Authorization": "Bearer ${env:BEACONMCP_TOKEN}" } } } } ``` -Reload the Cursor window after editing; the server shows up under -*Settings → Cursor Settings → MCP Servers* with a live status indicator. +Cursor expands `${env:VAR}` natively so the bearer can live in your +shell environment rather than in the repo. --- diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index c43f74c..acf2b38 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -1439,7 +1439,6 @@ a.sidebar-link { cursor: pointer; } #tab-bearer:checked ~ .inner-tabs [for=tab-bearer], #p-claude:checked ~ .inner-tabs [for=p-claude], #p-chatgpt:checked ~ .inner-tabs [for=p-chatgpt], -#p-perplexity:checked ~ .inner-tabs [for=p-perplexity], #p-gemini:checked ~ .inner-tabs [for=p-gemini], #p-mistral:checked ~ .inner-tabs [for=p-mistral], #p-opencode:checked ~ .inner-tabs [for=p-opencode], @@ -1454,7 +1453,6 @@ a.sidebar-link { cursor: pointer; } /* Replace old platform-tab selector set */ #p-claude:checked ~ .tab-panel[data-panel=claude], #p-chatgpt:checked ~ .tab-panel[data-panel=chatgpt], -#p-perplexity:checked ~ .tab-panel[data-panel=perplexity], #p-gemini:checked ~ .tab-panel[data-panel=gemini], #p-mistral:checked ~ .tab-panel[data-panel=mistral], #p-opencode:checked ~ .tab-panel[data-panel=opencode], @@ -1498,9 +1496,11 @@ a.sidebar-link { cursor: pointer; } /* ChatGPT variants */ #gv-web:checked ~ .sub-panel[data-sub=chatgpt-web], -#gv-mobile:checked ~ .sub-panel[data-sub=chatgpt-mobile] { display: block; } +#gv-mobile:checked ~ .sub-panel[data-sub=chatgpt-mobile], +#gv-codex:checked ~ .sub-panel[data-sub=chatgpt-codex] { display: block; } #gv-web:checked ~ .sub-tabs [for=gv-web], -#gv-mobile:checked ~ .sub-tabs [for=gv-mobile] { +#gv-mobile:checked ~ .sub-tabs [for=gv-mobile], +#gv-codex:checked ~ .sub-tabs [for=gv-codex] { background: var(--bg); color: var(--fg); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index bd72ad0..cc07ac7 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -127,8 +127,11 @@

    OAuth Dynamic Client Registration

    ChatGPT - Perplexity + Codex + Gemini CLI OpenCode + Cursor + VS Code

    @@ -252,7 +255,6 @@

    Active tokens

    {% if dcr_enabled %}{% endif %} - @@ -262,7 +264,6 @@

    Active tokens

    {% endif %} - {# ---- Perplexity ---- #} -
    -
    -
    -
    -

    Perplexity

    - OAuth + DCR -
    -
    -

    - Perplexity's custom connector auto-discovers the auth method - from the MCP server's .well-known metadata. Paste - a slug URL and Perplexity handles DCR, then runs you through - the OAuth consent flow (where you type your TOTP). -

    - {% if dcr_enabled %} -
      -
    1. Mint a connector URL from OAuth connectors (single-use, 15 min).
    2. -
    3. In Perplexity: Settings → Connectors → Add custom. Tick "I understand custom connectors can introduce risks".
    4. -
    5. Fill in: -
        -
      • Name: BeaconMCP
      • -
      • Description: (optional)
      • -
      • MCP Server URL: paste the /mcp/c/<slug> URL
      • -
      -
    6. -
    7. Perplexity detects OAuth, runs DCR, then redirects you to BeaconMCP's authorization page. Type your TOTP.
    8. -
    -

    - Requires Perplexity Pro, Max, or Enterprise — custom connectors aren't on the free tier. -

    - - Mint a connector URL - - - {% else %} -

    - Enable server.allow_dynamic_registration: true in - beaconmcp.yaml and restart the server to use Perplexity. -

    - {% endif %} -
    -
    - - {# ---- Gemini (sub-tabs: Web, CLI, Antigravity) ---- #} + {# ---- Gemini (sub-tabs: CLI, Antigravity, Web) ---- #}

    Gemini

    - Bearer
    -

    Every Gemini surface takes a static bearer header. One token covers all three.

    - +
    +

    OAuth + DCR Bearer

    +

    + Gemini CLI speaks full OAuth 2.1 with DCR — drop a remote + URL in settings.json and run + /mcp auth beaconmcp to trigger the browser + flow (TOTP prompt in your default browser). +

    + {% if dcr_enabled %} +

    Recommended — OAuth + DCR:

    +
    // ~/.gemini/settings.json
    +{
    +  "mcpServers": {
    +    "beaconmcp": {
    +      "httpUrl": "https://<your-host>/mcp/c/<slug>"
    +    }
    +  }
    +}
    +

    Then in the CLI: /mcp auth beaconmcp. Mint the slug from OAuth connectors.

    +

    Alternative — Bearer:

    + {% endif %}
    gemini mcp add beaconmcp \
       --url {{ mcp_url }} \
       --header "Authorization: Bearer <token>"
    -

    Mint the bearer from the bearer tab above, or the By method view.

    -
    -
    -

    gemini.google.com → Tools → Extensions → Custom MCP. Paste {{ mcp_url }} and set the Authorization header to Bearer <token>.

    +

    Bearer

    Edit ~/.gemini/antigravity/mcp_config.json (macOS / Linux) or %USERPROFILE%\.gemini\antigravity\mcp_config.json (Windows):

    {
       "mcpServers": {
    @@ -461,6 +456,13 @@ 

    Gemini

    back to npx mcp-remote with --header.

    +
    +

    + Gemini Web (gemini.google.com), the mobile apps, and the macOS + native app do not expose a custom-MCP connector + yet. Only Gemini CLI and Antigravity can reach BeaconMCP today. +

    +
    @@ -565,12 +567,33 @@

    OpenCode

    VS Code

    + OAuth + DCR Bearer
    -

    VS Code's built-in MCP client reads from workspace or user settings.

    +

    + VS Code routes MCP auth through its native Authentication + Provider system (same as GitHub / Microsoft Entra). It reads + WWW-Authenticate, shows a toast to Allow, + catches the redirect on vscode:// (or + vscode-insiders://), and stores tokens in the OS + keychain. +

    + {% if dcr_enabled %} +

    Recommended — OAuth + DCR:

    // .vscode/mcp.json (or settings.json → "mcp.servers")
     {
    +  "servers": {
    +    "beaconmcp": {
    +      "type": "http",
    +      "url": "https://<your-host>/mcp/c/<slug>"
    +    }
    +  }
    +}
    +

    Mint the slug from OAuth connectors. VS Code prompts you on first use.

    +

    Alternative — Bearer:

    + {% endif %} +
    {
       "servers": {
         "beaconmcp": {
           "type": "http",
    @@ -582,9 +605,9 @@ 

    VS Code

    } }

    - Open the Command Palette → MCP: List Servers to verify - the connection. If you're using GitHub Copilot's MCP integration, - the field names may differ — check the extension's readme. + Command Palette → MCP: List Servers to verify. Revoke + access anytime from the Accounts menu (profile icon, bottom + left) — MCP sessions live next to your other trusted logins.

    @@ -595,25 +618,46 @@

    VS Code

    Cursor

    + OAuth + DCR Bearer
    -

    Add BeaconMCP to Cursor's MCP config:

    +

    + Cursor is a first-class OAuth 2.1 client since v1.0. Drop a + remote URL in mcp.json, Cursor surfaces a blue + Connect button in Settings → Tools & MCP, + and catches the redirect via the cursor:// + scheme (or a local loopback fallback). PKCE + DCR are + automatic. +

    + {% if dcr_enabled %} +

    Recommended — OAuth + DCR:

    // ~/.cursor/mcp.json (global) or .cursor/mcp.json (per project)
     {
    +  "mcpServers": {
    +    "beaconmcp": {
    +      "url": "https://<your-host>/mcp/c/<slug>"
    +    }
    +  }
    +}
    +

    Mint the slug from OAuth connectors. Click Connect in Cursor settings when it surfaces the "Needs authentication" state.

    +

    Alternative — Bearer:

    + {% endif %} +
    {
       "mcpServers": {
         "beaconmcp": {
           "url": "{{ mcp_url }}",
           "headers": {
    -        "Authorization": "Bearer <token>"
    +        "Authorization": "Bearer ${env:BEACONMCP_TOKEN}"
           }
         }
       }
     }

    - Reload the Cursor window after editing; the server appears under - Settings → Cursor Settings → MCP Servers with a live - status indicator. + Reload the Cursor window after editing; the server shows up + under Settings → Cursor Settings → MCP Servers. + Cursor supports ${env:VAR} expansion so you can + keep secrets out of the repo.

    From 74c7e0d5381ffa1bae5cdbb2dca1425015a11a20 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 13:37:10 +0200 Subject: [PATCH 069/155] Validate redirect_uris against a trusted-origin allowlist on both DCR and authorize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DCR lets the caller propose any redirect_uri it wants. Without validation, a rogue script pointing at our /oauth/register/c/ with redirect_uri=https://evil.example/cb would get a valid client_id; if the operator later authorized that client thinking it was ChatGPT, the authorization code would land on the attacker's host. Add a fixed allowlist TRUSTED_REDIRECT_PREFIXES in auth.py covering every client documented in docs/clients.md — consumer + enterprise web surfaces (claude.ai, chatgpt.com, chat.mistral.ai, vscode.dev, cursor.com, …), OS URI schemes used by desktop clients (vscode://, cursor://), and HTTP loopback for CLI tools (localhost / 127.0.0.1 / ::1 with any port). Reject at /oauth/register/c/ before a client row is ever persisted, and again at /oauth/authorize so the same filter guards the pre-registered OAuth path. Tests cover both accepted and rejected cases including typo-squats (claude-ai.com, chatgpt.co), prefix-match evasion (claude.ai.evil.com), and non-HTTP schemes (javascript:, data:, file:). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/clients.md | 15 +++++ src/beaconmcp/__main__.py | 47 +++++++++++++-- src/beaconmcp/auth.py | 62 +++++++++++++++++++ tests/test_trusted_redirect.py | 107 +++++++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 tests/test_trusted_redirect.py diff --git a/docs/clients.md b/docs/clients.md index 1229255..f1129a5 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -19,6 +19,21 @@ auth paths the dashboard helps you drive: > code off your phone. Unattended-service automation is covered separately > in [totp-automation.md](totp-automation.md). +> **Trusted redirect URIs — hard-coded allowlist on the server.** +> Every `redirect_uri` reaching `/oauth/authorize` or `/oauth/register/c/` +> is checked against a fixed list in +> [`src/beaconmcp/auth.py`](../src/beaconmcp/auth.py) (constant +> `TRUSTED_REDIRECT_PREFIXES`). It covers every client documented here +> — consumer and enterprise web URLs (claude.ai, chatgpt.com, +> chat.mistral.ai, …), the OS URI schemes used by desktop clients +> (`vscode://`, `cursor://`), and HTTP loopback for CLI tools +> (`http://localhost:*`, `http://127.0.0.1:*`). If a new client shows +> "invalid_redirect_uri" during DCR or "redirect_uri origin not on the +> BeaconMCP trusted-origin allowlist" at `/oauth/authorize`, add its +> origin to that constant and restart. This check exists because DCR +> would otherwise let any caller register an attacker-controlled +> callback. + > **CORS allowlist — required for every web client.** > Browser-based MCP clients (Claude Web, ChatGPT Web, Le Chat, Perplexity, > Gemini Web) fire a CORS preflight before they can reach `/mcp`. If the diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 2e40889..32a8b2e 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -297,6 +297,19 @@ def _validate_authorize_params( {"error": "invalid_request", "error_description": "redirect_uri must use https"}, status_code=400, ) + # Reject any redirect_uri whose origin isn't on the trusted + # allowlist. Prevents authorization-code exfiltration via a + # typo-squat or attacker-controlled client that somehow got a + # valid client_id. + if not auth.is_trusted_redirect_uri(redirect_uri): + return {}, JSONResponse( + {"error": "invalid_request", + "error_description": ( + "redirect_uri origin not on the BeaconMCP trusted-" + "origin allowlist; see auth.TRUSTED_REDIRECT_PREFIXES" + )}, + status_code=400, + ) if not code_challenge or code_challenge_method != "S256": return {}, JSONResponse( {"error": "invalid_request", "error_description": "PKCE with S256 is required"}, @@ -619,6 +632,34 @@ async def dcr_register(request: Request) -> Response: if isinstance(candidate, str) and candidate.strip(): client_name = candidate.strip()[:60] + # Validate redirect_uris BEFORE provisioning a client. The MCP spec + # lets the caller propose arbitrary redirect URIs during DCR; if we + # accepted them blindly, a rogue script could register itself with + # redirect_uri=https://evil.example/cb and later phish an + # authorization code out of us. Reject anything not on the trusted + # allowlist (auth.TRUSTED_REDIRECT_PREFIXES). + redirect_uris_raw = None + if isinstance(body, dict): + redirect_uris_raw = body.get("redirect_uris") + if not isinstance(redirect_uris_raw, list) or not redirect_uris_raw: + return JSONResponse( + {"error": "invalid_redirect_uri", + "error_description": "redirect_uris is required"}, + status_code=400, + ) + bad = [u for u in redirect_uris_raw + if not auth.is_trusted_redirect_uri(u)] + if bad: + return JSONResponse( + {"error": "invalid_redirect_uri", + "error_description": ( + "one or more redirect_uris are not on the BeaconMCP " + "trusted-origin allowlist" + ), + "rejected_redirect_uris": bad}, + status_code=400, + ) + try: new_client_id, new_client_secret = client_store.create_dynamic( owner_client_id=row.owner_client_id, @@ -650,11 +691,7 @@ async def dcr_register(request: Request) -> Response: "token_endpoint_auth_method": "client_secret_post", "grant_types": ["authorization_code"], "response_types": ["code"], - "redirect_uris": ( - body.get("redirect_uris") - if isinstance(body, dict) and isinstance(body.get("redirect_uris"), list) - else [] - ), + "redirect_uris": redirect_uris_raw, }, status_code=201) class _McpSlugRewriteApp: diff --git a/src/beaconmcp/auth.py b/src/beaconmcp/auth.py index 3023062..da02413 100644 --- a/src/beaconmcp/auth.py +++ b/src/beaconmcp/auth.py @@ -64,6 +64,68 @@ def revoke_current_token() -> bool: CLIENTS_FILE = Path("/opt/beaconmcp/clients.json") +# Allowlist of redirect_uri prefixes accepted from DCR clients. Attack: +# a rogue script registers itself against your /oauth/register/c/ +# with redirect_uri="https://evil.example/cb". If you later authorize it +# (fooled into thinking it's ChatGPT), the authorization code lands on +# the attacker's server. Validating here stops that before a client row +# is ever persisted. +# +# Each prefix matches an origin + (optional) path prefix. Wildcards are +# only implicit via prefix matching — a listed origin covers every path +# beneath it. Custom OS URI schemes (vscode://, cursor://) are matched +# scheme-only because their host semantics don't carry meaning. +# +# Add a new client's origin here BEFORE flipping +# ``allow_dynamic_registration`` on for it, not after. +TRUSTED_REDIRECT_PREFIXES: tuple[str, ...] = ( + # Anthropic / Claude + "https://claude.ai/", + "https://claude.com/", + # OpenAI / ChatGPT + Codex + platform + "https://chatgpt.com/", + "https://chat.openai.com/", + "https://platform.openai.com/", + # Google / Gemini CLI + AI Studio + "https://gemini.google.com/", + "https://aistudio.google.com/", + "https://console.cloud.google.com/", + # Mistral + "https://chat.mistral.ai/", + "https://console.mistral.ai/", + # VS Code web surfaces + "https://vscode.dev/", + "https://github.dev/", + # Cursor dashboard + "https://cursor.com/", + # OS-level custom URI schemes used by desktop clients + "vscode://", + "vscode-insiders://", + "cursor://", + # Local loopback for every CLI / terminal client (Codex, Gemini CLI, + # Mistral Vibe, OpenCode, mcp-remote, …). Ports are dynamic so we + # match the scheme + loopback host and let the client pick the port. + "http://localhost:", + "http://localhost/", + "http://127.0.0.1:", + "http://127.0.0.1/", + "http://[::1]:", + "http://[::1]/", +) + + +def is_trusted_redirect_uri(redirect_uri: str) -> bool: + """Return True iff ``redirect_uri`` starts with a known-trusted prefix. + + Callers SHOULD reject any DCR ``redirect_uris`` entry for which this + returns False. See :data:`TRUSTED_REDIRECT_PREFIXES` for the rationale + and the list of accepted prefixes. + """ + if not isinstance(redirect_uri, str) or not redirect_uri: + return False + return any(redirect_uri.startswith(p) for p in TRUSTED_REDIRECT_PREFIXES) + + @dataclass class Client: client_id: str diff --git a/tests/test_trusted_redirect.py b/tests/test_trusted_redirect.py new file mode 100644 index 0000000..bec01d8 --- /dev/null +++ b/tests/test_trusted_redirect.py @@ -0,0 +1,107 @@ +"""Tests for :func:`beaconmcp.auth.is_trusted_redirect_uri`. + +The allowlist gates every redirect_uri that reaches :class:`/oauth/authorize` +or the DCR endpoint. If this check ever misfires — false-negative +breaking Claude; false-positive enabling an attacker's callback — the +whole OAuth surface is at risk. Guard it with explicit cases. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from beaconmcp.auth import is_trusted_redirect_uri + + +# --- Accepted origins -------------------------------------------------------- + + +@pytest.mark.parametrize( + "uri", + [ + # Claude + "https://claude.ai/api/organizations/xyz/mcp/callback", + "https://claude.com/some/path", + # ChatGPT family (consumer + enterprise + Codex) + "https://chatgpt.com/connector_platform_oauth/callback", + "https://chat.openai.com/oauth/callback", + "https://platform.openai.com/oauth/callback", + # Gemini CLI / AI Studio / GCP + "https://gemini.google.com/oauth/cb", + "https://aistudio.google.com/oauth", + "https://console.cloud.google.com/mcp", + # Mistral + "https://chat.mistral.ai/connectors/oauth/callback", + "https://console.mistral.ai/oauth", + # VS Code surfaces + "https://vscode.dev/oauth/cb", + "https://github.dev/oauth/cb", + # Cursor dashboard + "https://cursor.com/mcp/oauth/callback", + # Custom OS URI schemes + "vscode://ms-vscode.remote/callback", + "vscode-insiders://ms-vscode.remote/callback", + "cursor://mcp/callback", + # Loopback (Codex, Gemini CLI, Mistral Vibe, OpenCode, mcp-remote) + "http://localhost:54321/callback", + "http://localhost/callback", + "http://127.0.0.1:3000/oauth/cb", + "http://127.0.0.1/cb", + "http://[::1]:8080/cb", + ], +) +def test_trusted_origins_accepted(uri: str) -> None: + assert is_trusted_redirect_uri(uri), f"expected trusted: {uri}" + + +# --- Rejected origins -------------------------------------------------------- + + +@pytest.mark.parametrize( + "uri", + [ + # Obvious attacker-controlled domains + "https://evil.example.com/cb", + "https://attacker.xyz/callback", + # Typo-squats of real origins + "https://claude-ai.com/cb", # hyphenated fake + "https://chat.mistral.ai.evil.com/cb", # subdomain confusion + "https://chatgpt.co/cb", # TLD typo + # Valid-looking but not-whitelisted Google domains + "https://mail.google.com/oauth/cb", + "https://accounts.google.com/oauth/cb", + # Non-HTTP(S) schemes we don't trust + "ftp://claude.ai/cb", + "file:///etc/passwd", + "javascript:alert(1)", + "data:text/html, {% block head %}{% endblock %} diff --git a/src/beaconmcp/dashboard/templates/chat.html b/src/beaconmcp/dashboard/templates/chat.html index 6d56dfc..5dab659 100644 --- a/src/beaconmcp/dashboard/templates/chat.html +++ b/src/beaconmcp/dashboard/templates/chat.html @@ -5,23 +5,31 @@ {% endblock %} {% block body %} - diff --git a/src/beaconmcp/dashboard/templates/connectors.html b/src/beaconmcp/dashboard/templates/connectors.html index f44c2e4..20f3ee9 100644 --- a/src/beaconmcp/dashboard/templates/connectors.html +++ b/src/beaconmcp/dashboard/templates/connectors.html @@ -2,46 +2,52 @@ {% block title %}OAuth connectors · BeaconMCP{% endblock %} {% block body_class %}tokens-page{% endblock %} {% block body %} -
    -
    - - + + +
    +
    +
    BeaconMCP
    +
    +
    + Back to API access -
    -

    OAuth connectors

    -

    - Signed in as {{ client_name }}. Clients that use - OAuth Dynamic Client Registration (RFC 7591) — ChatGPT, OpenCode, - and similar — can't accept a pre-provisioned client_id - or a bearer header. Mint a single-use bootstrap URL here, paste it - into the client, and complete the 2FA prompt in-app. Each URL is - single-use and expires after {{ slug_ttl_minutes }} min. -

    -
    -
    +
    + +

    OAuth connectors

    +

    + Signed in as {{ client_name }}. Clients that use OAuth Dynamic Client + Registration (RFC 7591) — ChatGPT, OpenCode, and similar — can't accept a pre-provisioned + client_id or a bearer header. Mint a single-use bootstrap URL here, paste it + into the client, and complete the 2FA prompt in-app. Each URL is single-use and expires after + {{ slug_ttl_minutes }} min. +

    {% if just_created %} -
    -

    Connector URL ready

    -

    - Paste this URL in your OAuth-DCR client (ChatGPT: Settings → - Connectors → Add custom; OpenCode: opencode.json - with "oauth": true). Single-use, dies in - {{ slug_ttl_minutes }} min. -

    +
    + + + Connector URL ready — single-use, dies in {{ slug_ttl_minutes }} min +
    {{ just_created.url }}
    -
    + {% endif %} -
    -

    New connector

    +
    +
    +

    New connector

    +
    {% if form_error %} {% endif %} @@ -63,26 +69,27 @@

    New connector

    -
    + -
    -

    Pending URLs

    +
    +
    +

    Pending URLs

    +
    {% if pending_slugs %}
      {% for s in pending_slugs %} -
    • -
      - {{ s.label }} - {{ s.slug[:10] }}… -
      -
      - expires in {{ s.expires_in_minutes }} min +
    • +
      +
      + {{ s.label }} +
      +
      {{ s.slug[:10] }}… · expires in {{ s.expires_in_minutes }} min
      @@ -92,26 +99,27 @@

      Pending URLs

      {% else %}

      No pending URLs.

      {% endif %} -
    + -
    -

    Active connectors

    +
    +
    +

    Active connectors

    +
    {% if derived_clients %}
      {% for c in derived_clients %} -
    • -
      - {{ c.name }} - {{ c.client_id }} -
      -
      - registered {{ c.created_at_human }} +
    • +
      +
      + {{ c.name }} +
      +
      {{ c.client_id }} · registered {{ c.created_at_human }}
      @@ -121,30 +129,8 @@

      Active connectors

      {% else %}

      No active OAuth connectors.

      {% endif %} -
    + - - - + {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/login.html b/src/beaconmcp/dashboard/templates/login.html index 427afd4..0aa7c94 100644 --- a/src/beaconmcp/dashboard/templates/login.html +++ b/src/beaconmcp/dashboard/templates/login.html @@ -1,48 +1,95 @@ {% extends "base.html" %} {% block title %}Sign in · BeaconMCP{% endblock %} -{% block body_class %}centered-page{% endblock %} +{% block body_class %}auth-page{% endblock %} {% block body %}
    -

    BeaconMCP

    -

    Dashboard sign-in

    +
    + BeaconMCP +
    - {% if banner %} - - {% endif %} - -
    + {% if next %}{% endif %} - - - - - - - - - + {# Step 1 — Client ID + Client Secret #} +
    +

    Sign in

    +

    Paste your OAuth client credentials to continue.

    + + {% if banner %} + + {% endif %} + +
    + +
    + +
    +
    + +
    + +
    + + +
    +
    + +
    + +
    + + + +
    + No account? Ask your administrator +
    +
    + + {# Step 2 — TOTP #} +
    + + {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index 2c1fd05..9777d8f 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -2,46 +2,59 @@ {% block title %}API access · BeaconMCP{% endblock %} {% block body_class %}tokens-page{% endblock %} {% block body %} -
    -
    + + +
    + +
    +
    BeaconMCP
    +
    {% if chat_enabled %} - - + + Back to chat {% endif %} -
    -

    API access

    -

    - Signed in as {{ client_name }}. Pick your client and - follow the matching flow — every flow keeps your TOTP on your phone. -

    -
    -
    +
    -
    -
    MCP server URL
    -
    +

    API access

    +

    + Signed in as {{ client_name }}. Pick your client and follow the matching flow — + every flow keeps your TOTP on your phone. +

    + +
    +
    MCP server URL
    +
    {{ mcp_url }}
    -
    - - - Browser clients need their origin allowlisted - -
    -

    - A browser calling a different host has to pass a CORS preflight. - Web UIs (Claude, ChatGPT, - Le Chat, Perplexity, - Gemini Web) all do this before they can - reach /mcp. If the origin isn't listed, every - request fails silently in the browser console. -

    -

    Add the origins you plan to use to beaconmcp.yaml:

    +
    + +
    + + + Browser clients need their origin allowlisted + +
    +

    + A browser calling a different host has to pass a CORS preflight. Web UIs + (Claude, ChatGPT, Le Chat, + Perplexity, Gemini Web) all do this before they can reach + /mcp. If the origin isn't listed, every request fails silently in the + browser console. +

    +

    Add the origins you plan to use to beaconmcp.yaml:

    server:
       allowed_origins:
         - https://claude.ai
    @@ -49,18 +62,16 @@ 

    API access

    - https://chat.mistral.ai - https://www.perplexity.ai - https://gemini.google.com
    -

    - Desktop apps and CLI clients (Claude Desktop, Gemini CLI, - Cursor, VS Code, Mistral Vibe, OpenCode) don't run in a browser - and don't need an entry here. -

    -
    -
    -
    +

    + Desktop apps and CLI clients (Claude Desktop, Gemini CLI, Cursor, VS Code, Mistral Vibe, + OpenCode) don't run in a browser and don't need an entry here. +

    + +
    - - + +
    @@ -75,124 +86,112 @@

    API access

    - {# ---------- Panel: OAuth 2.1 ---------- #}
    -
    +

    OAuth 2.1 with a pre-registered client

    -

    - The path most clients take: provision - client_id / client_secret here, - paste them into the client, and let the client run - authorization code + PKCE against BeaconMCP. TOTP prompt - lives on BeaconMCP's authorization page; tokens last 24 h. -

    -
    -
    - Claude - Codex - Le Chat - Gemini CLI - Antigravity - OpenCode - Cursor - VS Code
    -
    -

    1. Provision credentials on the server

    +

    + The path most clients take: provision client_id / + client_secret here, paste them into the client, and let the client run + authorization code + PKCE against BeaconMCP. TOTP prompt lives on BeaconMCP's + authorization page; tokens last 24 h. +

    +
    +
    + Claude + Codex + Le Chat + Gemini CLI + Antigravity + OpenCode + Cursor + VS Code +
    + +
    1 · Provision credentials on the server

    One client per install so you can revoke granularly:

    -
    beaconmcp auth create --name "Claude iPhone"
    -

    - The CLI prints a client_id, a - client_secret, and a TOTP QR code (scan it - immediately — the secret is shown once). -

    -

    2. Paste credentials into the client

    +
    beaconmcp auth create --name "Claude iPhone"
    +

    The CLI prints a client_id, a client_secret, and a TOTP QR code (scan it immediately — the secret is shown once).

    + +
    2 · Paste credentials into the client

    - Every client has its own config file or UI panel. Switch to - the By platform view above for the exact - snippet per client. The MCP URL is always - {{ mcp_url }}. + Every client has its own config file or UI panel. Switch to the + By platform view above for the exact snippet per client. The MCP URL + is always {{ mcp_url }}.

    - {# ---------- Panel: OAuth + DCR ---------- #} {% if dcr_enabled %}
    -
    +

    OAuth with Dynamic Client Registration

    -

    - Reserved for clients whose UI won't let you paste - client_id / client_secret — they - read BeaconMCP's .well-known metadata and - register themselves. On our side that needs a single-use - bootstrap slug to gate the registration. -

    -
    - ChatGPT -
    -
    +

    + Reserved for clients whose UI won't let you paste client_id / + client_secret — they read BeaconMCP's .well-known metadata + and register themselves. On our side that needs a single-use bootstrap slug to gate + the registration. +

    +
    +
    ChatGPT

    - Mint a one-off connector URL, paste it into the client, it - auto-registers. The slug is single-use, expires in 15 min, - and the derived client is bound to your account — 2FA at - /oauth/authorize delegates to your TOTP seed, so - a leaked client can't mint a token without your phone. + Mint a one-off connector URL, paste it into the client, it auto-registers. The slug is + single-use, expires in 15 min, and the derived client is bound to your account — 2FA + at /oauth/authorize delegates to your TOTP seed, so a leaked client can't + mint a token without your phone.

    - - Open OAuth connectors - + Open OAuth connectors +
    {% endif %} - {# ---------- Panel: Bearer ---------- #}
    -
    +

    Static bearer tokens

    -

    For clients that attach Authorization: Bearer … to every call.

    -
    -
    - Gemini - Mistral - VS Code - Cursor - + REST
    -
    +

    For clients that attach Authorization: Bearer … to every call.

    +
    +
    + GeminiMistral + VS CodeCursor + + REST +
    {% if just_created %}
    - New token: {{ just_created.name }} -

    Copy it now — it will not be shown again.

    + + + New token: {{ just_created.name }} — copy it now, it won't be shown again +
    {{ just_created.token }}
    @@ -203,8 +202,8 @@

    Static bearer tokens

    {% endif %}
    -
    -

    Create a token {{ count }} / {{ cap }}

    +
    +
    Create a token {{ count }} / {{ cap }}
    {% if can_create %}
    @@ -231,24 +230,23 @@

    Create a token {{ count }} / {{ cap }} {% endif %}

    -
    -

    Active tokens

    +
    +
    Active tokens
    {% if tokens %}
      {% for t in tokens %} -
    • -
      - {{ t.name }} - {{ t.prefix }}… -
      -
      - expires in {{ t.expires_in_hours }} h +
    • +
      +
      + {{ t.name }} +
      +
      {{ t.prefix }}… · expires in {{ t.expires_in_hours }} h
      @@ -261,16 +259,15 @@

      Active tokens

    -

    - Concrete setup per client is in the By platform - tab. +

    + Concrete setup per client is in the By platform tab.

    {# ============================================================ #} - {# VIEW: BY PLATFORM — with nested sub-tabs for variants #} + {# VIEW: BY PLATFORM #} {# ============================================================ #}
    @@ -292,31 +289,30 @@

    Active tokens

    - {# ---- Claude (sub-tabs: Web/Mobile, Desktop) ---- #} + {# ---- Claude ---- #}
    -
    +

    Claude

    - OAuth 2.1 + OAuth 2.1
    -
    -

    - Claude uses OAuth with a user-provided client. Same CLI command - creates credentials for every surface — what changes is where - you paste them. -

    +

    + Claude uses OAuth with a user-provided client. Same CLI command creates credentials + for every surface — what changes is where you paste them. +

    +
      -
    1. On the server:
      beaconmcp auth create --name "Claude Web"
    2. +
    3. On the server:
      beaconmcp auth create --name "Claude Web"
    4. claude.ai or iOS/Android app → Settings → Integrations → Add custom connector.
    5. URL: {{ mcp_url }}. Paste client_id / client_secret.
    6. Type your TOTP on the authorization page when Claude redirects you.
    7. @@ -338,26 +334,25 @@

      Claude

      } } }
-

- mcp-remote is the community OAuth-to-stdio proxy. - It triggers the same authorization code flow as the web app - (TOTP prompt in your browser). Pre-provision the client with - beaconmcp auth create first. +

+ mcp-remote is the community OAuth-to-stdio proxy. It triggers the same + authorization code flow as the web app (TOTP prompt in your browser). Pre-provision + the client with beaconmcp auth create first.

- {# ---- ChatGPT (sub-tabs: Web, Mobile) ---- #} + {# ---- ChatGPT ---- #} {% if dcr_enabled %}
-
+

ChatGPT

- OAuth + DCR + OAuth + DCR
-
+
@@ -367,13 +362,12 @@

ChatGPT

+

- ChatGPT's Developer Mode connector UI only exposes "No auth" - and "OAuth" — no field for client_id / - client_secret. The OAuth path strictly requires - Dynamic Client Registration, so you have to mint a - single-use bootstrap URL. + ChatGPT's Developer Mode connector UI only exposes "No auth" and "OAuth" — no field + for client_id / client_secret. The OAuth path strictly + requires DCR, so you have to mint a single-use bootstrap URL.

  1. Mint a connector URL from OAuth connectors (single-use, 15 min).
  2. @@ -381,9 +375,8 @@

    ChatGPT

  3. Paste the /mcp/c/<slug> URL. Auth: OAuth.
  4. ChatGPT runs DCR, then redirects you — type your TOTP.
- - Mint a connector URL - + Mint a connector URL +
@@ -393,53 +386,40 @@

ChatGPT

  • ChatGPT app → profile → Settings → Connectors → Add custom.
  • Paste the /mcp/c/<slug> URL. OAuth. TOTP in the in-app browser.
  • -

    Mint a fresh slug per device if you want independent revocation.

    - - Mint a connector URL - - +

    Mint a fresh slug per device if you want independent revocation.

    -

    OAuth 2.1

    +

    OAuth 2.1

    - Unlike the web connector, Codex (OpenAI's terminal/IDE MCP - client) lets you pre-register credentials via its - config.toml. It catches the OAuth redirect on - an ephemeral local port and opens your default browser for - the TOTP prompt. + Unlike the web connector, Codex (OpenAI's terminal/IDE MCP client) lets you + pre-register credentials via its config.toml. It catches the OAuth + redirect on an ephemeral local port and opens your default browser for the TOTP + prompt.

      -
    1. - Provision a client on the server: -
      beaconmcp auth create --name "Codex"
      -
    2. -
    3. - Add BeaconMCP to your Codex config: +
    4. Provision a client on the server:
      beaconmcp auth create --name "Codex"
    5. +
    6. Add BeaconMCP to your Codex config:
      [mcp_servers.beaconmcp]
       url = "{{ mcp_url }}"
       client_id = "beaconmcp_..."
      -client_secret = "sk_..."
      -
    7. +client_secret = "sk_..."
  • Run codex mcp login beaconmcp. Your browser opens on BeaconMCP's authorization page — type your TOTP.
  • -

    - Remote devbox (Codespaces, SSH container)? Set - mcp_oauth_callback_url in config.toml to your - ingress URL so the redirect hits the right host. +

    + Remote devbox (Codespaces, SSH container)? Set mcp_oauth_callback_url + in config.toml to your ingress URL so the redirect hits the right host.

    {% endif %} - {# ---- Gemini (sub-tabs: CLI, Antigravity, Web) ---- #} + {# ---- Gemini ---- #}
    -
    -
    -

    Gemini

    -
    -
    +
    +

    Gemini

    +
    @@ -448,14 +428,14 @@

    Gemini

    +
    -

    OAuth 2.1 Bearer

    +

    OAuth 2.1 Bearer

    - Gemini CLI accepts a pre-registered client_id / - client_secret in settings.json, so - you don't need a DCR slug — just provision a client via the - CLI and paste the credentials. /mcp auth beaconmcp - then runs the browser flow with your TOTP prompt. + Gemini CLI accepts a pre-registered client_id / client_secret + in settings.json, so you don't need a DCR slug — just provision a client + via the CLI and paste the credentials. /mcp auth beaconmcp then runs the + browser flow with your TOTP prompt.

    Recommended — OAuth 2.1 (pre-registered):

      @@ -471,8 +451,7 @@

      Gemini

      } } } -}
    - +}
  • In the CLI: /mcp auth beaconmcp. Type your TOTP in the browser that opens.
  • Alternative — Bearer:

    @@ -480,12 +459,12 @@

    Gemini

    --url {{ mcp_url }} \ --header "Authorization: Bearer <token>" +
    -

    OAuth 2.1 Bearer

    +

    OAuth 2.1 Bearer

    - Antigravity's visual connection manager handles both paths. - OAuth keeps the TOTP prompt on BeaconMCP's side; Bearer is a - quick fallback when the OAuth flow misbehaves. + Antigravity's visual connection manager handles both paths. OAuth keeps the TOTP + prompt on BeaconMCP's side; Bearer is a quick fallback when the OAuth flow misbehaves.

    Recommended — OAuth 2.1 (pre-registered):

      @@ -505,30 +484,28 @@

      Gemini

      } } } -

      - Antigravity uses serverUrl (not url). - If the native HTTP transport misbehaves, fall back to - npx mcp-remote with --header. +

      + Antigravity uses serverUrl (not url). If the native HTTP + transport misbehaves, fall back to npx mcp-remote with --header.

    +

    - Gemini Web (gemini.google.com), the mobile apps, and the macOS - native app do not expose a custom-MCP connector - yet. Only Gemini CLI and Antigravity can reach BeaconMCP today. + Gemini Web (gemini.google.com), the mobile apps, and the macOS native app do + not expose a custom-MCP connector yet. Only Gemini CLI and + Antigravity can reach BeaconMCP today.

    - {# ---- Mistral (sub-tabs: Le Chat, Vibe) ---- #} + {# ---- Mistral ---- #}
    -
    -
    -

    Mistral

    -
    -
    +
    +

    Mistral

    +
    -

    OAuth 2.1

    +

    OAuth 2.1

    Le Chat speaks OAuth 2.1 natively. In the app: Intelligence → Connecteurs → Ajouter un connecteur → Connecteur MCP personnalisé. @@ -546,17 +523,17 @@

    Mistral

  • MCP Server URL: {{ mcp_url }}
  • Validate. Le Chat discovers the OAuth metadata and redirects you to BeaconMCP's authorization page — type your TOTP from your phone. Token lifetime: 24 h.
  • -

    - Custom connectors are on Le Chat Pro / Enterprise plans; on the free tier the panel may be hidden. - Add https://chat.mistral.ai to server.allowed_origins so the browser CORS preflight succeeds. +

    + Custom connectors are on Le Chat Pro / Enterprise plans; on the free tier the panel + may be hidden. Add https://chat.mistral.ai to + server.allowed_origins so the browser CORS preflight succeeds.

    -

    Bearer (unverified)

    -

    - Vibe's bearer path hasn't been tested against a live - BeaconMCP. If the snippet below fails, check the latest - Vibe docs — the schema has been iterating fast. +

    Bearer (unverified)

    +

    + Vibe's bearer path hasn't been tested against a live BeaconMCP. If the snippet below + fails, check the latest Vibe docs — the schema has been iterating fast.

    Vibe reads ./.vibe/config.toml (per-project) or ~/.vibe/config.toml (global). TOML format, not JSON.

    [[mcp_servers]]
    @@ -564,9 +541,9 @@ 

    Mistral

    transport = "http" url = "{{ mcp_url }}" headers = { "Authorization" = "Bearer <token>" }
    -

    - transport accepts "http", - "streamable-http", or "stdio". +

    + transport accepts "http", "streamable-http", + or "stdio".

    @@ -575,17 +552,16 @@

    Mistral

    {# ---- OpenCode ---- #}
    -
    +

    OpenCode

    - OAuth 2.1 - Bearer + OAuth 2.1 + Bearer
    -
    +

    - OpenCode supports three flavours in opencode.json: - pre-registered OAuth (recommended), auto-registration via DCR, - or a plain bearer token. Tokens land in + OpenCode supports three flavours in opencode.json: pre-registered OAuth + (recommended), auto-registration via DCR, or a plain bearer token. Tokens land in ~/.local/share/opencode/mcp-auth.json and refresh on their own.

    @@ -605,13 +581,12 @@

    OpenCode

    } } } -} - +}
  • Run opencode mcp auth beaconmcp. Type your TOTP in the browser.
  • {% if dcr_enabled %} -

    Alternative — DCR (no credentials to paste):

    +

    Alternative — DCR (no credentials to paste):

    {
       "mcp": {
         "beaconmcp": {
    @@ -622,13 +597,12 @@ 

    OpenCode

    } } }
    - - Mint a connector URL - + Mint a connector URL + {% endif %} -

    Alternative — Bearer:

    +

    Alternative — Bearer:

    {
       "mcp": {
         "beaconmcp": {
    @@ -648,20 +622,18 @@ 

    OpenCode

    {# ---- VS Code ---- #}
    -
    +

    VS Code

    - OAuth 2.1 - Bearer + OAuth 2.1 + Bearer
    -
    +

    - VS Code routes MCP auth through its native Authentication - Provider system (same as GitHub / Microsoft Entra). It reads - WWW-Authenticate, shows a toast to Allow, - catches the redirect on vscode:// (or - vscode-insiders://), and stores tokens in the OS - keychain. + VS Code routes MCP auth through its native Authentication Provider system (same as + GitHub / Microsoft Entra). It reads WWW-Authenticate, shows a toast to + Allow, catches the redirect on vscode:// (or vscode-insiders://), + and stores tokens in the OS keychain.

    Recommended — OAuth 2.1 (pre-registered):

    @@ -681,13 +653,12 @@

    VS Code

    "clientSecret": "${input:beaconmcp-client-secret}" } } -}
    - +}
  • VS Code prompts you on first use — OS keychain stores the tokens after your TOTP.
  • {% if dcr_enabled %} -

    Alternative — DCR:

    +

    Alternative — DCR:

    {
       "servers": {
         "beaconmcp": {
    @@ -696,13 +667,12 @@ 

    VS Code

    } } }
    - - Mint a connector URL - + Mint a connector URL + {% endif %} -

    Alternative — Bearer:

    +

    Alternative — Bearer:

    {
       "servers": {
         "beaconmcp": {
    @@ -714,10 +684,10 @@ 

    VS Code

    } } }
    -

    - Command Palette → MCP: List Servers to verify. Revoke - access anytime from the Accounts menu (profile icon, bottom - left) — MCP sessions live next to your other trusted logins. +

    + Command Palette → MCP: List Servers to verify. Revoke access anytime from the + Accounts menu (profile icon, bottom left) — MCP sessions live next to your other + trusted logins.

    @@ -725,19 +695,17 @@

    VS Code

    {# ---- Cursor ---- #}
    -
    +

    Cursor

    - OAuth 2.1 - Bearer + OAuth 2.1 + Bearer
    -
    +

    - Cursor is a first-class OAuth 2.1 client since v1.0. It - surfaces a blue Connect button in - Settings → Tools & MCP and catches the redirect - via the cursor:// scheme (or a loopback fallback). - PKCE runs in-app. + Cursor is a first-class OAuth 2.1 client since v1.0. It surfaces a blue + Connect button in Settings → Tools & MCP and catches the redirect + via the cursor:// scheme (or a loopback fallback). PKCE runs in-app.

    Recommended — OAuth 2.1 (pre-registered):

    @@ -752,13 +720,12 @@

    Cursor

    "clientSecret": "${env:BEACONMCP_CLIENT_SECRET}" } } -} - +}
  • Export the credentials in your shell (BEACONMCP_CLIENT_ID / BEACONMCP_CLIENT_SECRET). Reload Cursor; click Connect.
  • {% if dcr_enabled %} -

    Alternative — DCR:

    +

    Alternative — DCR:

    {
       "mcpServers": {
         "beaconmcp": {
    @@ -766,13 +733,12 @@ 

    Cursor

    } } }
    - - Mint a connector URL - + Mint a connector URL + {% endif %} -

    Alternative — Bearer:

    +

    Alternative — Bearer:

    {
       "mcpServers": {
         "beaconmcp": {
    @@ -783,11 +749,10 @@ 

    Cursor

    } } }
    -

    - Reload the Cursor window after editing; the server shows up - under Settings → Cursor Settings → MCP Servers. - Cursor supports ${env:VAR} expansion so you can - keep secrets out of the repo. +

    + Reload the Cursor window after editing; the server shows up under Settings → + Cursor Settings → MCP Servers. Cursor supports ${env:VAR} expansion + so you can keep secrets out of the repo.

    @@ -795,17 +760,16 @@

    Cursor

    {# ---- Other ---- #}
    -
    +

    Other MCP-HTTP clients

    - Bearer + Bearer
    -
    +

    - Any client that can send Authorization: Bearer … on - HTTP POST works. Mint a token, point the client at - {{ mcp_url }}, and attach the bearer on every - request. Tokens live 24 h. + Any client that can send Authorization: Bearer … on HTTP POST works. Mint + a token, point the client at {{ mcp_url }}, and attach the bearer on + every request. Tokens live 24 h.

    @@ -813,27 +777,5 @@

    Other MCP-HTTP clients

    - - - + {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/totp_refresh.html b/src/beaconmcp/dashboard/templates/totp_refresh.html index 351dd76..3bcd2c6 100644 --- a/src/beaconmcp/dashboard/templates/totp_refresh.html +++ b/src/beaconmcp/dashboard/templates/totp_refresh.html @@ -1,29 +1,37 @@ {% extends "base.html" %} -{% block title %}2FA code · BeaconMCP{% endblock %} -{% block body_class %}centered-page{% endblock %} +{% block title %}2FA · BeaconMCP{% endblock %} +{% block body_class %}auth-page{% endblock %} {% block body %}
    -

    BeaconMCP

    -

    Session active for {{ client_name }}

    -

    The MCP token expired. Enter the 2FA code to renew it.

    +
    + BeaconMCP +
    + +

    Two-factor

    +

    Session active for {{ client_name }}. Enter the 6-digit code from your authenticator app to renew your MCP token.

    {% if banner %} {% endif %} -
    + {% if next %}{% endif %} - +
    + + + + + + +
    + - +
    @@ -31,4 +39,6 @@

    BeaconMCP

    + + {% endblock %} From c93f25b0d160863b26c3fc88bf9573394a405a77 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:34:17 +0200 Subject: [PATCH 079/155] Add aggregator tools, unify exec tools, add fields= trim Four high-level aggregators collapse multi-call diagnostic workflows into one tool call each: - cluster_overview : nodes + VMs + storage in one call - cluster_health : node metrics + BMC + recent errors (per node or all) - vm_find : glob/substring search for VMs by name - vm_bulk_action : parallel start/stop/restart across many VMIDs Unified exec tools replace the sync + async + get_result trio on both Proxmox and SSH: - proxmox_run : sync by default, auto-switches to async on timeout, poll via exec_id=... - ssh_run : same pattern for SSH The old proxmox_exec_command / _async / _get_result and the SSH equivalents are retired. Response shape is normalized across both tools: {status: ok|running|error, exec_id, stdout, stderr, exit_code, duration_s}. New shared helper module beaconmcp.utils: - filter_fields : single-level projection used by every tool that accepts a fields=[...] kwarg (list_nodes, list_vms, node_status, storage_status, cluster_overview). - parse_since : 15m / 2h / 1d / ISO-8601 / epoch -> epoch-seconds, used by time-windowed tools. fields=[...] trim support wired into list_nodes, list_vms, node_status, storage_status, and cluster_overview so clients can cut payload size without losing access to the full response shape. server.py registers the aggregators alongside the existing Proxmox tools and refreshes the beaconmcp_context prompt to point the model at the new workflow (start with cluster_overview, fall back to the detail tools with fields=..., use vm_find/vm_bulk_action for multi-VM ops, prefer proxmox_run/ssh_run with auto-async). Integration tests migrated to the new API and a new test_aggregators section exercises cluster_overview / cluster_health / vm_find plus the fields= trim. test_utils.py covers the shared helpers (12 cases, all pass). --- src/beaconmcp/proxmox/aggregators.py | 337 +++++++++++++++++++++++++++ src/beaconmcp/proxmox/monitoring.py | 20 +- src/beaconmcp/proxmox/system.py | 217 ++++++++--------- src/beaconmcp/server.py | 37 ++- src/beaconmcp/ssh/tools.py | 122 ++++++---- src/beaconmcp/utils.py | 103 ++++++++ tests/test_integration.py | 152 ++++++++---- tests/test_utils.py | 70 ++++++ 8 files changed, 826 insertions(+), 232 deletions(-) create mode 100644 src/beaconmcp/proxmox/aggregators.py create mode 100644 src/beaconmcp/utils.py create mode 100644 tests/test_utils.py diff --git a/src/beaconmcp/proxmox/aggregators.py b/src/beaconmcp/proxmox/aggregators.py new file mode 100644 index 0000000..6e37648 --- /dev/null +++ b/src/beaconmcp/proxmox/aggregators.py @@ -0,0 +1,337 @@ +"""High-level aggregator tools that collapse multi-call workflows into one. + +Rationale +--------- +A typical diagnostic session in an MCP client looks like: +``list_nodes`` -> ``list_vms`` -> ``storage_status`` -> ``node_status`` -> ``get_logs``. +That's five tool calls, five round-trips, and a lot of repeated JSON. + +The helpers in this module return the same information in one call each, at +the cost of slightly larger payloads. Clients keep full access to the +fine-grained tools; these aggregators exist so the LLM can pick a shorter +path when it doesn't yet know what it's looking for. + +All aggregators are careful to: +* gracefully downgrade if a capability is missing (no SSH -> no SSH facts; + no BMC registry -> no hardware facts). +* report errors inline per node/VM rather than failing the whole call, so the + caller can still work with the partial view. +""" + +from __future__ import annotations + +import fnmatch +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from ..config import Config +from ..utils import filter_fields +from .client import ProxmoxClient + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _collect_node_summaries(client: ProxmoxClient) -> list[dict[str, Any]]: + """One row per configured node with key health metrics. + + Mirrors ``proxmox_list_nodes`` but keeps only the fields cluster_overview + actually needs to stay token-efficient. + """ + out: list[dict[str, Any]] = [] + for node_name in client.configured_nodes: + data = client.get(node_name, "nodes") + if isinstance(data, dict) and "error" in data: + out.append({"name": node_name, "status": "unreachable", "error": data["error"]}) + continue + if not isinstance(data, list): + out.append({"name": node_name, "status": "unknown"}) + continue + for node in data: + if node.get("node") != node_name: + continue + out.append({ + "name": node_name, + "status": node.get("status", "unknown"), + "cpu": round(node.get("cpu", 0) * 100, 1), + "mem_used_gb": round(node.get("mem", 0) / 1073741824, 1), + "mem_total_gb": round(node.get("maxmem", 0) / 1073741824, 1), + "uptime_h": round(node.get("uptime", 0) / 3600, 1), + }) + break + else: + out.append({"name": node_name, "status": "unknown"}) + return out + + +def _collect_vm_summaries( + client: ProxmoxClient, target_nodes: list[str] | None = None +) -> tuple[list[dict[str, Any]], int]: + """Flat list of VMs across one or more nodes + total count. + + Returns a flat list (not nested by node) because callers that use this + helper want to filter/count across the whole set; the per-node nesting + shape is already available via ``proxmox_list_vms``. + """ + nodes = target_nodes or client.configured_nodes + rows: list[dict[str, Any]] = [] + total = 0 + for n in nodes: + for vm_type in ("qemu", "lxc"): + data = client.get(n, f"nodes/{n}/{vm_type}") + if isinstance(data, dict) and "error" in data: + continue + if not isinstance(data, list): + continue + for vm in data: + rows.append({ + "node": n, + "vmid": vm.get("vmid"), + "name": vm.get("name", ""), + "status": vm.get("status"), + "type": vm_type, + "cpu_pct": round(vm.get("cpu", 0) * 100, 1), + "mem_used_mb": round(vm.get("mem", 0) / 1048576, 0), + }) + total += 1 + rows.sort(key=lambda v: (v.get("node", ""), v.get("vmid", 0))) + return rows, total + + +def _collect_storage_summaries(client: ProxmoxClient) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for n in client.configured_nodes: + data = client.get(n, f"nodes/{n}/storage") + if isinstance(data, dict) and "error" in data: + rows.append({"node": n, "error": data["error"]}) + continue + if not isinstance(data, list): + continue + for s in data: + name = s.get("storage") + if not name: + continue + status = client.get(n, f"nodes/{n}/storage/{name}/status") + used = 0 + total = 0 + if isinstance(status, dict) and "error" not in status: + used = status.get("used", 0) + total = status.get("total", 0) + rows.append({ + "node": n, + "name": name, + "type": s.get("type"), + "used_gb": round(used / 1073741824, 1), + "total_gb": round(total / 1073741824, 1), + "usage_pct": round(used / total * 100, 1) if total > 0 else 0, + }) + return rows + + +def _find_vm_location(client: ProxmoxClient, vmid: int) -> tuple[str, str] | None: + """Return (node, vm_type) for a VMID; None if not found.""" + for n in client.configured_nodes: + for vm_type in ("qemu", "lxc"): + data = client.get(n, f"nodes/{n}/{vm_type}/{vmid}/status/current") + if isinstance(data, dict) and "error" in data: + continue + if isinstance(data, dict) and data.get("status"): + return (n, vm_type) + return None + + +async def _bmc_summary(bmc_registry: dict, config: Config, node: str) -> dict[str, Any] | None: + """Return a short BMC status blurb for the given node, if one is mapped. + + Heuristic: a BMC device is "attached" to a node when its ``jump_host`` + matches the node name. That's how HP iLO setups tend to be declared and + is the only mapping the config currently exposes. + """ + if not bmc_registry: + return None + matches = [d for d in config.bmc_devices if d.jump_host == node] + if not matches: + return None + device = matches[0] + client = bmc_registry.get(device.id) + if not client: + return {"device_id": device.id, "error": "BMC device in config but not in registry"} + try: + power = await client.power_status() + health = await client.health() + except Exception as exc: # noqa: BLE001 -- surface anything as a soft error + return {"device_id": device.id, "error": str(exc)} + return { + "device_id": device.id, + "type": device.type, + "power": power.get("power_status") if isinstance(power, dict) else None, + "health_summary": { + k: v + for k, v in (health.items() if isinstance(health, dict) else []) + if k in ("overall", "fans", "temperatures", "power_supplies") + }, + } + + +def _recent_errors(client: ProxmoxClient, node: str, limit: int = 20) -> list[dict[str, Any]]: + """Pull the last ``limit`` failed tasks on ``node``. + + Proxmox exposes task exit status as a string: "OK" for success, anything + else (including "unknown", actual error strings) means not-ok. + """ + data = client.get(node, f"nodes/{node}/tasks", limit=limit) + if not isinstance(data, list): + return [] + errors = [] + for t in data: + status = t.get("status", "") + if status and status != "OK": + errors.append({ + "upid": t.get("upid"), + "type": t.get("type"), + "status": status, + "user": t.get("user"), + "starttime": t.get("starttime"), + "endtime": t.get("endtime"), + }) + return errors + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +def register_aggregator_tools( + mcp: FastMCP, + proxmox_client: ProxmoxClient, + config: Config, + bmc_registry: dict | None = None, +) -> None: + """Register the four aggregator tools. + + ``bmc_registry`` is accepted as a plain dict (device_id -> BMCClient) + rather than importing the type, so this module can be registered even + when BMC support is disabled. + """ + + bmc_registry = bmc_registry or {} + + @mcp.tool() + def cluster_overview( + include_storage: bool = True, + fields: list[str] | None = None, + ) -> dict[str, Any]: + """Return cluster state (nodes + VMs + optional storage) in one call. + + Use this as the first diagnostic step -- it replaces + ``proxmox_list_nodes`` + ``proxmox_list_vms`` + ``proxmox_storage_status``. + Set ``include_storage=False`` to skip storage (saves the per-pool status + roundtrip on large clusters). Pass ``fields=[...]`` to trim each entry + to only the keys you need (applied uniformly to nodes/vms/storage). + """ + nodes = _collect_node_summaries(proxmox_client) + vms, total_vms = _collect_vm_summaries(proxmox_client) + out: dict[str, Any] = { + "nodes": filter_fields(nodes, fields), + "vms": filter_fields(vms, fields), + "total_vms": total_vms, + } + if include_storage: + out["storage"] = filter_fields(_collect_storage_summaries(proxmox_client), fields) + return out + + @mcp.tool() + async def cluster_health(node: str = "") -> dict[str, Any]: + """Aggregate health signals for one node (or all nodes): metrics + BMC + recent errors. + + Collapses ``proxmox_node_status`` + ``bmc_health_status`` + + ``proxmox_get_tasks`` into one call. When ``node`` is empty every + configured node is scanned. BMC data is only attached for nodes that + have a BMC device declared with ``jump_host: ``. + """ + target_nodes = [node] if node else list(proxmox_client.configured_nodes) + results: list[dict[str, Any]] = [] + for n in target_nodes: + status = proxmox_client.get(n, f"nodes/{n}/status") + if isinstance(status, dict) and "error" in status: + results.append({"node": n, "error": status["error"]}) + continue + entry: dict[str, Any] = { + "node": n, + "cpu_pct": round(status.get("cpu", 0) * 100, 1), + "mem_used_gb": round(status.get("memory", {}).get("used", 0) / 1073741824, 1), + "mem_total_gb": round(status.get("memory", {}).get("total", 0) / 1073741824, 1), + "uptime_h": round(status.get("uptime", 0) / 3600, 1), + "kernel": status.get("kversion"), + "pve_version": status.get("pveversion"), + } + bmc = await _bmc_summary(bmc_registry, config, n) + if bmc is not None: + entry["bmc"] = bmc + entry["recent_errors"] = _recent_errors(proxmox_client, n, limit=20) + results.append(entry) + if node: + return results[0] if results else {"error": f"Node {node!r} not configured."} + return {"nodes": results} + + @mcp.tool() + def vm_find(pattern: str, node: str = "") -> dict[str, Any]: + """Find VMs/CTs by name using glob (``web-*``) or substring (``db``). + + Returns a compact hit list so the caller can follow up with + ``proxmox_vm_status`` or ``vm_bulk_action``. Omit ``node`` to search + across every configured node. + """ + target_nodes = [node] if node else None + vms, _ = _collect_vm_summaries(proxmox_client, target_nodes) + pat = pattern.strip() + is_glob = any(ch in pat for ch in "*?[") + hits: list[dict[str, Any]] = [] + for vm in vms: + name = vm.get("name", "") + if is_glob: + if fnmatch.fnmatchcase(name, pat): + hits.append(vm) + elif pat.lower() in name.lower(): + hits.append(vm) + return {"pattern": pat, "total": len(hits), "vms": hits} + + @mcp.tool() + def vm_bulk_action( + vmids: list[int], + action: str, + force: bool = False, + ) -> dict[str, Any]: + """Run ``start``/``stop``/``restart`` on many VMs/CTs in parallel. + + Locates each VMID across the cluster, fires the action, and collects + per-VM UPIDs (or errors) in one response. ``force`` applies to stop + and restart actions. + """ + valid_actions = {"start", "stop", "restart"} + if action not in valid_actions: + return {"error": f"Unsupported action {action!r}. Use one of {sorted(valid_actions)}."} + + results: list[dict[str, Any]] = [] + for vmid in vmids: + location = _find_vm_location(proxmox_client, vmid) + if not location: + results.append({"vmid": vmid, "error": "not found"}) + continue + n, vm_type = location + endpoint = f"nodes/{n}/{vm_type}/{vmid}/status/{action}" + params: dict[str, Any] = {} + if action in ("stop", "restart") and force: + params["forceStop"] = 1 + resp = proxmox_client.post(n, endpoint, **params) + if isinstance(resp, dict) and "error" in resp: + results.append({"vmid": vmid, "node": n, "error": resp["error"]}) + else: + upid = resp if isinstance(resp, str) else ( + resp.get("upid") if isinstance(resp, dict) else None + ) + results.append({"vmid": vmid, "node": n, "type": vm_type, "upid": upid}) + ok = sum(1 for r in results if "upid" in r) + return {"action": action, "total": len(results), "ok": ok, "results": results} diff --git a/src/beaconmcp/proxmox/monitoring.py b/src/beaconmcp/proxmox/monitoring.py index 4d2ec36..6983108 100644 --- a/src/beaconmcp/proxmox/monitoring.py +++ b/src/beaconmcp/proxmox/monitoring.py @@ -4,6 +4,7 @@ from mcp.server.fastmcp import FastMCP +from ..utils import filter_fields from .client import ProxmoxClient @@ -11,13 +12,15 @@ def register_monitoring_tools(mcp: FastMCP, client: ProxmoxClient) -> None: """Register all Proxmox monitoring and diagnostic tools.""" @mcp.tool() - def proxmox_list_nodes() -> dict[str, Any]: + def proxmox_list_nodes(fields: list[str] | None = None) -> dict[str, Any]: """List all Proxmox cluster nodes with their status (online/offline). Use this as the first step when diagnosing cluster health or checking which nodes are available. + Pass ``fields=[...]`` to trim each entry to only the keys you need + (e.g. ``["name", "status"]``). Returns: {"nodes": [{name, status, cpu, mem_used_gb, mem_total_gb, uptime_h}]}. - If a node appears offline, use ilo_health_status to check if it's a hardware issue, - or ssh_exec_command to try reaching it directly. + If a node appears offline, use bmc_health_status to check if it's a hardware issue, + or ssh_run to try reaching it directly. """ # Query every configured node. In a joined cluster each member returns the # same view (deduped by name); with standalone hosts each returns only @@ -52,10 +55,10 @@ def proxmox_list_nodes() -> dict[str, Any]: for entry in unreachable: results.setdefault(entry["name"], entry) - return {"nodes": list(results.values())} + return {"nodes": filter_fields(list(results.values()), fields)} @mcp.tool() - def proxmox_node_status(node: str) -> dict[str, Any]: + def proxmox_node_status(node: str, fields: list[str] | None = None) -> dict[str, Any]: """Get detailed status of a specific Proxmox node: CPU, RAM, disk, uptime, kernel, PVE version. Use after proxmox_list_nodes to drill into a specific node. @@ -67,7 +70,7 @@ def proxmox_node_status(node: str) -> dict[str, Any]: data = client.get(node, f"nodes/{node}/status") if isinstance(data, dict) and "error" in data: return data - return { + result = { "node": node, "cpu_cores": data.get("cpuinfo", {}).get("cores"), "cpu_model": data.get("cpuinfo", {}).get("model"), @@ -82,9 +85,10 @@ def proxmox_node_status(node: str) -> dict[str, Any]: "kernel_version": data.get("kversion"), "pve_version": data.get("pveversion"), } + return filter_fields(result, fields) @mcp.tool() - def proxmox_list_vms(node: str = "") -> dict[str, Any]: + def proxmox_list_vms(node: str = "", fields: list[str] | None = None) -> dict[str, Any]: """List all VMs and containers with their status and resource usage. Use to get an overview of what's running on the cluster. @@ -120,7 +124,7 @@ def proxmox_list_vms(node: str = "") -> dict[str, Any]: "uptime_h": round(vm.get("uptime", 0) / 3600, 1), }) entries.sort(key=lambda v: v.get("vmid", 0)) - by_node[n] = entries + by_node[n] = filter_fields(entries, fields) total += sum(1 for e in entries if "vmid" in e) return {"vms": by_node, "total": total} diff --git a/src/beaconmcp/proxmox/system.py b/src/beaconmcp/proxmox/system.py index 9e86a50..0cf3d31 100644 --- a/src/beaconmcp/proxmox/system.py +++ b/src/beaconmcp/proxmox/system.py @@ -8,6 +8,7 @@ from mcp.server.fastmcp import FastMCP +from ..utils import filter_fields from .client import ProxmoxClient @@ -53,64 +54,16 @@ def _detect_vm_type(client: ProxmoxClient, node: str, vmid: int) -> str | None: return None -async def _exec_qemu_sync(client: ProxmoxClient, node: str, vmid: int, command: str, timeout: int) -> dict[str, Any]: - """Execute a command in a QEMU VM via Guest Agent, polling until done.""" - import asyncio - import shlex - - # Proxmox agent/exec endpoint expects `command` as an array (binary + args). - # proxmoxer encodes list values with doseq=True, which PVE parses as an array. - parts = shlex.split(command) - - # Start the command via QEMU Guest Agent - result = client.post(node, f"nodes/{node}/qemu/{vmid}/agent/exec", command=parts) - if isinstance(result, dict) and "error" in result: - return result - - pid = result.get("pid") if isinstance(result, dict) else None - if pid is None: - return {"error": f"Failed to start command in VM {vmid}. QEMU Guest Agent may not be running."} - - # Poll for result (async-safe, does not block event loop) - deadline = time.time() + timeout - while time.time() < deadline: - status_data = client.get(node, f"nodes/{node}/qemu/{vmid}/agent/exec-status", pid=pid) - if isinstance(status_data, dict) and "error" in status_data: - return status_data - if isinstance(status_data, dict) and status_data.get("exited"): - stdout = status_data.get("out-data", "") - stderr = status_data.get("err-data", "") - # Proxmox returns base64-encoded output - if status_data.get("out-data-encoding") == "base64" and stdout: - stdout = base64.b64decode(stdout).decode("utf-8", errors="replace") - if status_data.get("err-data-encoding") == "base64" and stderr: - stderr = base64.b64decode(stderr).decode("utf-8", errors="replace") - return { - "stdout": stdout, - "stderr": stderr, - "exit_code": status_data.get("exitcode", -1), - } - await asyncio.sleep(2) - - return { - "stdout": "", - "stderr": "", - "exit_code": None, - "status": "timeout", - "error": f"Command timed out after {timeout}s. Use proxmox_exec_command_async for long-running commands.", - } - - def _exec_lxc_unsupported(vmid: int) -> dict[str, Any]: """LXC exec is not exposed by the Proxmox API. - Commands inside containers must be run via `pct exec` on the host, which + Commands inside containers must be run via ``pct exec`` on the host, which requires SSH access to the node. """ return { "error": ( f"Proxmox API does not expose an exec endpoint for LXC containers. " - f"Use ssh_exec_command on the host node with " + f"Use ssh_run on the host node with " f"'pct exec {vmid} -- ' instead." ) } @@ -120,14 +73,18 @@ def register_system_tools(mcp: FastMCP, client: ProxmoxClient) -> None: """Register Proxmox system administration and command execution tools.""" @mcp.tool() - def proxmox_storage_status(node: str = "") -> dict[str, Any]: + def proxmox_storage_status( + node: str = "", + fields: list[str] | None = None, + ) -> dict[str, Any]: """Get storage status across the cluster: usage, type, content types. Use to check disk space, storage health, or find available storage. Omit 'node' to list storage from all configured nodes. + Pass ``fields=[...]`` to trim each entry to a subset of keys + (e.g. ``["name", "usage_pct"]``). Returns: {"storage": {"": [{name, type, content, enabled, used_gb, - total_gb, usage_pct}]}}. The storage pool name is in the 'name' field - of each entry. Per-node errors appear as {"error": "..."} entries. + total_gb, usage_pct}]}}. Per-node errors appear as {"error": "..."} entries. """ target_nodes = [node] if node else client.configured_nodes by_node: dict[str, list[dict[str, Any]]] = {} @@ -162,7 +119,7 @@ def proxmox_storage_status(node: str = "") -> dict[str, Any]: "total_gb": round(total / 1073741824, 1), "usage_pct": round(used / total * 100, 1) if total > 0 else 0, }) - by_node[n] = entries + by_node[n] = filter_fields(entries, fields) return {"storage": by_node} @@ -195,55 +152,25 @@ def proxmox_network_config(node: str) -> dict[str, Any]: return {"node": node, "interfaces": interfaces} - @mcp.tool() - async def proxmox_exec_command(node: str, vmid: int, command: str, timeout: int = 60) -> dict[str, Any]: - """Execute a command inside a QEMU VM (via QEMU Guest Agent) and wait for the result. - - Use for short-lived commands that complete within the timeout (default 60s, max 300s). - Returns stdout, stderr, and exit_code. - For long-running commands (apt upgrade, backups, etc.), use proxmox_exec_command_async instead. - For commands on the Proxmox host itself, use ssh_exec_command. - LXC containers have no API exec endpoint: use ssh_exec_command with 'pct exec -- '. - """ - timeout = min(timeout, 300) - vm_type = _detect_vm_type(client, node, vmid) - if not vm_type: - return {"error": f"VM/CT {vmid} not found on node '{node}'. Check VMID and node name."} - - if vm_type == "qemu": - return await _exec_qemu_sync(client, node, vmid, command, timeout) - return _exec_lxc_unsupported(vmid) + # ----------------------------------------------------------------------- + # proxmox_run: unified sync + async QEMU exec + # ----------------------------------------------------------------------- - @mcp.tool() - def proxmox_exec_command_async(node: str, vmid: int, command: str) -> dict[str, Any]: - """Start a long-running command inside a VM or container and return immediately. - - Use for commands that take more than 60 seconds (apt upgrade, database dumps, file transfers). - Returns an exec_id to track the command. Use proxmox_exec_get_result with that exec_id - to poll for completion and retrieve output. - """ + def _start_async_qemu(node: str, vmid: int, command: str) -> dict[str, Any]: + """Kick off a QEMU guest-agent command and track it in the session store.""" import shlex - vm_type = _detect_vm_type(client, node, vmid) - if not vm_type: - return {"error": f"VM/CT {vmid} not found on node '{node}'. Check VMID and node name."} - - if vm_type == "lxc": - # LXC has no API exec endpoint; surface the actionable error up-front. - return _exec_lxc_unsupported(vmid) - _prune_exec_sessions() exec_id = str(uuid.uuid4())[:8] session = ExecSession( exec_id=exec_id, node=node, vmid=vmid, - vm_type=vm_type, + vm_type="qemu", command=command, ) _exec_sessions[exec_id] = session - # Start via guest agent (command is an array: binary + args) parts = shlex.split(command) result = client.post(node, f"nodes/{node}/qemu/{vmid}/agent/exec", command=parts) if isinstance(result, dict) and "error" in result: @@ -251,33 +178,15 @@ def proxmox_exec_command_async(node: str, vmid: int, command: str) -> dict[str, session.stderr = str(result["error"]) return {"exec_id": exec_id, "status": "failed", "error": result["error"]} session.pid = result.get("pid") if isinstance(result, dict) else None + return {"exec_id": exec_id, "status": "running"} - return {"exec_id": exec_id, "status": "running", "vmid": vmid, "command": command} - - @mcp.tool() - def proxmox_exec_get_result(exec_id: str) -> dict[str, Any]: - """Get the result of an async command started with proxmox_exec_command_async. - - Provide the exec_id returned by proxmox_exec_command_async. - Returns status (running/completed/failed/timeout), stdout, stderr, and exit_code when done. - Call repeatedly to poll for completion. - """ + def _poll_session(exec_id: str) -> dict[str, Any]: + """Advance a tracked session if possible, return a status-shape dict.""" session = _exec_sessions.get(exec_id) if not session: - return {"error": f"No command found with exec_id '{exec_id}'. It may have expired or never existed."} - - if session.status != "running": - return { - "exec_id": exec_id, - "status": session.status, - "stdout": session.stdout, - "stderr": session.stderr, - "exit_code": session.exit_code, - "command": session.command, - } + return {"status": "error", "error": f"No command found with exec_id {exec_id!r}."} - # Poll QEMU guest agent - if session.vm_type == "qemu" and session.pid is not None: + if session.status == "running" and session.vm_type == "qemu" and session.pid is not None: status_data = client.get( session.node, f"nodes/{session.node}/qemu/{session.vmid}/agent/exec-status", @@ -295,16 +204,86 @@ def proxmox_exec_get_result(exec_id: str) -> dict[str, Any]: session.stderr = stderr session.exit_code = status_data.get("exitcode", -1) - # Check for timeout (10 min max for async) - if time.time() - session.started_at > 600: - session.status = "timeout" + if session.status == "running" and time.time() - session.started_at > 600: + session.status = "timeout" + elapsed = round(time.time() - session.started_at, 1) + if session.status == "running": + return { + "status": "running", + "exec_id": exec_id, + "command": session.command, + "elapsed_s": elapsed, + } return { + "status": "ok" if session.status == "completed" and session.exit_code == 0 else session.status, "exec_id": exec_id, - "status": session.status, + "command": session.command, "stdout": session.stdout, "stderr": session.stderr, "exit_code": session.exit_code, - "command": session.command, - "elapsed_s": round(time.time() - session.started_at), + "duration_s": elapsed, + } + + @mcp.tool() + def proxmox_run( + node: str = "", + vmid: int = 0, + command: str = "", + timeout: int = 60, + wait: bool = True, + exec_id: str = "", + ) -> dict[str, Any]: + """Run a command inside a QEMU VM via the Guest Agent. Handles sync + async in one tool. + + Three call patterns: + + - **Sync** (default): pass ``node``, ``vmid``, ``command``. Blocks up to + ``timeout`` seconds (max 600). Completes -> returns + ``stdout``/``stderr``/``exit_code``. Times out -> auto-switches to + async and returns ``{status: "running", exec_id}``. + - **Async start**: pass ``node``, ``vmid``, ``command``, ``wait=False``. + Returns ``{status: "running", exec_id}`` immediately. + - **Poll existing**: pass ``exec_id`` only. Returns the current + status/output for that session. + + LXC containers have no Guest Agent -- use ``ssh_run`` with + ``pct exec -- `` instead. For commands on the Proxmox host + itself (not inside a VM), use ``ssh_run`` directly. + """ + if exec_id: + return _poll_session(exec_id) + + if not command: + return {"status": "error", "error": "`command` is required when `exec_id` is not provided."} + if not node or not vmid: + return {"status": "error", "error": "`node` and `vmid` are required to start a command."} + + vm_type = _detect_vm_type(client, node, vmid) + if not vm_type: + return {"status": "error", "error": f"VM/CT {vmid} not found on node '{node}'."} + if vm_type == "lxc": + return _exec_lxc_unsupported(vmid) | {"status": "error"} + + started = _start_async_qemu(node, vmid, command) + if started.get("status") == "failed": + return started + new_id = started["exec_id"] + + if not wait: + return {"status": "running", "exec_id": new_id, "elapsed_s": 0} + + max_timeout = min(max(timeout, 1), 600) + deadline = time.time() + max_timeout + while time.time() < deadline: + result = _poll_session(new_id) + if result["status"] != "running": + return result + time.sleep(1) + # Timed out; hand back the session handle so caller can keep polling. + return { + "status": "running", + "exec_id": new_id, + "elapsed_s": int(time.time() - _exec_sessions[new_id].started_at), + "hint": "Command still running. Call proxmox_run(exec_id=...) to poll.", } diff --git a/src/beaconmcp/server.py b/src/beaconmcp/server.py index 1ca0b14..1c6b8b7 100644 --- a/src/beaconmcp/server.py +++ b/src/beaconmcp/server.py @@ -9,6 +9,7 @@ from .bmc import build_registry as build_bmc_registry from .bmc import register_bmc_tools from .config import Config +from .proxmox.aggregators import register_aggregator_tools from .proxmox.client import ProxmoxClient from .proxmox.monitoring import register_monitoring_tools from .proxmox.system import register_system_tools @@ -156,29 +157,40 @@ def beaconmcp_context() -> str: # tool calls that would 404. steps: list[str] = [] if config.pve_nodes: - steps.append("Check cluster state with proxmox_list_nodes.") - steps.append("For a specific node, use proxmox_node_status.") + steps.append( + "Start with cluster_overview for the whole cluster in one call, " + "or cluster_health(node=...) for node metrics + BMC + recent errors." + ) + steps.append( + "Drill in with proxmox_node_status / proxmox_list_vms as needed. " + "Pass fields=[...] on detail tools to trim the response." + ) + steps.append( + "Find a VM by name with vm_find('web-*'); act on many at once with " + "vm_bulk_action(vmids=[...], action='stop')." + ) if config.pve_nodes and config.ssh and config.ssh.hosts: steps.append( - "If a Proxmox node is unreachable via API, try ssh_exec_command " - "against the matching ssh.hosts entry." + "If a Proxmox node is unreachable via API, try ssh_run against " + "the matching ssh.hosts entry." ) if bmc_registry: steps.append( - "If a host is completely unresponsive, list BMC devices with " - "bmc_list_devices and use bmc_health_status / bmc_power_status." + "If a host is completely unresponsive, cluster_health already " + "includes BMC facts; otherwise use bmc_list_devices + " + "bmc_health_status / bmc_power_status." ) if config.pve_nodes and config.ssh and config.ssh.hosts: steps.append( - "For in-VM issues, prefer proxmox_exec_command (QEMU Guest Agent) " - "or ssh_exec_command." + "For in-VM issues, prefer proxmox_run (QEMU Guest Agent) or ssh_run. " + "Both auto-switch to async on timeout and accept exec_id for polling." ) elif config.pve_nodes: - steps.append("For in-VM issues, use proxmox_exec_command (QEMU Guest Agent).") + steps.append("For in-VM issues, use proxmox_run (QEMU Guest Agent).") elif config.ssh and config.ssh.hosts: steps.append( - "Use ssh_exec_command on declared hosts; start " - "ssh_exec_command_async for anything that may exceed 60s." + "Use ssh_run on declared hosts. Pass wait=False for long commands; " + "poll with ssh_run(exec_id=...)." ) workflow = "\n".join(f"{i}. {s}" for i, s in enumerate(steps, 1)) or "(no workflow: no capabilities configured)" @@ -213,6 +225,9 @@ def beaconmcp_context() -> str: register_monitoring_tools(mcp, proxmox_client) register_vm_tools(mcp, proxmox_client) register_system_tools(mcp, proxmox_client) + # Aggregators ride on top of the Proxmox client and opportunistically + # pull BMC facts when the registry is non-empty. + register_aggregator_tools(mcp, proxmox_client, config, bmc_registry) if config.ssh and config.ssh.hosts: register_ssh_tools(mcp, ssh_client) if bmc_registry: diff --git a/src/beaconmcp/ssh/tools.py b/src/beaconmcp/ssh/tools.py index b3339e0..f0c0c98 100644 --- a/src/beaconmcp/ssh/tools.py +++ b/src/beaconmcp/ssh/tools.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import time from typing import Any @@ -8,73 +9,98 @@ from .client import SSHClient, SSHHostResolutionError, SSHNotConfiguredError +def _session_to_result(exec_id: str, session) -> dict[str, Any]: + """Turn an SSH session row into a ``proxmox_run``-shaped response.""" + elapsed = round(time.time() - session.started_at, 1) + if session.status == "running": + return { + "status": "running", + "exec_id": exec_id, + "host": session.host, + "command": session.command, + "elapsed_s": elapsed, + } + return { + "status": "ok" if session.status == "completed" and session.exit_code == 0 else session.status, + "exec_id": exec_id, + "host": session.host, + "command": session.command, + "stdout": session.stdout, + "stderr": session.stderr, + "exit_code": session.exit_code, + "duration_s": elapsed, + } + + def register_ssh_tools(mcp: FastMCP, ssh_client: SSHClient) -> None: """Register SSH command execution tools.""" @mcp.tool() - async def ssh_exec_command(host: str, command: str, timeout: int = 60) -> dict[str, Any]: - """Execute a command on a host via SSH and wait for the result. + async def ssh_run( + host: str = "", + command: str = "", + timeout: int = 60, + wait: bool = True, + exec_id: str = "", + ) -> dict[str, Any]: + """Run a command on a host via SSH. Handles sync + async in one tool. - Use as a fallback when the Proxmox API is unavailable, or to run commands - directly on a Proxmox host (not inside a VM -- use proxmox_exec_command for that). - 'host' can be a node name (pve1), a VMID (101 -> 192.168.1.101), or a direct IP/hostname. - Timeout defaults to 60s (max 300s). For long commands, use ssh_exec_command_async. - """ - timeout = min(timeout, 300) - try: - return await ssh_client.exec_command(host, command, timeout) - except (SSHNotConfiguredError, SSHHostResolutionError) as e: - return {"error": str(e)} + Three call patterns: - @mcp.tool() - async def ssh_exec_command_async(host: str, command: str) -> dict[str, Any]: - """Start a long-running SSH command and return immediately with an exec_id. + - **Sync** (default): pass ``host`` + ``command``. Blocks up to + ``timeout`` seconds (max 600). Completes -> returns + ``stdout``/``stderr``/``exit_code``. Times out -> auto-switches to + async and returns ``{status: "running", exec_id}``. + - **Async start**: ``host`` + ``command`` + ``wait=False``. + Returns ``{status: "running", exec_id}`` immediately. + - **Poll existing**: pass ``exec_id`` only. Returns the current + status/output for that session. - Use for commands that take more than 60 seconds (updates, large file operations, etc.). - Returns an exec_id. Use ssh_exec_get_result to poll for completion. - 'host' can be a node name (pve1), a VMID (101), or a direct IP/hostname. + ``host`` accepts a node name (``pve1``), a VMID template match + (``101`` -> ``192.168.1.101`` if ``vmid_to_ip`` is configured), or a + direct IP/hostname declared under ``ssh.hosts[]``. """ + if exec_id: + session = SSHClient.get_session(exec_id) + if not session: + return {"status": "error", "error": f"No SSH command with exec_id {exec_id!r}."} + return _session_to_result(exec_id, session) + + if not host or not command: + return {"status": "error", "error": "`host` and `command` are required when `exec_id` is not provided."} + + max_timeout = min(max(timeout, 1), 600) + try: - exec_id = await ssh_client.exec_command_async(host, command) - return { - "exec_id": exec_id, - "status": "running", - "host": host, - "resolved": ssh_client.resolve_host(host), - "command": command, - } + new_id = await ssh_client.exec_command_async(host, command) except (SSHNotConfiguredError, SSHHostResolutionError) as e: - return {"error": str(e)} + return {"status": "error", "error": str(e)} - @mcp.tool() - def ssh_exec_get_result(exec_id: str) -> dict[str, Any]: - """Get the result of an async SSH command started with ssh_exec_command_async. + if not wait: + return {"status": "running", "exec_id": new_id, "host": host, "elapsed_s": 0} - Provide the exec_id returned by ssh_exec_command_async. - Returns status (running/completed/failed/timeout), stdout, stderr, and exit_code. - Call repeatedly to poll for completion. - """ - session = SSHClient.get_session(exec_id) - if not session: - return {"error": f"No SSH command found with exec_id '{exec_id}'."} + deadline = time.time() + max_timeout + while time.time() < deadline: + session = SSHClient.get_session(new_id) + if session and session.status != "running": + return _session_to_result(new_id, session) + await asyncio.sleep(0.5) + + session = SSHClient.get_session(new_id) + if session and session.status != "running": + return _session_to_result(new_id, session) return { - "exec_id": exec_id, - "host": session.host, - "command": session.command, - "status": session.status, - "stdout": session.stdout, - "stderr": session.stderr, - "exit_code": session.exit_code, - "elapsed_s": round(time.time() - session.started_at) - if session.status == "running" - else None, + "status": "running", + "exec_id": new_id, + "host": host, + "elapsed_s": int(time.time() - (session.started_at if session else time.time())), + "hint": "Command still running. Call ssh_run(exec_id=...) to poll.", } @mcp.tool() def ssh_list_sessions() -> dict[str, Any]: """List all active and recent SSH command sessions. - Use to check what SSH commands are running or have completed. Returns exec_id, host, command, status, and elapsed time for each session. """ sessions = SSHClient.list_sessions() diff --git a/src/beaconmcp/utils.py b/src/beaconmcp/utils.py new file mode 100644 index 0000000..457d1b0 --- /dev/null +++ b/src/beaconmcp/utils.py @@ -0,0 +1,103 @@ +"""Shared response-shaping helpers for BeaconMCP tools. + +These helpers exist so individual tool modules don't each reimplement the two +cross-cutting patterns BeaconMCP relies on to stay token-efficient: + +* ``filter_fields`` lets callers trim tool output to the keys they need, cutting + the payload on the wire without forcing the server to ship a separate tool for + every projection. +* ``parse_since`` lets any time-windowed tool (``proxmox_get_tasks`` etc.) + accept either a relative duration (``"15m"``, ``"2h"``) or an absolute + epoch/ISO timestamp. +""" + +from __future__ import annotations + +import re +import time +from datetime import datetime, timezone +from typing import Any + + +def filter_fields(data: Any, fields: list[str] | None) -> Any: + """Return ``data`` trimmed to only the keys listed in ``fields``. + + - ``fields`` None / empty -> data is returned unchanged. + - dict -> returns a new dict with only the requested keys + (missing keys are skipped silently). + - list of dicts -> applies the same filter to every element. + - everything else -> returned unchanged (ints, strings, None, ...). + + Design notes: + * Missing keys are silently dropped rather than raising so callers can share + one ``fields`` list across tools that return slightly different shapes. + * Nested dicts/lists are kept as-is; this is a single-level projection on + purpose so callers keep predictable output shape. + """ + if not fields: + return data + keep = set(fields) + if isinstance(data, dict): + return {k: v for k, v in data.items() if k in keep} + if isinstance(data, list): + return [ + {k: v for k, v in item.items() if k in keep} + if isinstance(item, dict) + else item + for item in data + ] + return data + + +_SINCE_RE = re.compile(r"^\s*(\d+)\s*([smhd])\s*$", re.IGNORECASE) + + +def parse_since(value: Any, now: float | None = None) -> int | None: + """Parse a ``since`` argument into an epoch-seconds lower bound. + + Accepted forms: + * None / "" / 0 -> returns None (no lower bound). + * "" -> duration relative to ``now``. Units: s/m/h/d. + e.g. ``"15m"`` -> now - 900. + * int or numeric str -> treated as a unix epoch in seconds. + * ISO-8601 string -> parsed via ``datetime.fromisoformat``; naive values + are interpreted as UTC. + + Raises ``ValueError`` on anything else, so tools can surface a clean error + to the caller instead of silently misinterpreting input. + """ + if value in (None, "", 0): + return None + + current = now if now is not None else time.time() + + if isinstance(value, (int, float)): + return int(value) + + if isinstance(value, str): + match = _SINCE_RE.match(value) + if match: + n = int(match.group(1)) + unit = match.group(2).lower() + mult = {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit] + return int(current - n * mult) + + # Numeric epoch as a string. + if value.strip().isdigit(): + return int(value.strip()) + + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError( + f"Unrecognized 'since' value {value!r}. " + "Expected a duration like '15m'/'2h'/'1d', a unix epoch, or an ISO-8601 timestamp." + ) from exc + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return int(dt.timestamp()) + + raise ValueError( + f"Unsupported 'since' type {type(value).__name__}. " + "Expected str, int, or float." + ) diff --git a/tests/test_integration.py b/tests/test_integration.py index 4693b26..d09c881 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -299,7 +299,7 @@ def test_proxmox_exec(runner: TestRunner, tools: dict) -> None: if not running_qemu: runner.record( - "proxmox_exec_command (skipped: no running QEMU VM)", + "proxmox_run (skipped: no running QEMU VM)", True, "Need a running QEMU VM with guest agent to test exec", ) @@ -309,33 +309,33 @@ def test_proxmox_exec(runner: TestRunner, tools: dict) -> None: print(f" Using VMID {vmid} ({running_qemu.get('name', '?')}) for exec tests") # T9: Sync exec -- simple command - result = call_tool(tools, "proxmox_exec_command", node="pve1", vmid=vmid, + result = call_tool(tools, "proxmox_run", node="pve1", vmid=vmid, command="echo BeaconMCP-test", timeout=30) runner.record( - f"proxmox_exec_command 'echo' in VM {vmid}", - isinstance(result, dict) and "BeaconMCP-test" in result.get("stdout", ""), + f"proxmox_run 'echo' in VM {vmid}", + isinstance(result, dict) and result.get("status") == "ok" and "BeaconMCP-test" in result.get("stdout", ""), f"Expected stdout containing 'BeaconMCP-test', got: {result}", result, ) # T10: Sync exec -- exit code - result = call_tool(tools, "proxmox_exec_command", node="pve1", vmid=vmid, + result = call_tool(tools, "proxmox_run", node="pve1", vmid=vmid, command="cat /etc/hostname", timeout=30) runner.record( - f"proxmox_exec_command 'cat /etc/hostname' returns exit_code 0", + f"proxmox_run 'cat /etc/hostname' returns exit_code 0", isinstance(result, dict) and result.get("exit_code") == 0, f"exit_code = {result.get('exit_code')}, stdout = {result.get('stdout', '')[:100]}", result, ) - # T11: Async exec + poll - result = call_tool(tools, "proxmox_exec_command_async", node="pve1", vmid=vmid, - command="sleep 3 && echo async-done") + # T11: Async start + poll via unified proxmox_run + result = call_tool(tools, "proxmox_run", node="pve1", vmid=vmid, + command="sleep 3 && echo async-done", wait=False) has_exec_id = isinstance(result, dict) and "exec_id" in result runner.record( - f"proxmox_exec_command_async returns exec_id", + "proxmox_run(wait=False) returns exec_id", has_exec_id and result.get("status") == "running", - f"Expected status=running with exec_id", + "Expected status=running with exec_id", result, ) @@ -345,30 +345,30 @@ def test_proxmox_exec(runner: TestRunner, tools: dict) -> None: deadline = time.time() + 30 final_result = None while time.time() < deadline: - final_result = call_tool(tools, "proxmox_exec_get_result", exec_id=exec_id) + final_result = call_tool(tools, "proxmox_run", exec_id=exec_id) if isinstance(final_result, dict) and final_result.get("status") != "running": break time.sleep(2) runner.record( - f"proxmox_exec_get_result returns completed result", - isinstance(final_result, dict) and final_result.get("status") == "completed", + "proxmox_run(exec_id=...) returns completed result", + isinstance(final_result, dict) and final_result.get("status") == "ok", f"Final status: {final_result.get('status') if final_result else 'none'}", final_result, ) - if isinstance(final_result, dict) and final_result.get("status") == "completed": + if isinstance(final_result, dict) and final_result.get("status") == "ok": runner.record( - "Async exec stdout contains 'async-done'", + "Async stdout contains 'async-done'", "async-done" in final_result.get("stdout", ""), f"stdout = {final_result.get('stdout', '')[:100]}", ) # T12: Exec on non-existent VM - result = call_tool(tools, "proxmox_exec_command", node="pve1", vmid=99999, + result = call_tool(tools, "proxmox_run", node="pve1", vmid=99999, command="echo test", timeout=10) runner.record( - "proxmox_exec_command on invalid VMID returns error", - isinstance(result, dict) and "error" in result, + "proxmox_run on invalid VMID returns error", + isinstance(result, dict) and result.get("status") == "error", f"Expected error, got: {result}", result, ) @@ -399,14 +399,14 @@ def test_proxmox_exec_lxc(runner: TestRunner, tools: dict) -> None: vmid = running_lxc["vmid"] print(f" Using CT {vmid} ({running_lxc.get('name', '?')}) for LXC exec tests") - result = call_tool(tools, "proxmox_exec_command", node="pve1", vmid=vmid, + result = call_tool(tools, "proxmox_run", node="pve1", vmid=vmid, command="echo LXC-test", timeout=30) # The Proxmox API does not expose an exec endpoint for LXC containers; the - # tool must return an actionable error pointing to ssh_exec_command + pct exec. + # tool must return an actionable error pointing to ssh_run + pct exec. runner.record( - f"proxmox_exec_command on CT {vmid} returns LXC-not-supported guidance", + f"proxmox_run on CT {vmid} returns LXC-not-supported guidance", isinstance(result, dict) - and "error" in result + and result.get("status") == "error" and "pct exec" in result.get("error", ""), f"Result: {result}", result, @@ -525,7 +525,7 @@ def test_proxmox_vm_lifecycle(runner: TestRunner, tools: dict, test_vmid: int | def test_ssh(runner: TestRunner, tools: dict) -> None: runner.section("SSH Module") - if "ssh_exec_command" not in tools: + if "ssh_run" not in tools: runner.record( "SSH tests (skipped: SSH not configured)", True, @@ -547,37 +547,37 @@ def test_ssh(runner: TestRunner, tools: dict) -> None: ssh_target = _cfg.ssh.hosts[0].name # T20: SSH exec - result = call_tool(tools, "ssh_exec_command", host=ssh_target, command="uptime", timeout=30) + result = call_tool(tools, "ssh_run", host=ssh_target, command="uptime", timeout=30) runner.record( - f"ssh_exec_command 'uptime' on {ssh_target}", - isinstance(result, dict) and result.get("exit_code") == 0 and "load average" in result.get("stdout", ""), + f"ssh_run 'uptime' on {ssh_target}", + isinstance(result, dict) and result.get("status") == "ok" and "load average" in result.get("stdout", ""), f"Result: {result}", result, ) # T21: SSH exec -- hostname - result = call_tool(tools, "ssh_exec_command", host=ssh_target, command="hostname", timeout=15) + result = call_tool(tools, "ssh_run", host=ssh_target, command="hostname", timeout=15) runner.record( - f"ssh_exec_command 'hostname' on {ssh_target}", - isinstance(result, dict) and result.get("exit_code") == 0 and len(result.get("stdout", "").strip()) > 0, + f"ssh_run 'hostname' on {ssh_target}", + isinstance(result, dict) and result.get("status") == "ok" and len(result.get("stdout", "").strip()) > 0, f"stdout = '{result.get('stdout', '').strip()}'", result, ) # T22: SSH exec -- df (disk usage) - result = call_tool(tools, "ssh_exec_command", host=ssh_target, command="df -h /", timeout=15) + result = call_tool(tools, "ssh_run", host=ssh_target, command="df -h /", timeout=15) runner.record( - f"ssh_exec_command 'df -h /' on {ssh_target}", - isinstance(result, dict) and result.get("exit_code") == 0, + f"ssh_run 'df -h /' on {ssh_target}", + isinstance(result, dict) and result.get("status") == "ok", f"exit_code = {result.get('exit_code')}", result, ) # T23: SSH exec -- failing command - result = call_tool(tools, "ssh_exec_command", host=ssh_target, + result = call_tool(tools, "ssh_run", host=ssh_target, command="cat /nonexistent/file/12345", timeout=15) runner.record( - "ssh_exec_command on nonexistent file returns non-zero exit code", + "ssh_run on nonexistent file returns non-zero exit code", isinstance(result, dict) and result.get("exit_code", 0) != 0, f"exit_code = {result.get('exit_code')}, stderr = {result.get('stderr', '')[:100]}", result, @@ -613,12 +613,12 @@ def test_ssh(runner: TestRunner, tools: dict) -> None: "", ) - # T25: SSH async exec + poll - result = call_tool(tools, "ssh_exec_command_async", host=ssh_target, - command="sleep 2 && echo ssh-async-done") + # T25: SSH async start + poll via unified ssh_run + result = call_tool(tools, "ssh_run", host=ssh_target, + command="sleep 2 && echo ssh-async-done", wait=False) has_id = isinstance(result, dict) and "exec_id" in result runner.record( - "ssh_exec_command_async returns exec_id", + "ssh_run(wait=False) returns exec_id", has_id, f"Result: {result}", result, @@ -630,13 +630,13 @@ def test_ssh(runner: TestRunner, tools: dict) -> None: deadline = time.time() + 30 final = None while time.time() < deadline: - final = call_tool(tools, "ssh_exec_get_result", exec_id=exec_id) + final = call_tool(tools, "ssh_run", exec_id=exec_id) if isinstance(final, dict) and final.get("status") != "running": break time.sleep(2) runner.record( - "ssh_exec_get_result returns completed", - isinstance(final, dict) and final.get("status") == "completed", + "ssh_run(exec_id=...) returns completed", + isinstance(final, dict) and final.get("status") == "ok", f"Final: {final}", final, ) @@ -766,6 +766,65 @@ def test_mcp_resources(runner: TestRunner) -> None: ) +def test_aggregators(runner: TestRunner, tools: dict) -> None: + runner.section("Aggregators & Field Filtering") + + # A1: cluster_overview returns nodes + vms + storage in one call + result = call_tool(tools, "cluster_overview") + runner.record( + "cluster_overview returns nodes, vms, storage", + isinstance(result, dict) + and "nodes" in result and "vms" in result and "storage" in result, + f"Keys: {list(result.keys()) if isinstance(result, dict) else type(result).__name__}", + result, + ) + + # A2: cluster_overview(include_storage=False) omits storage + result = call_tool(tools, "cluster_overview", include_storage=False) + runner.record( + "cluster_overview(include_storage=False) omits storage", + isinstance(result, dict) and "nodes" in result and "vms" in result + and "storage" not in result, + f"storage key present: {'storage' in result if isinstance(result, dict) else 'N/A'}", + result, + ) + + # A3: cluster_health on first configured node + from beaconmcp.server import config as _cfg + first_node = _cfg.pve_nodes[0].name if _cfg.pve_nodes else "" + if first_node: + result = call_tool(tools, "cluster_health", node=first_node) + runner.record( + f"cluster_health(node='{first_node}') returns metrics + errors", + isinstance(result, dict) and ("cpu_pct" in result or "error" in result), + f"Keys: {list(result.keys()) if isinstance(result, dict) else type(result).__name__}", + result, + ) + + # A4: vm_find wildcard + result = call_tool(tools, "vm_find", pattern="*") + runner.record( + "vm_find('*') returns vms list", + isinstance(result, dict) and "vms" in result and "total" in result, + f"Got {result.get('total') if isinstance(result, dict) else 0} matches", + result, + ) + + # A5: fields filter on proxmox_list_nodes + result = call_tool(tools, "proxmox_list_nodes", fields=["name", "status"]) + ok = ( + isinstance(result, dict) + and isinstance(result.get("nodes"), list) + and all(set(n.keys()).issubset({"name", "status", "error"}) for n in result["nodes"]) + ) + runner.record( + "proxmox_list_nodes(fields=['name','status']) trims response", + ok, + f"Sample: {result['nodes'][:1] if isinstance(result, dict) and result.get('nodes') else 'empty'}", + result, + ) + + def test_error_handling(runner: TestRunner, tools: dict) -> None: runner.section("Error Handling") @@ -787,8 +846,8 @@ def test_error_handling(runner: TestRunner, tools: dict) -> None: result, ) - # T37: Invalid exec_id - result = call_tool(tools, "proxmox_exec_get_result", exec_id="nonexistent") + # T37: Invalid exec_id (via unified proxmox_run poll mode) + result = call_tool(tools, "proxmox_run", exec_id="nonexistent") runner.record( "Invalid exec_id returns error", isinstance(result, dict) and "error" in result, @@ -796,8 +855,8 @@ def test_error_handling(runner: TestRunner, tools: dict) -> None: result, ) - if "ssh_exec_get_result" in tools: - result = call_tool(tools, "ssh_exec_get_result", exec_id="nonexistent") + if "ssh_run" in tools: + result = call_tool(tools, "ssh_run", exec_id="nonexistent") runner.record( "Invalid SSH exec_id returns error", isinstance(result, dict) and "error" in result, @@ -835,6 +894,7 @@ def main() -> None: test_proxmox_exec(runner, tools) test_proxmox_exec_lxc(runner, tools) test_proxmox_vm_lifecycle(runner, tools, args.test_vmid) + test_aggregators(runner, tools) test_error_handling(runner, tools) if sections in ("ssh", "all"): diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..510b081 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,70 @@ +"""Unit tests for beaconmcp.utils (filter_fields, parse_since).""" + +from __future__ import annotations + +import time + +import pytest + +from beaconmcp.utils import filter_fields, parse_since + + +def test_filter_fields_none_returns_input() -> None: + assert filter_fields({"a": 1, "b": 2}, None) == {"a": 1, "b": 2} + + +def test_filter_fields_empty_returns_input() -> None: + assert filter_fields({"a": 1}, []) == {"a": 1} + + +def test_filter_fields_dict_trims_keys() -> None: + assert filter_fields({"a": 1, "b": 2, "c": 3}, ["a", "c"]) == {"a": 1, "c": 3} + + +def test_filter_fields_dict_missing_keys_silently_dropped() -> None: + assert filter_fields({"a": 1}, ["a", "missing"]) == {"a": 1} + + +def test_filter_fields_list_of_dicts() -> None: + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + assert filter_fields(data, ["a"]) == [{"a": 1}, {"a": 3}] + + +def test_filter_fields_non_dict_values_passthrough() -> None: + data = [{"a": 1}, "not-a-dict", 42] + assert filter_fields(data, ["a"]) == [{"a": 1}, "not-a-dict", 42] + + +def test_filter_fields_primitive_passthrough() -> None: + assert filter_fields(42, ["anything"]) == 42 + assert filter_fields("str", ["anything"]) == "str" + assert filter_fields(None, ["anything"]) is None + + +def test_parse_since_none_returns_none() -> None: + assert parse_since(None) is None + assert parse_since("") is None + assert parse_since(0) is None + + +def test_parse_since_duration_units() -> None: + now = 1_000_000.0 + assert parse_since("15m", now=now) == int(now - 900) + assert parse_since("2h", now=now) == int(now - 7200) + assert parse_since("1d", now=now) == int(now - 86400) + assert parse_since("30s", now=now) == int(now - 30) + + +def test_parse_since_numeric_epoch() -> None: + assert parse_since(1_700_000_000) == 1_700_000_000 + assert parse_since("1700000000") == 1_700_000_000 + + +def test_parse_since_iso8601() -> None: + # 2024-01-01T00:00:00Z == 1704067200 + assert parse_since("2024-01-01T00:00:00Z") == 1_704_067_200 + + +def test_parse_since_invalid_raises() -> None: + with pytest.raises(ValueError): + parse_since("not-a-duration") From 4fb14bd91968bc1b10b45929b152fa920f22b183 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 19:45:58 +0200 Subject: [PATCH 080/155] dashboard: interleave assistant text between tool calls --- src/beaconmcp/dashboard/chat.py | 305 +++++++++++++++++++++++++++++--- tests/test_dashboard_unit.py | 74 ++++++++ 2 files changed, 351 insertions(+), 28 deletions(-) diff --git a/src/beaconmcp/dashboard/chat.py b/src/beaconmcp/dashboard/chat.py index ae10b29..b29395a 100644 --- a/src/beaconmcp/dashboard/chat.py +++ b/src/beaconmcp/dashboard/chat.py @@ -142,12 +142,16 @@ class UsageAccumulated: # Tool names that MUST go through a human approval step before we run # them from a Gemini turn. Anything that can fire arbitrary shell on a -# host or VM (SSH directly, QEMU Guest Agent exec via proxmox_exec_*) -# belongs here -- otherwise a single compromised/confused turn could -# rm -rf a production box. ``proxmox_exec_get_result`` is read-only so -# it stays unconfirmed. Keep this list tight; every entry adds a modal -# click to the UX. +# host or VM (SSH directly, QEMU Guest Agent exec via proxmox_run) belongs +# here -- otherwise a single compromised/confused turn could rm -rf a +# production box. Legacy ``*_exec_command*`` names are kept for +# defense-in-depth in case an older MCP server is still wired up. +# Keep this list tight; every entry adds a modal click to the UX. _NEEDS_CONFIRMATION: frozenset[str] = frozenset({ + # Current (unified) tools. + "ssh_run", + "proxmox_run", + # Legacy names (pre-unified tools) -- kept defensively. "ssh_exec_command", "ssh_exec_command_async", "proxmox_exec_command", @@ -155,6 +159,26 @@ class UsageAccumulated: }) +def _tool_call_requires_confirmation(name: str, args: Any) -> bool: + """Return True when a tool call needs human approval before running. + + The unified ``ssh_run`` / ``proxmox_run`` tools have three call patterns + (sync start, async start, poll existing by ``exec_id``). Only the start + patterns actually execute shell; a pure poll call -- where ``exec_id`` + is set and ``command`` is not -- is read-only and must not trigger a + confirmation modal. We keep the allow-list name-based for everything + else, then peel off the poll case here. + """ + if name not in _NEEDS_CONFIRMATION: + return False + if name in {"ssh_run", "proxmox_run"} and isinstance(args, dict): + exec_id = args.get("exec_id") + command = args.get("command") + if exec_id and not command: + return False + return True + + # --------------------------------------------------------------------------- # Turn input # --------------------------------------------------------------------------- @@ -274,12 +298,12 @@ class GeminiChatEngine: """Gemini-backed engine that orchestrates MCP tool calls in-process. We open a local MCP ``ClientSession`` against the BeaconMCP endpoint - using the user's bearer, and pass that session to google-genai as a - tool. The SDK auto-discovers the tools, handles function-call / - function-response bookkeeping, and emits text / function_call / - function_response parts through the streaming API. This path does - NOT use the ``McpServer`` remote tool (where Google's backend calls - our MCP directly) — that feature is preview-gated and returned + using the user's bearer, and run a manual function-calling loop over + MCP declarations. For Gemini 3 models we also enable the built-in + Google Search tool and surface its server-side tool invocations in + the same dashboard tool timeline. This path does NOT use the + ``McpServer`` remote tool (where Google's backend calls our MCP + directly) — that feature is preview-gated and returned ``PERMISSION_DENIED`` on standard API keys. """ @@ -388,6 +412,7 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: tool_starts: dict[str, float] = {} seen_tool_ids: set[str] = set() + server_tool_name_by_id: dict[str, str] = {} # Accumulated across every ``generate_content_stream`` round in # this turn so the dashboard can charge the client once per turn. @@ -483,7 +508,9 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: # it through as Gemini's system_instruction so the model # grounds its answers on what the server actually exposes # instead of hallucinating from tool-name shapes. - server_instructions = (init_result.instructions or "").strip() or None + server_instructions = _compose_system_instruction( + (init_result.instructions or "").strip() or None + ) try: tools_result = await session.list_tools() @@ -512,14 +539,45 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: ] tools_cfg = [types.Tool(function_declarations=function_decls)] - config = types.GenerateContentConfig( - system_instruction=server_instructions, - thinking_config=thinking, - tools=tools_cfg, - automatic_function_calling=( + # Gemini web access: enable built-in Google Search on + # Gemini 3+ only. 2.5 models do not reliably support + # multi-tool combination in this request path. + if _is_gemini_3(turn.model): + web_tool = _build_google_search_tool(types) + if web_tool is not None: + tools_cfg.append(web_tool) + + config_kwargs: dict[str, Any] = { + "system_instruction": server_instructions, + "thinking_config": thinking, + "tools": tools_cfg, + "automatic_function_calling": ( types.AutomaticFunctionCallingConfig(disable=True) ), - ) + } + + # Ask Gemini to include server-side tool invocation parts + # (tool_call/tool_response) in streamed chunks so the UI + # can display web-search calls like regular tool cards. + tool_config_cls = getattr(types, "ToolConfig", None) + if tool_config_cls is not None: + try: + config_kwargs["tool_config"] = tool_config_cls( + include_server_side_tool_invocations=True, + ) + except TypeError: + config_kwargs[ + "include_server_side_tool_invocations" + ] = True + + try: + config = types.GenerateContentConfig(**config_kwargs) + except TypeError: + config_kwargs.pop( + "include_server_side_tool_invocations", None, + ) + config = types.GenerateContentConfig(**config_kwargs) + client = self._ensure_client() current_contents = list(contents) @@ -531,7 +589,7 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: ) model_parts: list = [] - fc_invocations: list = [] # (fc_part, fc_id) + fc_invocations: list = [] # (fc_part, fc_id, args) last_usage: Any = None async for chunk in stream: um = getattr(chunk, "usage_metadata", None) @@ -563,16 +621,69 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: getattr(fc, "id", None) or f"fc_{len(seen_tool_ids)}" ) - seen_tool_ids.add(fc_id) - tool_starts[fc_id] = time.monotonic() args = dict(getattr(fc, "args", None) or {}) fc_invocations.append((fc, fc_id, args)) + + # Server-side built-in tool invocation + # (e.g. Google Search). These are emitted by + # Gemini when include_server_side_tool_invocations + # is enabled and should be rendered as regular + # tool cards in the dashboard. + tc = getattr(part, "tool_call", None) + if tc: + tc_id = ( + getattr(tc, "id", None) + or f"tc_{len(seen_tool_ids)}" + ) + if tc_id not in seen_tool_ids: + seen_tool_ids.add(tc_id) + tool_starts[tc_id] = time.monotonic() + tool_name = _tool_name_from_server_tool_type( + getattr(tc, "tool_type", None), + ) + server_tool_name_by_id[tc_id] = tool_name + raw_args = _normalize_json_like( + getattr(tc, "args", None) + ) + args = raw_args if isinstance(raw_args, dict) else {} + yield ToolCallStart( + id=tc_id, + name=tool_name, + args=args, + ) model_parts.append(part) - yield ToolCallStart( - id=fc_id, - name=getattr(fc, "name", "?"), - args=args, + + tr = getattr(part, "tool_response", None) + if tr: + tr_id = getattr(tr, "id", None) or "" + tr_id = str(tr_id) if tr_id else f"tr_{len(seen_tool_ids)}" + tool_name = server_tool_name_by_id.get(tr_id) + if not tool_name: + tool_name = _tool_name_from_server_tool_type( + getattr(tr, "tool_type", None), + ) + payload = _normalize_json_like( + getattr(tr, "response", None) ) + if tr_id not in seen_tool_ids: + # If we only got a response part, still + # synthesize a start card so the timeline + # remains coherent. + seen_tool_ids.add(tr_id) + yield ToolCallStart( + id=tr_id, + name=tool_name, + args={}, + ) + start = tool_starts.pop(tr_id, time.monotonic()) + status = "error" if _tool_response_is_error(payload) else "ok" + yield ToolCallEnd( + id=tr_id, + status=status, + preview=_short_preview(payload), + duration_ms=int((time.monotonic() - start) * 1000), + ) + model_parts.append(part) if last_usage is not None: usage_total_prompt += int( @@ -594,17 +705,34 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: ) return # model is done + selected_fc = fc_invocations[:_MAX_FUNCTION_CALLS_PER_ROUND] + if len(fc_invocations) > len(selected_fc): + _logger.info( + "gemini chat: model emitted %d function calls in one round; " + "executing %d to preserve text/tool interleaving", + len(fc_invocations), + len(selected_fc), + ) + model_parts.extend(fc for fc, _fc_id, _args in selected_fc) + current_contents.append( types.Content(role="model", parts=model_parts) ) response_parts: list = [] - for fc, fc_id, args in fc_invocations: + for fc, fc_id, args in selected_fc: name = getattr(fc, "name", "") + seen_tool_ids.add(fc_id) + tool_starts[fc_id] = time.monotonic() + yield ToolCallStart( + id=fc_id, + name=name or "?", + args=args, + ) start = tool_starts.pop(fc_id, time.monotonic()) approved = True - if name in _NEEDS_CONFIRMATION: + if _tool_call_requires_confirmation(name, args): req = ToolConfirmRequired( id=fc_id, name=name, args=args, ) @@ -735,11 +863,28 @@ async def title(self, *, model: str, user_text: str) -> str | None: # Max rounds of function_call / function_response before we give up. -# Each round is one generate_content_stream + one batch of tool calls. +# Each round is one generate_content_stream + one tool call. # 10 is comfortably above realistic orchestration depth while still # bounding run-away loops. _MAX_TOOL_ROUNDS = 10 +# Keep MCP tool orchestration conversational: one tool at a time lets +# the model add a short sentence between calls (Copilot/Claude style). +_MAX_FUNCTION_CALLS_PER_ROUND = 1 + + +def _compose_system_instruction(server_instructions: str | None) -> str: + """Compose dashboard system instructions for interleaved tool usage.""" + base = ( + "When tools are needed, never batch multiple MCP function calls in one reply. " + "Before each tool call, first send one short natural-language sentence " + "about what you are about to check. Then emit exactly one function_call " + "and wait for its result before deciding the next step." + ) + if server_instructions: + return f"{base}\n\n{server_instructions}" + return base + def _iter_parts(chunk: Any): """Yield every ``Part`` from a google-genai stream chunk.""" @@ -802,6 +947,110 @@ def _mcp_call_result_to_response(result: Any) -> dict: return out +def _build_google_search_tool(types_mod: Any) -> Any | None: + """Return a google-genai built-in web-search tool when available. + + SDK type names changed across releases (``GoogleSearch`` vs + ``ToolGoogleSearch``). We support both to keep the dashboard forward + compatible with minor library updates. + """ + tool_cls = getattr(types_mod, "Tool", None) + if tool_cls is None: + return None + + for cls_name in ("GoogleSearch", "ToolGoogleSearch", "WebSearch"): + search_cls = getattr(types_mod, cls_name, None) + if search_cls is None: + continue + try: + return tool_cls(google_search=search_cls()) + except TypeError: + continue + + # Older SDK variants expose retrieval style search. + gsr_cls = getattr(types_mod, "GoogleSearchRetrieval", None) + if gsr_cls is not None: + try: + return tool_cls(google_search_retrieval=gsr_cls()) + except TypeError: + return None + return None + + +def _normalize_json_like(value: Any) -> Any: + """Best-effort conversion of SDK value objects into plain JSON-ish data.""" + if value is None or isinstance(value, (str, int, float, bool, list, dict)): + return value + + to_dict = getattr(value, "to_json_dict", None) + if callable(to_dict): + try: + return to_dict() + except Exception: # noqa: BLE001 + pass + + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + try: + return model_dump() + except Exception: # noqa: BLE001 + pass + + raw = getattr(value, "__dict__", None) + if isinstance(raw, dict): + return { + str(k): _normalize_json_like(v) + for k, v in raw.items() + if not str(k).startswith("_") + } + return str(value) + + +def _normalize_server_tool_type(tool_type: Any) -> str: + if tool_type is None: + return "TOOL_TYPE_UNSPECIFIED" + text = str(tool_type) + if "." in text: + text = text.split(".")[-1] + return text.upper() + + +def _tool_name_from_server_tool_type(tool_type: Any) -> str: + """Map SDK server-side tool type enums to stable dashboard names.""" + normalized = _normalize_server_tool_type(tool_type) + mapping = { + "GOOGLE_SEARCH_WEB": "google_search_web", + "GOOGLE_SEARCH_IMAGE": "google_search_image", + "GOOGLE_MAPS": "google_maps", + "URL_CONTEXT": "url_context", + "FILE_SEARCH": "file_search", + } + if normalized in mapping: + return mapping[normalized] + if normalized.startswith("TOOL_TYPE_"): + normalized = normalized[len("TOOL_TYPE_") :] + return normalized.lower() or "server_tool" + + +def _tool_response_is_error(payload: Any) -> bool: + """Heuristic: built-in tool responses may encode failure in payload fields.""" + if not isinstance(payload, dict): + return False + + err = payload.get("error") + if err: + return True + + status = str(payload.get("status") or "").lower() + if status in {"error", "failed", "failure"}: + return True + + retrieval = _normalize_json_like(payload.get("url_retrieval_status")) + if isinstance(retrieval, str) and retrieval.endswith("_ERROR"): + return True + return False + + def _is_transient_error(exc: BaseException) -> bool: """Return True if ``exc`` looks like a retryable Google 5xx / network blip.""" msg = str(exc) diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index aaac8e6..65091af 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -431,6 +431,80 @@ class FakeMCPTool: # empty object schema so the tool still registers. assert decl.parameters_json_schema == {"type": "object", "properties": {}} +def test_build_google_search_tool_supports_google_search_class(): + from beaconmcp.dashboard.chat import _build_google_search_tool + + class _Tool: + def __init__(self, **kwargs): + self.kwargs = kwargs + + class _GoogleSearch: + pass + + class _Types: + Tool = _Tool + GoogleSearch = _GoogleSearch + + tool = _build_google_search_tool(_Types) + assert tool is not None + assert "google_search" in tool.kwargs + assert isinstance(tool.kwargs["google_search"], _GoogleSearch) + + +def test_build_google_search_tool_supports_legacy_toolgooglesearch(): + from beaconmcp.dashboard.chat import _build_google_search_tool + + class _Tool: + def __init__(self, **kwargs): + self.kwargs = kwargs + + class _ToolGoogleSearch: + pass + + class _Types: + Tool = _Tool + ToolGoogleSearch = _ToolGoogleSearch + + tool = _build_google_search_tool(_Types) + assert tool is not None + assert "google_search" in tool.kwargs + assert isinstance(tool.kwargs["google_search"], _ToolGoogleSearch) + + +def test_tool_name_from_server_tool_type_mapping(): + from beaconmcp.dashboard.chat import _tool_name_from_server_tool_type + + assert _tool_name_from_server_tool_type("GOOGLE_SEARCH_WEB") == "google_search_web" + assert _tool_name_from_server_tool_type("ToolType.URL_CONTEXT") == "url_context" + assert _tool_name_from_server_tool_type(None) == "unspecified" + + +def test_tool_response_is_error_helper(): + from beaconmcp.dashboard.chat import _tool_response_is_error + + assert _tool_response_is_error({"status": "error"}) is True + assert _tool_response_is_error({"error": {"message": "boom"}}) is True + assert _tool_response_is_error({"url_retrieval_status": "URL_RETRIEVAL_STATUS_ERROR"}) is True + assert _tool_response_is_error({"status": "ok"}) is False + + +def test_compose_system_instruction_includes_interleaving_and_server_context(): + from beaconmcp.dashboard.chat import _compose_system_instruction + + text = _compose_system_instruction("Tools available: ssh_run, proxmox_run") + assert "never batch multiple MCP function calls" in text + assert "Before each tool call" in text + assert "Tools available: ssh_run, proxmox_run" in text + + +def test_compose_system_instruction_without_server_context(): + from beaconmcp.dashboard.chat import _compose_system_instruction + + text = _compose_system_instruction(None) + assert "never batch multiple MCP function calls" in text + assert "function_call" in text + assert "Tools available:" not in text + def test_mcp_call_result_to_response_flattens_text_content(): from beaconmcp.dashboard.chat import _mcp_call_result_to_response From cae6ab231ce5c2ed0f49f3cb0f74262df842604e Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 19:50:09 +0200 Subject: [PATCH 081/155] dashboard: fix invalid function_call part replay --- src/beaconmcp/dashboard/chat.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/beaconmcp/dashboard/chat.py b/src/beaconmcp/dashboard/chat.py index b29395a..46fcb29 100644 --- a/src/beaconmcp/dashboard/chat.py +++ b/src/beaconmcp/dashboard/chat.py @@ -589,7 +589,7 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: ) model_parts: list = [] - fc_invocations: list = [] # (fc_part, fc_id, args) + fc_invocations: list = [] # (fc_part, fc, fc_id, args) last_usage: Any = None async for chunk in stream: um = getattr(chunk, "usage_metadata", None) @@ -622,7 +622,7 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: or f"fc_{len(seen_tool_ids)}" ) args = dict(getattr(fc, "args", None) or {}) - fc_invocations.append((fc, fc_id, args)) + fc_invocations.append((part, fc, fc_id, args)) # Server-side built-in tool invocation # (e.g. Google Search). These are emitted by @@ -713,14 +713,14 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: len(fc_invocations), len(selected_fc), ) - model_parts.extend(fc for fc, _fc_id, _args in selected_fc) + model_parts.extend(fc_part for fc_part, _fc, _fc_id, _args in selected_fc) current_contents.append( types.Content(role="model", parts=model_parts) ) response_parts: list = [] - for fc, fc_id, args in selected_fc: + for _fc_part, fc, fc_id, args in selected_fc: name = getattr(fc, "name", "") seen_tool_ids.add(fc_id) tool_starts[fc_id] = time.monotonic() From 7dfb86914ab4184c1f4003d113aacdda53d2bc89 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 19:57:55 +0200 Subject: [PATCH 082/155] dashboard: separate tool-round text and raise loop limit --- src/beaconmcp/dashboard/chat.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/beaconmcp/dashboard/chat.py b/src/beaconmcp/dashboard/chat.py index 46fcb29..e2726d1 100644 --- a/src/beaconmcp/dashboard/chat.py +++ b/src/beaconmcp/dashboard/chat.py @@ -419,6 +419,7 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: usage_total_prompt = 0 usage_total_cached = 0 usage_total_output = 0 + emitted_visible_text = False contents = self._build_contents(turn.history, turn.user_text) thinking = self._build_thinking_config(turn.model, turn.effort) @@ -590,6 +591,7 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: model_parts: list = [] fc_invocations: list = [] # (fc_part, fc, fc_id, args) + emitted_visible_text_this_round = False last_usage: Any = None async for chunk in stream: um = getattr(chunk, "usage_metadata", None) @@ -605,7 +607,16 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: if getattr(part, "thought", False): yield ThinkingDelta(summary=text) else: + if emitted_visible_text and not emitted_visible_text_this_round: + # Tool rounds often produce a short + # sentence before each call; insert + # a paragraph break so they don't + # visually collapse into one blob. + yield TextDelta(text="\n\n") + model_parts.append(types.Part(text="\n\n")) yield TextDelta(text=text) + emitted_visible_text = True + emitted_visible_text_this_round = True model_parts.append( types.Part( text=text, @@ -864,9 +875,9 @@ async def title(self, *, model: str, user_text: str) -> str | None: # Max rounds of function_call / function_response before we give up. # Each round is one generate_content_stream + one tool call. -# 10 is comfortably above realistic orchestration depth while still +# 50 leaves room for longer orchestrations while still # bounding run-away loops. -_MAX_TOOL_ROUNDS = 10 +_MAX_TOOL_ROUNDS = 50 # Keep MCP tool orchestration conversational: one tool at a time lets # the model add a short sentence between calls (Copilot/Claude style). From 8a8757c2c6b6a3d15be1a2a9dbc65a2316807150 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 20:08:12 +0200 Subject: [PATCH 083/155] dashboard: interleave streaming text and tool cards --- src/beaconmcp/dashboard/static/chat.js | 73 +++++++++++++++++++++----- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/src/beaconmcp/dashboard/static/chat.js b/src/beaconmcp/dashboard/static/chat.js index cf4556b..140e733 100644 --- a/src/beaconmcp/dashboard/static/chat.js +++ b/src/beaconmcp/dashboard/static/chat.js @@ -553,8 +553,17 @@ function toolIconName(name) { if (cat === "ssh") return "terminal"; return "bolt"; } -function toolNeedsConfirm(name) { - return /^(ssh_exec_command|proxmox_exec_command)/.test(name || ""); +function toolNeedsConfirm(name, args) { + // Unified run tools (ssh_run / proxmox_run) plus legacy *_exec_command* + // names kept for backwards compatibility with older servers. Pure poll + // calls (exec_id set, no command) are read-only and must not require a + // confirmation click. + const match = /^(ssh_run|proxmox_run|ssh_exec_command|proxmox_exec_command)(_|$)/.test(name || ""); + if (!match) return false; + if ((name === "ssh_run" || name === "proxmox_run") && args && typeof args === "object") { + if (args.exec_id && !args.command) return false; + } + return true; } function renderMessages() { @@ -592,16 +601,16 @@ function renderMessage(m) { } const toolCardMap = new Map(); + const body = h("div", { class: "msg-body" }); + body._raw = m.content || ""; + body.innerHTML = renderMarkdown(body._raw); + row.append(body); for (const tc of m.tool_calls || []) { const card = renderToolCard(tc, m.id); toolCardMap.set(tc.id, card); row.append(card); } - const body = h("div", { class: "msg-body" }); - body.innerHTML = renderMarkdown(m.content || ""); - row.append(body); - if (!m.streaming) { row.append(h("div", { class: "msg-actions" }, [ h("button", { @@ -620,6 +629,29 @@ function renderMessage(m) { return row; } +function insertStreamNode(row, node) { + const tail = row.querySelector(".typing") || row.querySelector(".msg-actions"); + if (tail) row.insertBefore(node, tail); + else row.append(node); +} + +function ensureStreamingBody(layout) { + let body = layout.bodyRef.current; + if (body) return body; + body = h("div", { class: "msg-body" }); + body._raw = ""; + insertStreamNode(layout.row, body); + layout.bodyRef.current = body; + return body; +} + +function closeStreamingBody(layout) { + const body = layout.bodyRef.current; + if (!body) return; + if (!(body._raw || "").trim()) body.remove(); + layout.bodyRef.current = null; +} + function renderThinking(text, active) { const block = h("div", { class: `thinking${active ? " active" : ""}` }); const labelSpan = h("span", { class: "thinking-label", text: active ? "Thinking…" : "Thought for a moment" }); @@ -651,7 +683,7 @@ function renderToolCard(tc, msgId) { }[tc.status] || ""; // Special prominent confirm card for shell commands awaiting approval. - if (tc.status === "awaiting_confirm" && toolNeedsConfirm(tc.name)) { + if (tc.status === "awaiting_confirm" && toolNeedsConfirm(tc.name, tc.args)) { return renderConfirmCard(tc, msgId); } @@ -980,7 +1012,7 @@ async function streamTurn(userText, inner) { // Live thinking block is injected on the first thinking_delta. let thinkingBlock = null; const toolCardMap = row._toolCardMap; - const body = row._body; + const layout = { row, bodyRef: { current: row._body } }; const indicator = h("div", { class: "typing" }, [ h("div", { class: "typing-dots" }, [h("span"), h("span"), h("span")]), @@ -1025,7 +1057,16 @@ async function streamTurn(userText, inner) { const chunk = buf.slice(0, sepIdx); buf = buf.slice(sepIdx + 2); const ev = parseSseFrame(chunk); - if (ev) handleEvent(ev, assistantMsg, row, body, toolCardMap, { getThinking: () => thinkingBlock, setThinking: (b) => { thinkingBlock = b; } }); + if (ev) { + handleEvent( + ev, + assistantMsg, + row, + toolCardMap, + { getThinking: () => thinkingBlock, setThinking: (b) => { thinkingBlock = b; } }, + layout, + ); + } } } } catch (err) { @@ -1076,11 +1117,13 @@ function parseSseFrame(chunk) { } } -function handleEvent({ event, data }, assistantMsg, row, body, toolCardMap, thinkingCtx) { +function handleEvent({ event, data }, assistantMsg, row, toolCardMap, thinkingCtx, layout) { switch (event) { case "text_delta": { - assistantMsg.content += data.text; - body.innerHTML = renderMarkdown(assistantMsg.content); + const body = ensureStreamingBody(layout); + body._raw = (body._raw || "") + (data.text || ""); + assistantMsg.content += data.text || ""; + body.innerHTML = renderMarkdown(body._raw); scrollToBottom(); break; } @@ -1099,6 +1142,7 @@ function handleEvent({ event, data }, assistantMsg, row, body, toolCardMap, thin break; } case "tool_call": { + closeStreamingBody(layout); const tc = { id: data.id, name: data.name, args: data.args || {}, status: "pending", preview: null, duration_ms: null, @@ -1106,13 +1150,14 @@ function handleEvent({ event, data }, assistantMsg, row, body, toolCardMap, thin assistantMsg.tool_calls.push(tc); const card = renderToolCard(tc, assistantMsg.id); toolCardMap.set(data.id, { tc, card }); - body.before(card); + insertStreamNode(layout.row, card); scrollToBottom(); break; } case "tool_confirm_required": { let entry = toolCardMap.get(data.id); if (!entry) { + closeStreamingBody(layout); const tc = { id: data.id, name: data.name, args: data.args || {}, status: "awaiting_confirm", preview: null, duration_ms: null, @@ -1120,7 +1165,7 @@ function handleEvent({ event, data }, assistantMsg, row, body, toolCardMap, thin assistantMsg.tool_calls.push(tc); const card = renderToolCard(tc, assistantMsg.id); toolCardMap.set(data.id, { tc, card }); - body.before(card); + insertStreamNode(layout.row, card); } else { entry.tc.status = "awaiting_confirm"; const fresh = renderToolCard(entry.tc, assistantMsg.id); From 957f3210bf23094a2c0e1d91b59c28c3a68084f7 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 21:18:58 +0200 Subject: [PATCH 084/155] =?UTF-8?q?Bring=20OAuth=202FA=20page=20+=20'Think?= =?UTF-8?q?ing=E2=80=A6'=20shimmer=20up=20to=20the=20new=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /oauth/authorize TOTP prompt still rendered the pre-redesign inline HTML; port it to the design system (Inter + JetBrains Mono, warm-grey tokens, 6-digit boxes with paste handling, accent CTA). Self-contained page so the OAuth flow works even when the dashboard is disabled. Share the TOTP box logic between /app/refresh and /oauth/authorize via a new totp_boxes.js (replaces totp_refresh.js, which was hard-wired to #refresh-form). Add a shimmer on the live 'Thinking…' indicator in the chat panel — 3s linear, accent gradient fading through fg-muted, consistent with the collapsible thinking block. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/beaconmcp/__main__.py | 273 ++++++++++++++++-- src/beaconmcp/dashboard/static/app.css | 12 + src/beaconmcp/dashboard/static/chat.js | 2 +- .../static/{totp_refresh.js => totp_boxes.js} | 12 +- .../dashboard/templates/totp_refresh.html | 2 +- tests/test_dashboard_integration.py | 3 +- 6 files changed, 275 insertions(+), 29 deletions(-) rename src/beaconmcp/dashboard/static/{totp_refresh.js => totp_boxes.js} (78%) diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 51c7070..8dcdcb3 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -336,39 +336,268 @@ def _render_authorize_form( f'' for k, v in normalized.items() ) + banner = "" if locked: banner = ( - '

    Too many attempts. Try again in 5 minutes.

    ' + '" ) elif error: - banner = f'

    {html.escape(error)}

    ' + banner = f'' disabled = "disabled" if locked else "" + # Self-contained page: /oauth/authorize is reachable even when the + # dashboard (and its /app/static bundle) is disabled. Styles/JS live + # inline; same design tokens as the dashboard auth pages. page = f""" - -BeaconMCP - Two-factor authentication + + + +BeaconMCP · Two-factor + + + -
    -

    BeaconMCP

    -

    Two-factor authentication for {html.escape(client_name)}.

    -{banner} -
    +:root {{ + --accent: oklch(0.68 0.17 48); + --accent-soft: oklch(0.68 0.17 48 / 0.12); + --accent-softer: oklch(0.68 0.17 48 / 0.06); + --accent-border: oklch(0.68 0.17 48 / 0.35); + --accent-hover: oklch(0.62 0.18 48); + --accent-fg: #fff; + --bg: oklch(0.99 0.004 70); + --bg-soft: oklch(0.975 0.005 70); + --bg-elev: #fff; + --fg: oklch(0.22 0.01 70); + --fg-mid: oklch(0.42 0.008 70); + --fg-muted: oklch(0.55 0.008 70); + --fg-faint: oklch(0.7 0.006 70); + --border: oklch(0.92 0.006 70); + --border-strong: oklch(0.86 0.008 70); + --border-subtle: oklch(0.95 0.005 70); + --danger: oklch(0.58 0.19 25); + --danger-soft: oklch(0.58 0.19 25 / 0.1); + --shadow: 0 1px 2px rgba(20,14,8,0.04), 0 4px 20px rgba(20,14,8,0.06); + --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); +}} +[data-theme="dark"] {{ + --bg: oklch(0.16 0.008 60); + --bg-soft: oklch(0.19 0.008 60); + --bg-elev: oklch(0.21 0.009 60); + --fg: oklch(0.95 0.006 70); + --fg-mid: oklch(0.78 0.008 70); + --fg-muted: oklch(0.62 0.01 70); + --fg-faint: oklch(0.45 0.008 70); + --border: oklch(0.28 0.009 60); + --border-strong: oklch(0.36 0.01 60); + --border-subtle: oklch(0.24 0.008 60); + --accent: oklch(0.75 0.17 50); + --accent-soft: oklch(0.75 0.17 50 / 0.16); + --accent-softer: oklch(0.75 0.17 50 / 0.08); + --accent-border: oklch(0.75 0.17 50 / 0.4); + --accent-hover: oklch(0.82 0.17 50); + --accent-fg: oklch(0.12 0.008 60); + --danger: oklch(0.68 0.19 25); + --shadow: 0 1px 2px rgba(0,0,0,0.3), 0 4px 20px rgba(0,0,0,0.4); +}} +* {{ box-sizing: border-box; }} +html, body {{ + margin: 0; padding: 0; + font-family: var(--font); + font-size: 15px; + color: var(--fg); + background: var(--bg); + -webkit-font-smoothing: antialiased; +}} +body {{ + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +}} +.auth-card {{ + width: 100%; max-width: 380px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 16px; + padding: 32px; + box-shadow: var(--shadow); + animation: rise 400ms var(--ease-out) both; +}} +@keyframes rise {{ + from {{ opacity: 0; transform: translateY(6px); }} + to {{ opacity: 1; transform: translateY(0); }} +}} +.auth-brand {{ display: flex; align-items: center; margin-bottom: 26px; }} +.auth-brand .name {{ font-weight: 600; font-size: 15px; letter-spacing: -0.01em; }} +h1 {{ margin: 0 0 4px; font-size: 22px; font-weight: 600; letter-spacing: -0.015em; }} +.sub {{ margin: 0 0 18px; font-size: 13.5px; color: var(--fg-muted); }} +.sub strong {{ color: var(--fg); font-weight: 600; }} +.banner {{ + padding: 10px 14px; + border-radius: 10px; + font-size: 13px; + margin: 0 0 14px; + background: var(--danger-soft); + color: var(--danger); + border: 1px solid color-mix(in oklab, var(--danger) 35%, var(--border)); +}} +.toast-banner {{ + background: var(--accent-softer); + border: 1px solid var(--accent-border); + color: var(--fg); + border-radius: 10px; + padding: 9px 12px; + font-size: 12.5px; + margin-bottom: 16px; + display: flex; align-items: center; gap: 8px; +}} +.toast-banner .dot {{ + width: 6px; height: 6px; + border-radius: 50%; background: var(--accent); + flex-shrink: 0; +}} +.toast-banner b {{ font-family: var(--font-mono); margin-left: 2px; }} +.totp-inputs {{ + display: flex; gap: 8px; justify-content: space-between; + margin: 8px 0 18px; +}} +.totp-inputs input {{ + width: 100%; aspect-ratio: 1 / 1.15; + text-align: center; + font-size: 24px; font-weight: 600; + font-family: var(--font-mono); + background: var(--bg-soft); + border: 1px solid var(--border-strong); + border-radius: 10px; + color: var(--fg); + outline: none; + transition: border-color 160ms var(--ease-out), box-shadow 160ms var(--ease-out); +}} +.totp-inputs input:focus {{ + border-color: var(--accent); + box-shadow: 0 0 0 4px var(--accent-soft); +}} +.totp-inputs input.filled {{ + background: var(--accent-softer); + border-color: var(--accent-border); +}} +.btn-primary {{ + width: 100%; + padding: 12px 16px; + border: 0; border-radius: 10px; + background: var(--accent); color: var(--accent-fg); + font-family: var(--font); font-weight: 600; font-size: 14.5px; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + transition: background 180ms var(--ease-out), transform 100ms var(--ease-out); + box-shadow: 0 4px 14px oklch(0.68 0.17 48 / 0.28), inset 0 1px 0 rgba(255,255,255,0.2); +}} +.btn-primary:hover:not(:disabled) {{ background: var(--accent-hover); }} +.btn-primary:active:not(:disabled) {{ transform: translateY(1px); }} +.btn-primary:disabled {{ opacity: 0.6; cursor: not-allowed; }} + + + + +
    +
    BeaconMCP
    +

    Authorize access

    +

    Enter the 6-digit code from your authenticator to grant access to {html.escape(client_name)}.

    + {banner} +
    + + Client: {html.escape(normalized["client_id"])} +
    + {hidden} - - - -
    +
    + + + + + + +
    + + + +
    + + + """ return HTMLResponse(page) diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index 42c4f4c..f4226fd 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -1315,6 +1315,18 @@ body.chat-page { color: var(--fg-muted); margin-top: 4px; } +.typing-label { + background: linear-gradient(90deg, + var(--fg-muted) 0%, + var(--accent) 50%, + var(--fg-muted) 100%); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: shimmer 3s linear infinite; + font-weight: 500; +} .typing-dots { display: inline-flex; gap: 3px; } .typing-dots span { width: 5px; height: 5px; diff --git a/src/beaconmcp/dashboard/static/chat.js b/src/beaconmcp/dashboard/static/chat.js index 140e733..d7dcc45 100644 --- a/src/beaconmcp/dashboard/static/chat.js +++ b/src/beaconmcp/dashboard/static/chat.js @@ -1016,7 +1016,7 @@ async function streamTurn(userText, inner) { const indicator = h("div", { class: "typing" }, [ h("div", { class: "typing-dots" }, [h("span"), h("span"), h("span")]), - h("span", { text: "Thinking…" }), + h("span", { class: "typing-label", text: "Thinking…" }), ]); row.append(indicator); scrollToBottom(); diff --git a/src/beaconmcp/dashboard/static/totp_refresh.js b/src/beaconmcp/dashboard/static/totp_boxes.js similarity index 78% rename from src/beaconmcp/dashboard/static/totp_refresh.js rename to src/beaconmcp/dashboard/static/totp_boxes.js index 8a67738..41b50a6 100644 --- a/src/beaconmcp/dashboard/static/totp_refresh.js +++ b/src/beaconmcp/dashboard/static/totp_boxes.js @@ -1,10 +1,14 @@ -// 6-digit TOTP inputs, shared with the login page. +// Shared 6-box TOTP input handler. +// Works on any form that contains #totp-inputs (inputs), #totp (hidden field), +// and #verify-btn (submit button). Used by /app/refresh and /oauth/authorize. (function() { - var form = document.getElementById("refresh-form"); - if (!form) return; + var container = document.getElementById("totp-inputs"); + if (!container) return; + var form = container.closest("form"); var totpHidden = document.getElementById("totp"); var verifyBtn = document.getElementById("verify-btn"); - var inputs = document.querySelectorAll("#totp-inputs input"); + var inputs = container.querySelectorAll("input"); + if (!form || !totpHidden || !verifyBtn || !inputs.length) return; function collectTotp() { var s = ""; diff --git a/src/beaconmcp/dashboard/templates/totp_refresh.html b/src/beaconmcp/dashboard/templates/totp_refresh.html index 3bcd2c6..d7d95ac 100644 --- a/src/beaconmcp/dashboard/templates/totp_refresh.html +++ b/src/beaconmcp/dashboard/templates/totp_refresh.html @@ -40,5 +40,5 @@

    Two-factor

    - + {% endblock %} diff --git a/tests/test_dashboard_integration.py b/tests/test_dashboard_integration.py index 7a18c94..55ff58d 100644 --- a/tests/test_dashboard_integration.py +++ b/tests/test_dashboard_integration.py @@ -296,7 +296,8 @@ def test_refresh_when_bearer_expired_renders_form(client, deps): r = client.get("/app/refresh") assert r.status_code == 200 assert "Test Client" in r.text - assert "2FA code" in r.text + # New UI replaces the "2FA code" label with the 6-digit boxes + copy. + assert "6-digit code" in r.text def test_refresh_post_re_issues_bearer(client, deps): From f4999216707972e8c9d31edfa1431d131f3c0aa0 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 22:20:58 +0200 Subject: [PATCH 085/155] SSH: restore pre-2.0 homelab ergonomics with inherit_proxmox_nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-reachability regression: before the multi-host refactor (d9a728a), ssh_run(host="pve1") worked out of the box — resolve_host() mapped the node name to proxmox.nodes[].host and a single global ssh.user/password authenticated every target. After the refactor every SSH target had to be declared under ssh.hosts[], with a name distinct from the Proxmox node (so "pve1" became "pve1-ssh"), and credentials duplicated per entry. The ergonomics of "root + same key on every PVE node" were gone. Three changes bring it back without giving up the multi-host model: 1. Drop the ssh.hosts[].name ↔ proxmox.nodes[].name collision check. The two names live in separate tool namespaces (ssh_* vs proxmox_*), so a declared "pve1" under ssh.hosts is unambiguous. Test flipped: the collision case is now asserted OK instead of rejected. 2. Add ssh.defaults (user + password/key_file) + ssh.inherit_proxmox_nodes. When inheritance is on, every Proxmox node that isn't already covered by an explicit hosts[] entry (matched by name OR address) gets one synthesized at load time from defaults. Explicit declarations win by design, so per-node overrides are just one extra hosts[] line. 3. Fix the ssh_run docstring (it still advertised the pre-2.0 shape) and enrich the resolution-error message: when host= matches a Proxmox node name, point at either declaring it / flipping inheritance, or at proxmox_run for in-guest commands. Same hint for bare VMID misses. beaconmcp.yaml.example gains a documented ssh.defaults + inherit_proxmox_nodes block; README's config table adds two rows. 5 new config tests cover the happy path (pve1+pve2 synthesized), the override-wins case, the "inherit without defaults" error, the defaults auth-method check, and that a shared name with a Proxmox node is now accepted. Full suite: 219/219 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 +- beaconmcp.yaml.example | 42 ++++++--- src/beaconmcp/config.py | 132 +++++++++++++++++++++++------ src/beaconmcp/ssh/client.py | 24 +++++- src/beaconmcp/ssh/tools.py | 13 ++- tests/test_config_yaml.py | 165 ++++++++++++++++++++++++++++++++++-- 6 files changed, 329 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index b4aea76..ab6150b 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,9 @@ Common keys: | `server.allowed_hosts` | DNS-rebinding allowlist — **must** include the public FQDN behind your reverse proxy. | | `server.allowed_origins` | CORS allowlist for browser-based MCP clients. | | `proxmox.nodes[]` | One entry per Proxmox node. Needs an API token per node. For the host BeaconMCP itself runs on, use `host: localhost` — both the API (`:8006`) and SSH (`:22`) are reachable locally without going through any reverse proxy or tunnel. Remote nodes in the cluster use their FQDN (append `:443` if a reverse proxy terminates the API). | -| `ssh.vmid_to_ip` | Optional template (e.g. `"192.168.1.{id}"`) used by `ssh_exec_command` when the `host` argument is a bare VMID. Omit to disable numeric-ID shortcuts. | +| `ssh.hosts[]` | One entry per SSH target (VPS, Proxmox node, jump box, …). Each entry carries its own `user` + exactly one of `password` / `key_file`. Names may match `proxmox.nodes[].name`. | +| `ssh.defaults` + `ssh.inherit_proxmox_nodes` | Homelab shortcut. Set `defaults:` (user + password/key_file) and flip `inherit_proxmox_nodes: true` — every Proxmox node becomes SSH-reachable under its own name with those defaults, no duplication. Explicit `ssh.hosts[]` entries still win when they match a node by name or address. | +| `ssh.vmid_to_ip` | Optional template (e.g. `"192.168.1.{id}"`) used by `ssh_run` when the `host` argument is a bare VMID. The resolved IP must match an `ssh.hosts[].host` to authenticate. Omit to disable numeric-ID shortcuts. | | `bmc.devices[]` | Zero or more BMCs. `type` is one of `hp_ilo`, `ipmi`, `idrac` (stub), `supermicro` (stub). `jump_host` is optional — set it to the name of a `proxmox.nodes[]` entry to route the connection over an SSH tunnel. | | `features.dashboard.limits` | Per-5h and per-week USD caps for the Gemini chat. Set to `0` to disable a window. | diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index 48defe7..6de006f 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -68,12 +68,15 @@ proxmox: # -------- SSH capability (optional) ---------------------------------------- # Delete this section if you have no SSH targets. Each `hosts[]` entry is one -# addressable target declared with its own credentials (password OR key_file, -# exactly one). No shared/global SSH creds — every target is explicit. +# addressable target with its own credentials (password OR key_file, exactly +# one). An `ssh.hosts[]` entry may share a name with a `proxmox.nodes[]` +# entry — the two live in separate tool namespaces (`ssh_*` vs `proxmox_*`) +# so there is no routing ambiguity. # -# Host names must be distinct from `proxmox.nodes[].name` (collision is -# rejected at load time). To SSH into a Proxmox host, add it here under a -# different name (e.g. `pve1-ssh`). +# Typical homelab shortcut: set `inherit_proxmox_nodes: true` + `defaults:` +# and every Proxmox node becomes SSH-reachable under its own name with the +# default creds. Explicit `hosts[]` entries still win when they match a node +# by name or address, so you can override per-node. ssh: # Optional helper: a numeric identifier passed to ssh_* tools (e.g. a # Proxmox VMID) is fed into this template, and the resulting IP is matched @@ -81,6 +84,20 @@ ssh: # disable the shortcut. vmid_to_ip: "192.168.1.{id}" + # Default credentials used when `inherit_proxmox_nodes: true` synthesizes + # one ssh.hosts[] entry per Proxmox node. Provide exactly one of + # `password` or `key_file`. + defaults: + user: root + key_file: ~/.ssh/beaconmcp + # password: ${SSH_ROOT_PW} + + # When true, each Proxmox node from `proxmox.nodes[]` that isn't already + # covered by an explicit `hosts[]` entry (matched by name OR address) is + # auto-declared as an SSH target using `defaults`. So `ssh_run(host="pve1")` + # just works without repeating credentials per node. + inherit_proxmox_nodes: true + hosts: - name: vps1 host: 198.51.100.10 @@ -93,13 +110,12 @@ ssh: user: admin password: ${VPS2_PW} - # If you want to SSH into your Proxmox host (e.g. for `pct exec` on an - # LXC container), declare it here with a DIFFERENT name than the Proxmox - # node name. BMC tunneling also resolves `jump_host` against these names. - - name: pve1-ssh - host: localhost - user: root - key_file: ~/.ssh/beaconmcp + # Example override: if pve2 needs a different key than `defaults`, declare + # it explicitly — inheritance sees the name match and skips synthesizing. + # - name: pve2 + # host: pve2.example.com + # user: root + # key_file: ~/.ssh/pve2_only # -------- BMC capability (optional) ---------------------------------------- # Delete this section if you have no HP iLO / IPMI / iDRAC / Supermicro @@ -114,7 +130,7 @@ bmc: # Optional: tunnel the iLO connection through an ssh.hosts[] entry # (useful when the BMC lives on a private management VLAN only # reachable from a bastion). References `ssh.hosts[].name`. - jump_host: pve1-ssh + jump_host: pve1 - id: rack2-bmc type: ipmi diff --git a/src/beaconmcp/config.py b/src/beaconmcp/config.py index 227797d..5ef500c 100644 --- a/src/beaconmcp/config.py +++ b/src/beaconmcp/config.py @@ -63,6 +63,23 @@ class SSHHost: key_file: str | None = None +@dataclass +class SSHDefaults: + """Default credentials applied by ``ssh.inherit_proxmox_nodes``. + + When :attr:`SSHConfig.inherit_proxmox_nodes` is true, each declared + Proxmox node that isn't already covered by an explicit ``ssh.hosts[]`` + entry gets one synthesized from the node's address + these defaults. + Exactly one of ``password`` or ``key_file`` is required — same rule as + an explicit host entry. + """ + + user: str + port: int = 22 + password: str | None = None + key_file: str | None = None + + @dataclass class SSHConfig: hosts: list[SSHHost] = field(default_factory=list) @@ -71,6 +88,14 @@ class SSHConfig: # The resolved IP must match the ``host`` field of one of ``hosts``; # otherwise the SSH client returns an actionable error. vmid_to_ip: str | None = None + # Default SSH credentials, consumed when ``inherit_proxmox_nodes`` is on. + defaults: SSHDefaults | None = None + # When true, synthesize an ``ssh.hosts[]`` entry for every declared + # Proxmox node that isn't already covered by an explicit ``hosts[]`` + # entry. Synthesized entries inherit address from ``proxmox.nodes[].host`` + # and credentials from ``ssh.defaults``. Restores the pre-2.0 ergonomic + # where a single credential block covered every Proxmox node. + inherit_proxmox_nodes: bool = False @dataclass @@ -303,22 +328,64 @@ def _build(cls, raw: dict) -> Config: # Reject the legacy single-credential shape explicitly so existing # deployments get a clear migration pointer instead of a confusing # "missing field" error. - if "hosts" not in ssh_raw and ("user" in ssh_raw or "password" in ssh_raw): + if ( + "hosts" not in ssh_raw + and "defaults" not in ssh_raw + and ("user" in ssh_raw or "password" in ssh_raw) + ): raise ConfigError( "ssh: legacy shape with top-level 'user'/'password' is no " - "longer supported. Migrate to 'ssh.hosts:' — see " - "beaconmcp.yaml.example. Each host now carries its own " - "credentials (password or key_file)." + "longer supported. Migrate to 'ssh.hosts:' + optional " + "'ssh.defaults:' + 'ssh.inherit_proxmox_nodes:' — see " + "beaconmcp.yaml.example." + ) + + # Optional default credentials, consumed when inheritance is on. + defaults: SSHDefaults | None = None + defaults_raw = ssh_raw.get("defaults") + if defaults_raw: + if not isinstance(defaults_raw, dict): + raise ConfigError("ssh.defaults: must be a mapping.") + d_password = defaults_raw.get("password") or None + d_key_file = defaults_raw.get("key_file") or None + if bool(d_password) == bool(d_key_file): + raise ConfigError( + "ssh.defaults: provide exactly one of 'password' or " + "'key_file' (got " + + ("both" if d_password and d_key_file else "neither") + + ")." + ) + defaults = SSHDefaults( + user=_required(defaults_raw, "user", "ssh.defaults"), + port=int(defaults_raw.get("port", 22)), + password=d_password, + key_file=d_key_file, + ) + + inherit_flag = _bool(ssh_raw.get("inherit_proxmox_nodes", False)) + if inherit_flag and defaults is None: + raise ConfigError( + "ssh.inherit_proxmox_nodes: requires 'ssh.defaults:' with " + "user + password/key_file — the synthesized entries need " + "credentials to authenticate." ) + hosts_raw = ssh_raw.get("hosts") or [] - if not isinstance(hosts_raw, list) or not hosts_raw: + if not isinstance(hosts_raw, list): + raise ConfigError("ssh.hosts: must be a list of mappings.") + # An 'ssh:' section with neither 'hosts' nor 'inherit_proxmox_nodes' + # would load into an empty SSH config -- equivalent to no SSH at + # all. Be explicit so the user notices the mis-config early. + if not hosts_raw and not inherit_flag: raise ConfigError( - "ssh.hosts: at least one host entry is required when the " - "'ssh:' section is present. Remove the section entirely " - "to disable SSH." + "ssh: either declare at least one 'ssh.hosts[]' entry or " + "set 'ssh.inherit_proxmox_nodes: true' (with 'ssh.defaults'). " + "Remove the 'ssh:' section entirely to disable SSH." ) + ssh_hosts: list[SSHHost] = [] seen_names: set[str] = set() + seen_addresses: set[str] = set() for h in hosts_raw: if not isinstance(h, dict): raise ConfigError( @@ -339,19 +406,46 @@ def _build(cls, raw: dict) -> Config: + ("both" if password and key_file else "neither") + ")." ) + host_addr = _required(h, "host", f"ssh.hosts[{host_name}]") + seen_addresses.add(host_addr) ssh_hosts.append( SSHHost( name=host_name, - host=_required(h, "host", f"ssh.hosts[{host_name}]"), + host=host_addr, user=_required(h, "user", f"ssh.hosts[{host_name}]"), port=int(h.get("port", 22)), password=password, key_file=key_file, ) ) + + # Synthesize ssh.hosts[] entries for Proxmox nodes that aren't + # already covered by an explicit declaration. Skip a node when it + # already matches an explicit entry by name OR by address, so an + # operator who wants different creds for a specific node just + # declares it explicitly and the inheritance leaves it alone. + if inherit_flag and defaults is not None: + for node in pve_nodes: + if node.name in seen_names or node.host in seen_addresses: + continue + ssh_hosts.append( + SSHHost( + name=node.name, + host=node.host, + user=defaults.user, + port=defaults.port, + password=defaults.password, + key_file=defaults.key_file, + ) + ) + seen_names.add(node.name) + seen_addresses.add(node.host) + ssh = SSHConfig( hosts=ssh_hosts, vmid_to_ip=ssh_raw.get("vmid_to_ip"), + defaults=defaults, + inherit_proxmox_nodes=inherit_flag, ) srv_raw = raw.get("server") or {} @@ -396,21 +490,11 @@ def _build(cls, raw: dict) -> Config: "in beaconmcp.yaml." ) - # Refuse to start if an SSH host name collides with a Proxmox node - # name. The two share the ``host`` argument of ssh_* tools, so a - # shared name would be ambiguous. Forcing distinct names means one - # declarative source of truth per SSH target. - if ssh and ssh.hosts: - pve_names = {n.name for n in pve_nodes} - for h in ssh.hosts: - if h.name in pve_names: - raise ConfigError( - f"host name {h.name!r} is declared both in " - "ssh.hosts and proxmox.nodes — choose distinct " - "names. To SSH into a Proxmox host, add it to " - "ssh.hosts with a different name (e.g. " - f"'{h.name}-ssh')." - ) + # SSH host names and Proxmox node names are allowed to match: the + # two live in separate tool namespaces (``ssh_*`` vs ``proxmox_*``) + # so there is no routing ambiguity. Letting them match removes the + # need for synthetic suffixes like ``pve1-ssh`` and lets + # ``ssh.inherit_proxmox_nodes`` auto-declare hosts cleanly. # BMC jump_host now references ssh.hosts[].name (was proxmox.nodes[].name # in pre-2.0 shape). Validate the reference exists so the user gets a diff --git a/src/beaconmcp/ssh/client.py b/src/beaconmcp/ssh/client.py index 2bddcab..d09e0fe 100644 --- a/src/beaconmcp/ssh/client.py +++ b/src/beaconmcp/ssh/client.py @@ -143,10 +143,32 @@ def resolve(self, identifier: str) -> SSHHost: return by_addr declared = ", ".join(h.name for h in self._config.ssh.hosts) or "" + hint = "" + # When the identifier matches a Proxmox node that wasn't declared as + # an SSH host, point the caller at the two common fixes instead of + # just reporting "not declared". This is the single most common + # foot-gun — pre-2.0 code let you SSH into a Proxmox node by name + # implicitly. + if any(n.name == identifier for n in self._config.pve_nodes): + hint = ( + f" Note: {identifier!r} is a Proxmox node. To reach it via " + "SSH, either add it under ssh.hosts[] explicitly, or set " + "'ssh.inherit_proxmox_nodes: true' with 'ssh.defaults:' so " + "every node is auto-declared. If you meant to run something " + "*inside* a VM/LXC on that node, use proxmox_run(node=..., " + "vmid=..., command=...) instead — it goes through QEMU Guest " + "Agent / pct exec and doesn't need SSH." + ) + elif identifier.isdigit(): + hint = ( + f" Note: {identifier!r} looks like a VMID. To run a command " + "inside that guest, prefer proxmox_run(node=..., " + f"vmid={identifier}, command=...)." + ) raise SSHHostResolutionError( f"Host {identifier!r} is not declared in ssh.hosts[]. Add an " "entry (name, host, user, password or key_file) to enable SSH " - f"to this target. Declared hosts: {declared}." + f"to this target. Declared hosts: {declared}.{hint}" ) def resolve_host(self, identifier: str) -> str: diff --git a/src/beaconmcp/ssh/tools.py b/src/beaconmcp/ssh/tools.py index f0c0c98..7411c21 100644 --- a/src/beaconmcp/ssh/tools.py +++ b/src/beaconmcp/ssh/tools.py @@ -56,9 +56,16 @@ async def ssh_run( - **Poll existing**: pass ``exec_id`` only. Returns the current status/output for that session. - ``host`` accepts a node name (``pve1``), a VMID template match - (``101`` -> ``192.168.1.101`` if ``vmid_to_ip`` is configured), or a - direct IP/hostname declared under ``ssh.hosts[]``. + ``host`` must resolve to a declared ``ssh.hosts[]`` entry. Accepts: + an entry ``name``; a numeric VMID when ``ssh.vmid_to_ip`` is set + (e.g. ``"110"`` -> ``"192.168.1.110"``); or a declared ``host`` + address. If ``ssh.inherit_proxmox_nodes: true``, every Proxmox node + is auto-declared as an SSH host under its own name, so reaching the + hypervisor reuses the same identifier as ``proxmox_run(node=…)``. + + To run **inside a VM or LXC** managed by Proxmox, prefer + ``proxmox_run`` (QEMU Guest Agent / ``pct exec``) — no SSH is needed + and it works even when the guest has no inbound network reachability. """ if exec_id: session = SSHClient.get_session(exec_id) diff --git a/tests/test_config_yaml.py b/tests/test_config_yaml.py index d3a7ee7..693bb98 100644 --- a/tests/test_config_yaml.py +++ b/tests/test_config_yaml.py @@ -194,10 +194,16 @@ def test_ssh_legacy_shape_rejected(tmp_path: Path, monkeypatch: pytest.MonkeyPat Config.load(config_path=path) -def test_ssh_host_name_collides_with_proxmox_node( +def test_ssh_host_name_can_match_proxmox_node( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """If an ssh.hosts[].name matches a proxmox.nodes[].name, refuse to start.""" + """An ssh.hosts[].name may match a proxmox.nodes[].name. + + Pre-2.0 the loader rejected this collision. The two names live in + separate tool namespaces (``ssh_*`` vs ``proxmox_*``) so there is no + routing ambiguity, and matching names removes the need for synthetic + ``*-ssh`` suffixes. + """ monkeypatch.setenv("PVE1_TOKEN_SECRET", "x") monkeypatch.setenv("SSH_PW", "y") path = _write( @@ -206,19 +212,155 @@ def test_ssh_host_name_collides_with_proxmox_node( version: 1 proxmox: nodes: - - name: serv1 - host: serv1.example.com + - name: pve1 + host: pve1.example.com token_id: root@pam!beaconmcp token_secret: ${PVE1_TOKEN_SECRET} ssh: hosts: - - name: serv1 - host: serv1.example.com + - name: pve1 + host: pve1.example.com user: root password: ${SSH_PW} """, ) - with pytest.raises(ConfigError, match="declared both in"): + cfg = Config.load(config_path=path) + assert cfg.ssh is not None + assert [h.name for h in cfg.ssh.hosts] == ["pve1"] + assert cfg.pve_nodes[0].name == "pve1" + + +def test_ssh_inherit_proxmox_nodes_synthesizes_hosts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """With ``inherit_proxmox_nodes: true`` + defaults, every Proxmox node + that isn't covered by an explicit ssh.hosts[] entry gets one synthesized. + Restores the pre-2.0 single-credential-block ergonomic.""" + monkeypatch.setenv("PVE1_TOKEN_SECRET", "x") + monkeypatch.setenv("PVE2_TOKEN_SECRET", "x") + path = _write( + tmp_path / "beaconmcp.yaml", + """ + version: 1 + proxmox: + nodes: + - name: pve1 + host: 10.0.0.1 + token_id: root@pam!beaconmcp + token_secret: ${PVE1_TOKEN_SECRET} + - name: pve2 + host: 10.0.0.2 + token_id: root@pam!beaconmcp + token_secret: ${PVE2_TOKEN_SECRET} + ssh: + defaults: + user: root + key_file: ~/.ssh/homelab + inherit_proxmox_nodes: true + """, + ) + cfg = Config.load(config_path=path) + assert cfg.ssh is not None + names = sorted(h.name for h in cfg.ssh.hosts) + assert names == ["pve1", "pve2"] + pve1 = next(h for h in cfg.ssh.hosts if h.name == "pve1") + assert pve1.host == "10.0.0.1" + assert pve1.user == "root" + assert pve1.key_file == "~/.ssh/homelab" + assert pve1.password is None + + +def test_ssh_inherit_proxmox_nodes_explicit_override_wins( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An explicit ssh.hosts[] entry shadows inheritance by name or address. + + Here pve2 is declared explicitly; inheritance must only synthesize pve1. + """ + monkeypatch.setenv("PVE1_TOKEN_SECRET", "x") + monkeypatch.setenv("PVE2_TOKEN_SECRET", "x") + monkeypatch.setenv("PVE2_PW", "specific") + path = _write( + tmp_path / "beaconmcp.yaml", + """ + version: 1 + proxmox: + nodes: + - name: pve1 + host: 10.0.0.1 + token_id: root@pam!beaconmcp + token_secret: ${PVE1_TOKEN_SECRET} + - name: pve2 + host: 10.0.0.2 + token_id: root@pam!beaconmcp + token_secret: ${PVE2_TOKEN_SECRET} + ssh: + defaults: + user: root + key_file: ~/.ssh/homelab + inherit_proxmox_nodes: true + hosts: + - name: pve2 + host: 10.0.0.2 + user: admin + password: ${PVE2_PW} + """, + ) + cfg = Config.load(config_path=path) + assert cfg.ssh is not None + pve2 = next(h for h in cfg.ssh.hosts if h.name == "pve2") + assert pve2.user == "admin" + assert pve2.password == "specific" + # pve1 still inherits. + pve1 = next(h for h in cfg.ssh.hosts if h.name == "pve1") + assert pve1.key_file == "~/.ssh/homelab" + + +def test_ssh_inherit_proxmox_nodes_requires_defaults( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``inherit_proxmox_nodes: true`` without ``defaults:`` is rejected.""" + monkeypatch.setenv("PVE1_TOKEN_SECRET", "x") + path = _write( + tmp_path / "beaconmcp.yaml", + """ + version: 1 + proxmox: + nodes: + - name: pve1 + host: 10.0.0.1 + token_id: root@pam!beaconmcp + token_secret: ${PVE1_TOKEN_SECRET} + ssh: + inherit_proxmox_nodes: true + """, + ) + with pytest.raises(ConfigError, match=r"requires 'ssh\.defaults"): + Config.load(config_path=path) + + +def test_ssh_defaults_requires_one_auth_method( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Defaults block follows the same rule as an explicit host entry.""" + monkeypatch.setenv("PVE1_TOKEN_SECRET", "x") + path = _write( + tmp_path / "beaconmcp.yaml", + """ + version: 1 + proxmox: + nodes: + - name: pve1 + host: 10.0.0.1 + token_id: root@pam!beaconmcp + token_secret: ${PVE1_TOKEN_SECRET} + ssh: + defaults: + user: root + inherit_proxmox_nodes: true + """, + ) + with pytest.raises(ConfigError, match="ssh.defaults.*neither"): Config.load(config_path=path) @@ -261,7 +403,12 @@ def test_ssh_host_requires_one_auth_method( def test_ssh_empty_hosts_list_rejected(tmp_path: Path) -> None: - """An ssh: section with no hosts[] entries is a config mistake.""" + """An ssh: section that resolves to zero hosts is a config mistake. + + Empty ``hosts: []`` with no ``inherit_proxmox_nodes`` → the SSH section + contributes nothing, which is almost certainly an oversight. Require + the user to either declare a host or flip inheritance on. + """ path = _write( tmp_path / "beaconmcp.yaml", """ @@ -270,7 +417,7 @@ def test_ssh_empty_hosts_list_rejected(tmp_path: Path) -> None: hosts: [] """, ) - with pytest.raises(ConfigError, match="at least one host entry"): + with pytest.raises(ConfigError, match="inherit_proxmox_nodes"): Config.load(config_path=path) From c538edf81f10b246dd1ac5851b17c0bed8713c22 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 22:38:36 +0200 Subject: [PATCH 086/155] Docker image + LAN-IP convention for host: fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-stage Dockerfile (Python 3.13 slim, wheel built in stage 1, installed into a non-root runtime in stage 2) and a minimal compose file with network_mode: host so LAN IPs in proxmox.nodes[] resolve directly. State (clients.json, dashboard.db) lands in a named volume; BEACONMCP_CLIENTS_FILE and BEACONMCP_DASHBOARD_DB point it at /state/ in the image. .dockerignore trims the build context. Shifts the documented convention for proxmox.nodes[].host away from localhost / public FQDN-with-port (pve2.example.com:443) toward LAN IPs. A LAN IP is the one string that works cleanly for both the Proxmox API and ssh.inherit_proxmox_nodes — same address, two services. A public FQDN on :443 routes only the API through the reverse proxy and breaks SSH inheritance silently. README gets a Docker quickstart and a second install path; the bare- metal script stays supported. The yaml.example uses 10.0.0.1 / 10.0.0.2 as sample LAN IPs and documents when to override inheritance (remote nodes behind a tunnel). Co-Authored-By: Claude Opus 4.7 (1M context) --- .dockerignore | 53 ++++++++++++++++++++++++++++++++++++++++++ Dockerfile | 52 +++++++++++++++++++++++++++++++++++++++++ README.md | 38 ++++++++++++++++++++++++++---- beaconmcp.yaml.example | 26 +++++++++++++++------ docker-compose.yml | 19 +++++++++++++++ 5 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8f020e5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,53 @@ +# Keep the build context small — ship only what's needed to build the wheel. + +# VCS & editors +.git +.gitignore +.github +.vscode +.idea + +# Python caches +__pycache__ +*.pyc +*.pyo +*.pyd +.pytest_cache +.mypy_cache +.ruff_cache +.coverage +htmlcov +*.egg-info +build +dist + +# Local venvs and env files — never bake secrets into the image +.venv +venv +env +.env +.env.* +!.env.example + +# Runtime state that should NOT go into the image +clients.json +dashboard.db +*.sqlite +*.sqlite3 + +# Local config (user-specific — built image uses /config at runtime) +beaconmcp.yaml + +# Docs / tooling / ops extras that aren't needed at runtime +docs +deploy/install.sh +deploy/beaconmcp.service +.playwright-mcp +.claude + +# OS cruft +.DS_Store +Thumbs.db + +# Tests aren't shipped in the wheel +tests diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..10c84bc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +FROM python:3.13-slim AS builder + +ENV PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PYTHONDONTWRITEBYTECODE=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY pyproject.toml README.md ./ +COPY src ./src + +RUN pip install --upgrade pip build \ + && python -m build --wheel --outdir /dist + + +FROM python:3.13-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + BEACONMCP_CONFIG=/config/beaconmcp.yaml \ + BEACONMCP_DASHBOARD_DB=/state/dashboard.db \ + BEACONMCP_CLIENTS_FILE=/state/clients.json + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ipmitool ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd --system --gid 10001 beaconmcp \ + && useradd --system --uid 10001 --gid beaconmcp --home /app --shell /usr/sbin/nologin beaconmcp + +COPY --from=builder /dist/*.whl /tmp/ +RUN pip install /tmp/*.whl \ + && rm -f /tmp/*.whl + +RUN mkdir -p /config /state \ + && chown -R beaconmcp:beaconmcp /config /state + +USER beaconmcp +WORKDIR /app + +EXPOSE 8420 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8420/health', timeout=3).read(); sys.exit(0)" \ + || exit 1 + +ENTRYPOINT ["beaconmcp"] +CMD ["serve"] diff --git a/README.md b/README.md index ab6150b..4e30105 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,9 @@ Clients (Claude, ChatGPT, Gemini) BeaconMCP runs on any host that can reach the Proxmox API of every declared node and the BMC management network. It speaks MCP over Streamable HTTP and is typically placed behind a reverse proxy with DNS-rebinding protection configured via `server.allowed_hosts` in the YAML. -**Recommended deployment:** run BeaconMCP **directly on one of your Proxmox nodes** (the primary one, conventionally `pve1`). That node becomes addressable as `host: localhost` in the YAML — both the Proxmox API (`:8006`) and SSH (`:22`) are reachable without a reverse proxy or tunnel, which also lets the `bmc_*` SSH-jump tunnel feature (HP iLO on a private management VLAN) work without extra configuration. Remote nodes in the cluster keep using their public FQDN. +**Recommended deployment:** put BeaconMCP on the **same local network** as your Proxmox cluster — on one of the nodes, in a dedicated LXC / VM, or in a Docker container with host networking (see *Docker* below). That way every `proxmox.nodes[].host` is a plain **LAN IP** (e.g. `10.0.0.1`, `10.0.0.2`), usable as-is for both the Proxmox API (`:8006`) and for SSH (`:22`) — including the `ssh.inherit_proxmox_nodes` shortcut and the `bmc_*` SSH-jump tunnel for HP iLO on a private management VLAN. + +Public FQDNs with reverse-proxy ports (`pve2.example.com:443`) pin the entry to HTTPS and break the SSH inheritance — the SSH service is on port 22 of the node, not behind the HTTPS tunnel. For a truly remote node, declare it explicitly under `ssh.hosts[]` with its real SSH address (Tailscale IP, VPN, bastion…). --- @@ -77,9 +79,37 @@ BeaconMCP runs on any host that can reach the Proxmox API of every declared node ## Installation -### 1. Install +Two supported paths: **Docker** (quickest, isolated) or the **bare-metal install script** (native systemd service). Pick whichever fits your infra — they expose the same CLI and HTTP surface. + +### Option A — Docker (recommended for most setups) + +Requires Docker Engine 20.10+ with the Compose plugin. Runs on the Proxmox node itself, inside an LXC/VM on the same LAN, or on any box that can reach every declared node's API and SSH port directly. + +```bash +git clone https://github.com/Showdown76py/BeaconMCP.git +cd BeaconMCP +cp beaconmcp.yaml.example beaconmcp.yaml # edit for your topology +cp .env.example .env # fill in the ${VAR} secrets +docker compose up -d +``` + +The bundled [`docker-compose.yml`](docker-compose.yml) uses `network_mode: host` so the container sits directly on the LAN — LAN IPs in `proxmox.nodes[].host` just work for both the Proxmox API (`:8006`) and SSH (`:22`), which is what makes the `ssh.inherit_proxmox_nodes` shortcut practical. State (OAuth clients, dashboard DB, usage history) lives in a named volume `beaconmcp-state` and survives container recreation. + +Initial setup (run once, while the container is up): + +```bash +docker compose exec beaconmcp beaconmcp validate-config +docker compose exec beaconmcp beaconmcp auth create --name "Claude Web" +curl http://localhost:8420/health # should return {"status":"ok",...} +``` + +The container listens on port 8420; put HTTPS + your FQDN in front with any reverse proxy (Caddy, nginx, Traefik, Cloudflare tunnel). + +**SSH key files.** If any of your `ssh.hosts[]` entries (or `ssh.defaults`) use `key_file:`, either copy the keys into the `beaconmcp-state` volume and reference them via `/state/keys/...`, or uncomment the `~/.ssh` bind mount in the compose file. Host paths like `~/.ssh/id_ed25519` don't exist inside the container — they're resolved against the container's filesystem. + +### Option B — Bare-metal install script -SSH to the Proxmox node that will host BeaconMCP (we recommend your primary node — pve1 in typical setups), then: +SSH to the Proxmox node that will host BeaconMCP (we recommend your primary node — `pve1` in typical setups), then: ```bash git clone https://github.com/Showdown76py/BeaconMCP.git /opt/beaconmcp @@ -182,7 +212,7 @@ Common keys: |---------|-------| | `server.allowed_hosts` | DNS-rebinding allowlist — **must** include the public FQDN behind your reverse proxy. | | `server.allowed_origins` | CORS allowlist for browser-based MCP clients. | -| `proxmox.nodes[]` | One entry per Proxmox node. Needs an API token per node. For the host BeaconMCP itself runs on, use `host: localhost` — both the API (`:8006`) and SSH (`:22`) are reachable locally without going through any reverse proxy or tunnel. Remote nodes in the cluster use their FQDN (append `:443` if a reverse proxy terminates the API). | +| `proxmox.nodes[]` | One entry per Proxmox node. Needs an API token per node. Prefer a **LAN IP** in `host:` (e.g. `10.0.0.1`) — it's the one string that works for both the Proxmox API and for SSH inheritance. `localhost` is OK when BeaconMCP runs directly on that node. Only use an FQDN with a reverse-proxy port (e.g. `:443`) for nodes you can't reach on the LAN, and declare those explicitly under `ssh.hosts[]` with their real SSH address. | | `ssh.hosts[]` | One entry per SSH target (VPS, Proxmox node, jump box, …). Each entry carries its own `user` + exactly one of `password` / `key_file`. Names may match `proxmox.nodes[].name`. | | `ssh.defaults` + `ssh.inherit_proxmox_nodes` | Homelab shortcut. Set `defaults:` (user + password/key_file) and flip `inherit_proxmox_nodes: true` — every Proxmox node becomes SSH-reachable under its own name with those defaults, no duplication. Explicit `ssh.hosts[]` entries still win when they match a node by name or address. | | `ssh.vmid_to_ip` | Optional template (e.g. `"192.168.1.{id}"`) used by `ssh_run` when the `host` argument is a bare VMID. The resolved IP must match an `ssh.hosts[].host` to authenticate. Omit to disable numeric-ID shortcuts. | diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index 6de006f..0583fe2 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -51,18 +51,28 @@ server: # -------- Proxmox capability (optional) ------------------------------------ # Delete this section if you have no Proxmox cluster. Tools starting with # `proxmox_` won't be registered. +# +# `host:` convention — use the node's **LAN address** whenever BeaconMCP is +# on the same local network as the cluster. It's the one string that works +# cleanly for *both* the Proxmox API (port 8006) and SSH inheritance (port +# 22) — same IP, two services. A `localhost` is fine when BeaconMCP runs +# directly on that node. Avoid public FQDNs with reverse-proxy ports (e.g. +# `pve2.example.com:443`): the port pins the API to HTTPS and breaks the +# `ssh.inherit_proxmox_nodes` shortcut (SSH is on :22, not :443). For a +# fully remote node, declare it explicitly under `ssh.hosts[]` with its +# real SSH address (Tailscale IP, VPN tunnel, bastion…). proxmox: verify_ssl: false # One entry per Proxmox node. N nodes supported; the first one is not special. # Each node needs an API token (Datacenter > Permissions > API Tokens). nodes: - name: pve1 - host: localhost # node BeaconMCP runs on + host: 10.0.0.1 # LAN IP — works for both API and SSH token_id: "root@pam!beaconmcp" # quotes required: '!' and '@' are YAML-reserved token_secret: ${PVE1_TOKEN_SECRET} - name: pve2 - host: pve2.example.com:443 # reachable remote node (reverse-proxied API) + host: 10.0.0.2 # LAN IP of the second node token_id: "root@pam!beaconmcp" token_secret: ${PVE2_TOKEN_SECRET} @@ -110,12 +120,14 @@ ssh: user: admin password: ${VPS2_PW} - # Example override: if pve2 needs a different key than `defaults`, declare - # it explicitly — inheritance sees the name match and skips synthesizing. - # - name: pve2 - # host: pve2.example.com + # Example: a Proxmox node BeaconMCP can't reach on the LAN (remote datacenter, + # API behind a Cloudflare tunnel on :443, etc.) — the inherited SSH entry + # would point at the API URL which is wrong for SSH. Declare the node here + # explicitly with its real SSH address and the override wins. + # - name: pve-remote + # host: 100.64.5.3 # Tailscale IP, VPN tunnel, bastion, … # user: root - # key_file: ~/.ssh/pve2_only + # key_file: ~/.ssh/pve_remote # -------- BMC capability (optional) ---------------------------------------- # Delete this section if you have no HP iLO / IPMI / iDRAC / Supermicro diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6c4af4b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + beaconmcp: + build: + context: . + dockerfile: Dockerfile + image: beaconmcp:local + container_name: beaconmcp + restart: unless-stopped + network_mode: host + env_file: + - .env + volumes: + - ./beaconmcp.yaml:/config/beaconmcp.yaml:ro + - beaconmcp-state:/state + # - /root/.ssh:/home/beaconmcp/.ssh:ro + +volumes: + beaconmcp-state: + name: beaconmcp-state From 1d31fecd51c1c86bcf234a2d05c86ef466896dc5 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Fri, 17 Apr 2026 22:59:57 +0200 Subject: [PATCH 087/155] =?UTF-8?q?Add=20`beaconmcp=20init`=20=E2=80=94=20?= =?UTF-8?q?interactive=20TUI=20config=20wizard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-pane textual app: section menu on the left, forms in the middle, live beaconmcp.yaml preview on the right. Covers the whole bootstrap path — Proxmox nodes, SSH (defaults + inherit_proxmox_nodes toggle + explicit hosts), BMC devices, and server allowlists. The preview refreshes on every field change so you see the exact YAML that'll land on disk. Secrets never enter the draft directly: fields ask for the env-var name (e.g. PVE1_TOKEN_SECRET), the YAML carries ${NAME} references, and the save flow appends any missing placeholders to .env for the operator to fill in post-wizard. Optional dep: `pip install 'beaconmcp[wizard]'` pulls textual. Slim server installs don't need it. The CLI subcommand prints a clear install hint when the import is missing. Output validates cleanly against Config.load() and exercises the new inherit_proxmox_nodes path end-to-end. Full test suite: 219/219 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 15 +- pyproject.toml | 5 + src/beaconmcp/__main__.py | 24 + src/beaconmcp/wizard.py | 1016 +++++++++++++++++++++++++++++++++++++ 4 files changed, 1059 insertions(+), 1 deletion(-) create mode 100644 src/beaconmcp/wizard.py diff --git a/README.md b/README.md index 4e30105..f6d43d1 100644 --- a/README.md +++ b/README.md @@ -121,13 +121,26 @@ The install script creates a `beaconmcp` system user, installs the package in ed ### 2. Configure +Two ways to produce `beaconmcp.yaml`: + +**Guided (TUI wizard).** A terminal UI walks you through each capability (Proxmox nodes, SSH, BMC, server) with a live YAML preview on the right and adds `${VAR}` placeholders to `.env` for the secrets you'll fill in after: + +```bash +pip install 'beaconmcp[wizard]' # pulls the optional textual dep +beaconmcp init # writes beaconmcp.yaml + extends .env +``` + +Arrow keys to browse sections, `enter` to open forms, `ctrl+s` to save without quitting, `q` to exit. + +**Manual.** Copy the example and edit: + ```bash cp beaconmcp.yaml.example /opt/beaconmcp/beaconmcp.yaml cp .env.example /opt/beaconmcp/.env # Edit both: YAML defines the topology, .env holds the secrets. ``` -The YAML declares Proxmox nodes, BMC devices, SSH credentials, the dashboard configuration, and DNS-rebinding allowlists. Secrets are referenced via `${ENV_VAR}` placeholders resolved at startup against the `.env` file. Validate the result without starting the server: +Either way, the YAML declares Proxmox nodes, BMC devices, SSH credentials, the dashboard configuration, and DNS-rebinding allowlists. Secrets are referenced via `${ENV_VAR}` placeholders resolved at startup against the `.env` file. Validate the result without starting the server: ```bash beaconmcp validate-config diff --git a/pyproject.toml b/pyproject.toml index f692636..7b62b26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,11 @@ dependencies = [ "google-genai>=1.0", ] +[project.optional-dependencies] +# Interactive TUI config wizard (`beaconmcp init`). Kept optional so slim +# server deployments don't pull the textual stack just to run the daemon. +wizard = ["textual>=0.85"] + [project.scripts] beaconmcp = "beaconmcp.__main__:main" diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 8dcdcb3..635c8b6 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -59,6 +59,20 @@ def main(): help="Path to beaconmcp.yaml (overrides BEACONMCP_CONFIG and the default search)", ) + # --- init (interactive TUI wizard) --- + init_parser = sub.add_parser( + "init", + help="Interactive TUI to build a beaconmcp.yaml (needs 'beaconmcp[wizard]')", + ) + init_parser.add_argument( + "--config", type=Path, default=None, + help="Output path (default: ./beaconmcp.yaml)", + ) + init_parser.add_argument( + "--env", type=Path, default=Path(".env"), + help="Path to .env where referenced ${VAR} names are appended", + ) + # --- auth --- auth_parser = sub.add_parser("auth", help="Manage OAuth client credentials") auth_sub = auth_parser.add_subparsers(dest="auth_command") @@ -82,6 +96,16 @@ def main(): _cmd_auth(args) elif args.command == "validate-config": _cmd_validate_config(args) + elif args.command == "init": + _cmd_init(args) + + +def _cmd_init(args): + from .wizard import run_wizard + + yaml_path = args.config if args.config else Path(os.environ.get("BEACONMCP_CONFIG", "beaconmcp.yaml")) + env_path = args.env + sys.exit(run_wizard(yaml_path=yaml_path, env_path=env_path)) def _cmd_validate_config(args): diff --git a/src/beaconmcp/wizard.py b/src/beaconmcp/wizard.py new file mode 100644 index 0000000..1ae9f15 --- /dev/null +++ b/src/beaconmcp/wizard.py @@ -0,0 +1,1016 @@ +"""Interactive TUI config wizard for BeaconMCP (`beaconmcp init`). + +Three-pane layout: section menu on the left, section-specific form in the +middle, live ``beaconmcp.yaml`` preview on the right. The draft stays in +memory until the user saves — at which point the YAML gets written to +disk and any referenced ``${VAR}`` placeholders are appended to ``.env`` +with empty values for the user to fill in. + +The wizard is intentionally a **bootstrap** tool, not a full config +editor. It covers the capabilities (Proxmox, SSH, BMC), the critical +server fields (allowed_hosts / allowed_origins), and nothing else — +tweaks to dashboard settings or obscure fields happen by editing the +resulting YAML directly. Keeping the scope small means the preview pane +stays honest: what you see is the whole file. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable + +try: + from textual.app import App, ComposeResult + from textual.binding import Binding + from textual.containers import Horizontal, Vertical, VerticalScroll + from textual.screen import ModalScreen + from textual.widgets import ( + Button, + DataTable, + Footer, + Header, + Input, + Label, + ListItem, + ListView, + Static, + Switch, + TextArea, + ) +except ImportError as _exc: # pragma: no cover - import guard + _WIZARD_IMPORT_ERROR = _exc +else: + _WIZARD_IMPORT_ERROR = None + + +# --------------------------------------------------------------------------- +# Draft data model (lenient mirror of beaconmcp.config dataclasses) +# --------------------------------------------------------------------------- + + +@dataclass +class PVENodeDraft: + name: str = "" + host: str = "" + token_id: str = "" + token_secret_env: str = "" # env var name, rendered as ${NAME} + + +@dataclass +class SSHDefaultsDraft: + user: str = "root" + port: int = 22 + # Exactly one of these two should be set in a valid draft. Wizard + # enforces it via the form, but the model accepts both empty so the + # user can start typing. + password_env: str = "" + key_file: str = "" + + +@dataclass +class SSHHostDraft: + name: str = "" + host: str = "" + user: str = "root" + port: int = 22 + password_env: str = "" + key_file: str = "" + + +@dataclass +class SSHDraft: + enabled: bool = True + vmid_to_ip: str = "" + inherit_proxmox_nodes: bool = True + defaults: SSHDefaultsDraft = field(default_factory=SSHDefaultsDraft) + hosts: list[SSHHostDraft] = field(default_factory=list) + + +@dataclass +class BMCDeviceDraft: + id: str = "" + type: str = "hp_ilo" + host: str = "" + user: str = "" + password_env: str = "" + jump_host: str = "" # references ssh.hosts[].name + + +@dataclass +class ServerDraft: + allowed_hosts: list[str] = field(default_factory=lambda: ["127.0.0.1:*", "localhost:*", "[::1]:*"]) + allowed_origins: list[str] = field( + default_factory=lambda: [ + "https://claude.ai", + "https://chatgpt.com", + "https://chat.mistral.ai", + "https://gemini.google.com", + ] + ) + + +@dataclass +class ConfigDraft: + server: ServerDraft = field(default_factory=ServerDraft) + pve_nodes: list[PVENodeDraft] = field(default_factory=list) + ssh: SSHDraft = field(default_factory=SSHDraft) + bmc_devices: list[BMCDeviceDraft] = field(default_factory=list) + + def referenced_env_vars(self) -> list[str]: + """Collect every ``${VAR}`` name the draft references. + + Used when saving to append placeholders to ``.env`` so the user has + one file to fill in after the wizard exits. + """ + names: list[str] = [] + for n in self.pve_nodes: + if n.token_secret_env: + names.append(n.token_secret_env) + if self.ssh.enabled: + if self.ssh.defaults.password_env: + names.append(self.ssh.defaults.password_env) + for h in self.ssh.hosts: + if h.password_env: + names.append(h.password_env) + for d in self.bmc_devices: + if d.password_env: + names.append(d.password_env) + # Dedupe while preserving order + seen: set[str] = set() + out: list[str] = [] + for name in names: + if name in seen: + continue + seen.add(name) + out.append(name) + return out + + +# --------------------------------------------------------------------------- +# YAML rendering — hand-rolled so we control comments and quoting precisely. +# --------------------------------------------------------------------------- + + +def _q(value: str) -> str: + """Quote a YAML scalar when it contains reserved characters.""" + if not value: + return '""' + if any(ch in value for ch in "!@:#&*`{}[]|>?,%"): + return f'"{value}"' + if value.lower() in {"true", "false", "yes", "no", "on", "off", "null", "~"}: + return f'"{value}"' + return value + + +def render_yaml(draft: ConfigDraft) -> str: + """Render the draft as a ``beaconmcp.yaml`` string.""" + lines: list[str] = [] + lines.append("# Generated by `beaconmcp init`. Edit freely once saved.") + lines.append("version: 1") + lines.append("") + + # Server + lines.append("server:") + lines.append(" host: 0.0.0.0") + lines.append(" port: 8420") + if draft.server.allowed_hosts: + lines.append(" allowed_hosts:") + for h in draft.server.allowed_hosts: + lines.append(f" - {_q(h)}") + if draft.server.allowed_origins: + lines.append(" allowed_origins:") + for o in draft.server.allowed_origins: + lines.append(f" - {o}") + lines.append("") + + # Proxmox + if draft.pve_nodes: + lines.append("proxmox:") + lines.append(" verify_ssl: false") + lines.append(" nodes:") + for n in draft.pve_nodes: + lines.append(f" - name: {_q(n.name)}") + lines.append(f" host: {_q(n.host)}") + lines.append(f" token_id: {_q(n.token_id)}") + secret = f"${{{n.token_secret_env}}}" if n.token_secret_env else '""' + lines.append(f" token_secret: {secret}") + lines.append("") + + # SSH + if draft.ssh.enabled and ( + draft.ssh.hosts + or draft.ssh.inherit_proxmox_nodes + or draft.ssh.vmid_to_ip + ): + lines.append("ssh:") + if draft.ssh.vmid_to_ip: + lines.append(f" vmid_to_ip: {_q(draft.ssh.vmid_to_ip)}") + d = draft.ssh.defaults + if draft.ssh.inherit_proxmox_nodes or d.password_env or d.key_file: + lines.append(" defaults:") + lines.append(f" user: {_q(d.user)}") + if d.port and d.port != 22: + lines.append(f" port: {d.port}") + if d.key_file: + lines.append(f" key_file: {_q(d.key_file)}") + elif d.password_env: + lines.append(f" password: ${{{d.password_env}}}") + if draft.ssh.inherit_proxmox_nodes: + lines.append(" inherit_proxmox_nodes: true") + if draft.ssh.hosts: + lines.append(" hosts:") + for h in draft.ssh.hosts: + lines.append(f" - name: {_q(h.name)}") + lines.append(f" host: {_q(h.host)}") + lines.append(f" user: {_q(h.user)}") + if h.port and h.port != 22: + lines.append(f" port: {h.port}") + if h.key_file: + lines.append(f" key_file: {_q(h.key_file)}") + elif h.password_env: + lines.append(f" password: ${{{h.password_env}}}") + lines.append("") + + # BMC + if draft.bmc_devices: + lines.append("bmc:") + lines.append(" devices:") + for b in draft.bmc_devices: + lines.append(f" - id: {_q(b.id)}") + lines.append(f" type: {b.type}") + lines.append(f" host: {_q(b.host)}") + lines.append(f" user: {_q(b.user)}") + secret = f"${{{b.password_env}}}" if b.password_env else '""' + lines.append(f" password: {secret}") + if b.jump_host: + lines.append(f" jump_host: {_q(b.jump_host)}") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +# --------------------------------------------------------------------------- +# Textual app +# --------------------------------------------------------------------------- + +SECTIONS = [ + ("proxmox", "Proxmox nodes"), + ("ssh", "SSH"), + ("bmc", "BMC devices"), + ("server", "Server"), + ("save", "Save & exit"), +] + + +CSS = """ +Screen { + layout: vertical; +} + +#body { + layout: horizontal; + height: 1fr; +} + +#sidebar { + width: 24; + border-right: solid $primary-background; + padding: 1; +} + +#sidebar ListView { + background: $surface; + height: auto; +} + +#main { + width: 1fr; + padding: 1 2; +} + +#preview { + width: 55; + padding: 1; + border-left: solid $primary-background; +} + +#preview-title { + color: $text-muted; + text-style: bold; + margin-bottom: 1; +} + +#preview-area { + background: $surface; + border: solid $primary-background; + height: 1fr; +} + +.section-heading { + text-style: bold; + color: $accent; + margin-bottom: 1; +} + +.hint { + color: $text-muted; + margin-bottom: 1; +} + +DataTable { + height: auto; + max-height: 12; + margin: 1 0; +} + +.form-row { + layout: horizontal; + height: auto; + margin: 0 0 1 0; +} + +.form-row Label { + width: 16; + padding: 1 1 0 0; +} + +.form-row Input { + width: 1fr; +} + +.form-actions { + layout: horizontal; + height: auto; + margin-top: 1; +} + +.form-actions Button { + margin-right: 1; +} + +Switch { + margin-right: 1; +} +""" + + +# --------------------------------------------------------------------------- +# Modals — forms for add/edit flows +# --------------------------------------------------------------------------- + + +class _FormModal(ModalScreen[dict[str, str] | None]): + """Generic modal with a list of (label, key, initial) fields. + + Returns a dict of entered values on Save, or None on Cancel. + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("ctrl+s", "save", "Save"), + ] + + def __init__( + self, + title: str, + fields: list[tuple[str, str, str]], + hint: str = "", + ) -> None: + super().__init__() + self._title = title + self._fields = fields + self._hint = hint + + def compose(self) -> ComposeResult: + with Vertical(id="modal-box"): + yield Static(self._title, classes="section-heading") + if self._hint: + yield Static(self._hint, classes="hint") + for label, key, initial in self._fields: + with Horizontal(classes="form-row"): + yield Label(label + ":") + yield Input(value=initial, id=f"f-{key}") + with Horizontal(classes="form-actions"): + yield Button("Save", id="ok", variant="primary") + yield Button("Cancel", id="cancel") + + def on_mount(self) -> None: + first = self.query(Input).first() + if first is not None: + first.focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "cancel": + self.dismiss(None) + elif event.button.id == "ok": + self.action_save() + + def action_save(self) -> None: + out: dict[str, str] = {} + for _label, key, _initial in self._fields: + out[key] = self.query_one(f"#f-{key}", Input).value.strip() + self.dismiss(out) + + def action_cancel(self) -> None: + self.dismiss(None) + + +# --------------------------------------------------------------------------- +# Section panels — each renders into the centre pane +# --------------------------------------------------------------------------- + + +class _ProxmoxPanel(Static): + def __init__(self, draft: ConfigDraft, on_change: Callable[[], None]) -> None: + super().__init__() + self.draft = draft + self.on_change = on_change + + def compose(self) -> ComposeResult: + yield Static("Proxmox nodes", classes="section-heading") + yield Static( + "One entry per Proxmox node. Use LAN IPs in `host:` — same " + "address will be reused for SSH inheritance.", + classes="hint", + ) + yield DataTable(id="pve-table", cursor_type="row", zebra_stripes=True) + with Horizontal(classes="form-actions"): + yield Button("Add", id="pve-add", variant="primary") + yield Button("Edit", id="pve-edit") + yield Button("Delete", id="pve-delete", variant="error") + + def on_mount(self) -> None: + table = self.query_one("#pve-table", DataTable) + table.add_columns("name", "host", "token_id", "secret env") + self._refresh_table() + + def _refresh_table(self) -> None: + table = self.query_one("#pve-table", DataTable) + table.clear() + for n in self.draft.pve_nodes: + table.add_row( + n.name or "—", + n.host or "—", + n.token_id or "—", + f"${{{n.token_secret_env}}}" if n.token_secret_env else "—", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "pve-add": + self._open_form(None) + elif event.button.id == "pve-edit": + idx = self._selected_row() + if idx is not None: + self._open_form(idx) + elif event.button.id == "pve-delete": + idx = self._selected_row() + if idx is not None: + del self.draft.pve_nodes[idx] + self._refresh_table() + self.on_change() + + def _selected_row(self) -> int | None: + table = self.query_one("#pve-table", DataTable) + if table.cursor_row is None or not self.draft.pve_nodes: + return None + idx = table.cursor_row + if 0 <= idx < len(self.draft.pve_nodes): + return idx + return None + + def _open_form(self, idx: int | None) -> None: + existing = self.draft.pve_nodes[idx] if idx is not None else PVENodeDraft() + default_env = existing.token_secret_env or ( + f"PVE{len(self.draft.pve_nodes) + 1}_TOKEN_SECRET" if idx is None else "" + ) + modal = _FormModal( + title="Proxmox node" if idx is None else f"Edit {existing.name or 'node'}", + hint=( + "host: LAN IP of the node (e.g. 10.0.0.1). token_id: the " + "Proxmox API token ID in user@realm!tokenname shape. The " + "secret itself lives in .env — type the env-var name here." + ), + fields=[ + ("name", "name", existing.name), + ("host", "host", existing.host), + ("token id", "token_id", existing.token_id or "root@pam!beaconmcp"), + ("secret env", "token_secret_env", default_env), + ], + ) + + def after(result: dict[str, str] | None) -> None: + if result is None: + return + entry = PVENodeDraft( + name=result["name"], + host=result["host"], + token_id=result["token_id"], + token_secret_env=result["token_secret_env"], + ) + if idx is None: + self.draft.pve_nodes.append(entry) + else: + self.draft.pve_nodes[idx] = entry + self._refresh_table() + self.on_change() + + self.app.push_screen(modal, after) + + +class _SSHPanel(Static): + def __init__(self, draft: ConfigDraft, on_change: Callable[[], None]) -> None: + super().__init__() + self.draft = draft + self.on_change = on_change + + def compose(self) -> ComposeResult: + yield Static("SSH capability", classes="section-heading") + yield Static( + "Flip inheritance on to reach every Proxmox node via SSH using " + "the `defaults` creds — no per-node duplication.", + classes="hint", + ) + + ssh = self.draft.ssh + with Horizontal(classes="form-row"): + yield Label("Enable SSH:") + yield Switch(value=ssh.enabled, id="ssh-enabled") + with Horizontal(classes="form-row"): + yield Label("vmid_to_ip:") + yield Input( + value=ssh.vmid_to_ip, + placeholder="e.g. 192.168.1.{id} (leave empty to disable)", + id="ssh-vmid", + ) + with Horizontal(classes="form-row"): + yield Label("Inherit PVE nodes:") + yield Switch(value=ssh.inherit_proxmox_nodes, id="ssh-inherit") + + yield Static("Default credentials", classes="section-heading") + yield Static( + "Used for inherited Proxmox entries. Provide exactly one of " + "key_file OR password (env var name).", + classes="hint", + ) + with Horizontal(classes="form-row"): + yield Label("Default user:") + yield Input(value=ssh.defaults.user, id="ssh-def-user") + with Horizontal(classes="form-row"): + yield Label("Key file:") + yield Input( + value=ssh.defaults.key_file, + placeholder="~/.ssh/beaconmcp", + id="ssh-def-key", + ) + with Horizontal(classes="form-row"): + yield Label("Password env:") + yield Input( + value=ssh.defaults.password_env, + placeholder="(only if no key_file)", + id="ssh-def-pw", + ) + + yield Static("Explicit hosts", classes="section-heading") + yield Static( + "Targets outside your Proxmox cluster (VPS, bastion, remote " + "node with its own creds). Names may match a Proxmox node — " + "the explicit entry shadows inheritance.", + classes="hint", + ) + yield DataTable(id="ssh-table", cursor_type="row", zebra_stripes=True) + with Horizontal(classes="form-actions"): + yield Button("Add host", id="ssh-add", variant="primary") + yield Button("Edit", id="ssh-edit") + yield Button("Delete", id="ssh-delete", variant="error") + + def on_mount(self) -> None: + table = self.query_one("#ssh-table", DataTable) + table.add_columns("name", "host", "user", "auth") + self._refresh_table() + + def _refresh_table(self) -> None: + table = self.query_one("#ssh-table", DataTable) + table.clear() + for h in self.draft.ssh.hosts: + auth = h.key_file or (f"${{{h.password_env}}}" if h.password_env else "—") + table.add_row(h.name or "—", h.host or "—", h.user or "—", auth) + + def on_switch_changed(self, event: Switch.Changed) -> None: + if event.switch.id == "ssh-enabled": + self.draft.ssh.enabled = event.value + elif event.switch.id == "ssh-inherit": + self.draft.ssh.inherit_proxmox_nodes = event.value + self.on_change() + + def on_input_changed(self, event: Input.Changed) -> None: + ssh = self.draft.ssh + if event.input.id == "ssh-vmid": + ssh.vmid_to_ip = event.value.strip() + elif event.input.id == "ssh-def-user": + ssh.defaults.user = event.value.strip() or "root" + elif event.input.id == "ssh-def-key": + ssh.defaults.key_file = event.value.strip() + if ssh.defaults.key_file: + ssh.defaults.password_env = "" + elif event.input.id == "ssh-def-pw": + ssh.defaults.password_env = event.value.strip() + if ssh.defaults.password_env: + ssh.defaults.key_file = "" + self.on_change() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "ssh-add": + self._open_form(None) + elif event.button.id == "ssh-edit": + idx = self._selected_row() + if idx is not None: + self._open_form(idx) + elif event.button.id == "ssh-delete": + idx = self._selected_row() + if idx is not None: + del self.draft.ssh.hosts[idx] + self._refresh_table() + self.on_change() + + def _selected_row(self) -> int | None: + table = self.query_one("#ssh-table", DataTable) + if table.cursor_row is None or not self.draft.ssh.hosts: + return None + idx = table.cursor_row + if 0 <= idx < len(self.draft.ssh.hosts): + return idx + return None + + def _open_form(self, idx: int | None) -> None: + existing = self.draft.ssh.hosts[idx] if idx is not None else SSHHostDraft() + modal = _FormModal( + title="SSH host" if idx is None else f"Edit {existing.name or 'host'}", + hint=( + "key_file or password env — one of the two, not both. " + "Leave port empty for 22." + ), + fields=[ + ("name", "name", existing.name), + ("host", "host", existing.host), + ("user", "user", existing.user), + ("port", "port", str(existing.port) if existing.port and existing.port != 22 else ""), + ("key_file", "key_file", existing.key_file), + ("password env", "password_env", existing.password_env), + ], + ) + + def after(result: dict[str, str] | None) -> None: + if result is None: + return + port = int(result["port"]) if result["port"].isdigit() else 22 + key = result["key_file"] + pw = result["password_env"] + # Enforce mutual exclusion + if key and pw: + pw = "" + entry = SSHHostDraft( + name=result["name"], + host=result["host"], + user=result["user"] or "root", + port=port, + key_file=key, + password_env=pw, + ) + if idx is None: + self.draft.ssh.hosts.append(entry) + else: + self.draft.ssh.hosts[idx] = entry + self._refresh_table() + self.on_change() + + self.app.push_screen(modal, after) + + +class _BMCPanel(Static): + def __init__(self, draft: ConfigDraft, on_change: Callable[[], None]) -> None: + super().__init__() + self.draft = draft + self.on_change = on_change + + def compose(self) -> ComposeResult: + yield Static("BMC devices", classes="section-heading") + yield Static( + "HP iLO, IPMI, iDRAC or Supermicro. `jump_host` (optional) " + "references an ssh.hosts[] entry by name.", + classes="hint", + ) + yield DataTable(id="bmc-table", cursor_type="row", zebra_stripes=True) + with Horizontal(classes="form-actions"): + yield Button("Add", id="bmc-add", variant="primary") + yield Button("Edit", id="bmc-edit") + yield Button("Delete", id="bmc-delete", variant="error") + + def on_mount(self) -> None: + table = self.query_one("#bmc-table", DataTable) + table.add_columns("id", "type", "host", "jump_host") + self._refresh_table() + + def _refresh_table(self) -> None: + table = self.query_one("#bmc-table", DataTable) + table.clear() + for d in self.draft.bmc_devices: + table.add_row(d.id or "—", d.type, d.host or "—", d.jump_host or "—") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "bmc-add": + self._open_form(None) + elif event.button.id == "bmc-edit": + idx = self._selected_row() + if idx is not None: + self._open_form(idx) + elif event.button.id == "bmc-delete": + idx = self._selected_row() + if idx is not None: + del self.draft.bmc_devices[idx] + self._refresh_table() + self.on_change() + + def _selected_row(self) -> int | None: + table = self.query_one("#bmc-table", DataTable) + if table.cursor_row is None or not self.draft.bmc_devices: + return None + idx = table.cursor_row + if 0 <= idx < len(self.draft.bmc_devices): + return idx + return None + + def _open_form(self, idx: int | None) -> None: + existing = self.draft.bmc_devices[idx] if idx is not None else BMCDeviceDraft() + default_env = existing.password_env or ( + f"BMC{len(self.draft.bmc_devices) + 1}_PASSWORD" if idx is None else "" + ) + modal = _FormModal( + title="BMC device" if idx is None else f"Edit {existing.id or 'device'}", + hint=( + "type: hp_ilo | ipmi | idrac | supermicro. jump_host is the " + "name of an ssh.hosts[] entry used to tunnel into a private " + "management VLAN (leave empty for direct access)." + ), + fields=[ + ("id", "id", existing.id), + ("type", "type", existing.type), + ("host", "host", existing.host), + ("user", "user", existing.user or "Administrator"), + ("password env", "password_env", default_env), + ("jump_host", "jump_host", existing.jump_host), + ], + ) + + def after(result: dict[str, str] | None) -> None: + if result is None: + return + entry = BMCDeviceDraft( + id=result["id"], + type=result["type"] or "hp_ilo", + host=result["host"], + user=result["user"], + password_env=result["password_env"], + jump_host=result["jump_host"], + ) + if idx is None: + self.draft.bmc_devices.append(entry) + else: + self.draft.bmc_devices[idx] = entry + self._refresh_table() + self.on_change() + + self.app.push_screen(modal, after) + + +class _ServerPanel(Static): + def __init__(self, draft: ConfigDraft, on_change: Callable[[], None]) -> None: + super().__init__() + self.draft = draft + self.on_change = on_change + + def compose(self) -> ComposeResult: + yield Static("Server", classes="section-heading") + yield Static( + "DNS-rebinding allowlist + CORS origins. One entry per line.", + classes="hint", + ) + yield Static("allowed_hosts") + yield TextArea( + "\n".join(self.draft.server.allowed_hosts), + id="srv-hosts", + show_line_numbers=False, + ) + yield Static("allowed_origins") + yield TextArea( + "\n".join(self.draft.server.allowed_origins), + id="srv-origins", + show_line_numbers=False, + ) + + def on_text_area_changed(self, event: TextArea.Changed) -> None: + lines = [line.strip() for line in event.text_area.text.splitlines() if line.strip()] + if event.text_area.id == "srv-hosts": + self.draft.server.allowed_hosts = lines + elif event.text_area.id == "srv-origins": + self.draft.server.allowed_origins = lines + self.on_change() + + +class _SavePanel(Static): + def __init__( + self, + draft: ConfigDraft, + yaml_path: Path, + env_path: Path, + on_save: Callable[[Path, Path], None], + ) -> None: + super().__init__() + self.draft = draft + self.yaml_path = yaml_path + self.env_path = env_path + self.on_save = on_save + + def compose(self) -> ComposeResult: + yield Static("Save & exit", classes="section-heading") + yield Static( + f"YAML will be written to: {self.yaml_path}\n" + f".env will be extended at: {self.env_path}", + classes="hint", + ) + yield Static("Referenced env vars (need values in .env):", classes="section-heading") + refs = self.draft.referenced_env_vars() + yield Static("\n".join(f" - {n}" for n in refs) if refs else "(none)") + with Horizontal(classes="form-actions"): + yield Button("Save config", id="save", variant="primary") + yield Button("Cancel", id="cancel") + yield Static("", id="save-status", classes="hint") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "cancel": + self.app.exit() + elif event.button.id == "save": + try: + self.on_save(self.yaml_path, self.env_path) + self.query_one("#save-status", Static).update( + f"Saved. Edit {self.env_path} to fill in the secrets, then " + "run `beaconmcp validate-config`." + ) + except Exception as exc: # noqa: BLE001 + self.query_one("#save-status", Static).update( + f"[red]Save failed: {exc}[/red]" + ) + + +# --------------------------------------------------------------------------- +# Main app +# --------------------------------------------------------------------------- + + +class ConfigWizardApp(App[None]): + CSS = CSS + TITLE = "BeaconMCP — config wizard" + SUB_TITLE = "beaconmcp init" + + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("ctrl+s", "quick_save", "Save"), + ] + + def __init__(self, yaml_path: Path, env_path: Path) -> None: + super().__init__() + self.draft = ConfigDraft() + self.yaml_path = yaml_path + self.env_path = env_path + self._current_section = "proxmox" + + def compose(self) -> ComposeResult: + yield Header(show_clock=False) + with Horizontal(id="body"): + with Vertical(id="sidebar"): + yield Static("Sections", classes="section-heading") + yield ListView( + *[ListItem(Label(label), id=f"sect-{key}") for key, label in SECTIONS], + id="sections", + ) + yield Static("", classes="hint") + yield Static( + "Tip: arrow keys to move, enter to open a section, " + "tab to jump between panes.", + classes="hint", + ) + with VerticalScroll(id="main"): + yield Static("Select a section on the left.", id="main-content") + with Vertical(id="preview"): + yield Static("beaconmcp.yaml (live preview)", id="preview-title") + yield TextArea("", id="preview-area", read_only=True, show_line_numbers=False) + yield Footer() + + def on_mount(self) -> None: + lv = self.query_one("#sections", ListView) + lv.focus() + self._show_section("proxmox") + self._refresh_preview() + + def on_list_view_selected(self, event: ListView.Selected) -> None: + item_id = event.item.id or "" + if not item_id.startswith("sect-"): + return + self._show_section(item_id[len("sect-"):]) + + def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: + # Highlight on arrow keys also swaps the panel, so the user doesn't + # have to press enter to preview each section. + if event.item is None: + return + item_id = event.item.id or "" + if item_id.startswith("sect-"): + self._show_section(item_id[len("sect-"):]) + + def _show_section(self, key: str) -> None: + self._current_section = key + container = self.query_one("#main", VerticalScroll) + container.remove_children() + panel: Static + if key == "proxmox": + panel = _ProxmoxPanel(self.draft, self._refresh_preview) + elif key == "ssh": + panel = _SSHPanel(self.draft, self._refresh_preview) + elif key == "bmc": + panel = _BMCPanel(self.draft, self._refresh_preview) + elif key == "server": + panel = _ServerPanel(self.draft, self._refresh_preview) + elif key == "save": + panel = _SavePanel( + self.draft, self.yaml_path, self.env_path, self._write_files + ) + else: + panel = Static("Unknown section.") + container.mount(panel) + + def _refresh_preview(self) -> None: + self.query_one("#preview-area", TextArea).text = render_yaml(self.draft) + + def action_quick_save(self) -> None: + # Triggered by Ctrl+S anywhere in the app. Doesn't exit — user can + # keep editing. Status gets reflected on the save panel if open. + try: + self._write_files(self.yaml_path, self.env_path) + except Exception: # noqa: BLE001 + pass + + def _write_files(self, yaml_path: Path, env_path: Path) -> None: + yaml_path.parent.mkdir(parents=True, exist_ok=True) + yaml_path.write_text(render_yaml(self.draft), encoding="utf-8") + _merge_env_placeholders(env_path, self.draft.referenced_env_vars()) + + +def _merge_env_placeholders(env_path: Path, names: list[str]) -> None: + """Ensure every referenced env var has a line in ``.env``. + + Existing values are preserved. Missing names get an empty placeholder + with a comment noting the wizard added them. Passing an empty list is + a no-op. + """ + if not names: + return + env_path.parent.mkdir(parents=True, exist_ok=True) + existing = {} + if env_path.exists(): + for line in env_path.read_text(encoding="utf-8").splitlines(): + if "=" in line and not line.lstrip().startswith("#"): + key = line.split("=", 1)[0].strip() + if key: + existing[key] = True + to_add = [n for n in names if n not in existing] + if not to_add: + return + with env_path.open("a", encoding="utf-8") as f: + if env_path.stat().st_size and not env_path.read_text(encoding="utf-8").endswith("\n"): + f.write("\n") + f.write("\n# Added by `beaconmcp init` — fill these in.\n") + for name in to_add: + f.write(f"{name}=\n") + + +# --------------------------------------------------------------------------- +# Entry point used by the CLI +# --------------------------------------------------------------------------- + + +def run_wizard(yaml_path: Path | None = None, env_path: Path | None = None) -> int: + """Launch the wizard. Returns a process exit code.""" + if _WIZARD_IMPORT_ERROR is not None: + print( + "The interactive wizard needs the optional 'textual' dependency.\n" + "Install it with:\n" + " pip install 'beaconmcp[wizard]'\n" + f"Import failed with: {_WIZARD_IMPORT_ERROR}", + ) + return 1 + + yaml_path = yaml_path or Path(os.environ.get("BEACONMCP_CONFIG", "beaconmcp.yaml")) + env_path = env_path or Path(".env") + ConfigWizardApp(yaml_path=yaml_path, env_path=env_path).run() + return 0 From 26edad465c7265e20cd056ffeae71340b6123bfa Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Sat, 18 Apr 2026 19:33:50 +0200 Subject: [PATCH 088/155] beaconmcp init: refuse to overwrite existing config; fix NameError without textual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate papercuts on the freshly-added wizard: 1. When the optional `textual` dependency was missing, wizard.py raised NameError at import time (ModalScreen undefined) instead of the friendly "pip install beaconmcp[wizard]" message run_wizard prints. The try/except caught the ImportError but class definitions below still referenced the unbound names. Added `_Stub` bindings in the except branch so the module loads cleanly. 2. The wizard always starts from a blank ConfigDraft() and on save overwrites beaconmcp.yaml — so running `beaconmcp init` against an existing config silently destroyed it. Gate it behind a file-exists check and a new --force flag until the wizard learns to load existing YAML into the draft. --- src/beaconmcp/__main__.py | 19 +++++++++++++++++++ src/beaconmcp/wizard.py | 14 ++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 635c8b6..fec15c3 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -72,6 +72,10 @@ def main(): "--env", type=Path, default=Path(".env"), help="Path to .env where referenced ${VAR} names are appended", ) + init_parser.add_argument( + "--force", action="store_true", + help="Overwrite an existing beaconmcp.yaml (the wizard does not load it)", + ) # --- auth --- auth_parser = sub.add_parser("auth", help="Manage OAuth client credentials") @@ -105,6 +109,21 @@ def _cmd_init(args): yaml_path = args.config if args.config else Path(os.environ.get("BEACONMCP_CONFIG", "beaconmcp.yaml")) env_path = args.env + + # The wizard always starts from a blank draft and will overwrite + # whatever is at yaml_path on save. Refuse to proceed if a config + # already exists so we don't silently clobber it. + if yaml_path.exists() and not args.force: + print( + f"ERROR: {yaml_path} already exists.\n" + f"The wizard does not load existing configs — running it would " + f"overwrite this file with an empty config.\n" + f"Edit the YAML directly, or pass --force to start from scratch " + f"(back up first).", + file=sys.stderr, + ) + sys.exit(1) + sys.exit(run_wizard(yaml_path=yaml_path, env_path=env_path)) diff --git a/src/beaconmcp/wizard.py b/src/beaconmcp/wizard.py index 1ae9f15..c43b958 100644 --- a/src/beaconmcp/wizard.py +++ b/src/beaconmcp/wizard.py @@ -41,6 +41,20 @@ ) except ImportError as _exc: # pragma: no cover - import guard _WIZARD_IMPORT_ERROR = _exc + + # Stubs so the class definitions below can be loaded without textual + # installed. Actual use is gated by `_WIZARD_IMPORT_ERROR` in + # `run_wizard`, which prints an install hint and exits. + class _Stub: + def __init__(self, *args: Any, **kwargs: Any) -> None: ... + def __class_getitem__(cls, item: Any) -> type: # noqa: D401 + return cls + + App = ComposeResult = Binding = _Stub # type: ignore[assignment,misc] + Horizontal = Vertical = VerticalScroll = _Stub # type: ignore[assignment,misc] + ModalScreen = _Stub # type: ignore[assignment,misc] + Button = DataTable = Footer = Header = Input = Label = _Stub # type: ignore[assignment,misc] + ListItem = ListView = Static = Switch = TextArea = _Stub # type: ignore[assignment,misc] else: _WIZARD_IMPORT_ERROR = None From f2449692792d74ecd73497e115039711fb905f98 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Sat, 18 Apr 2026 19:37:27 +0200 Subject: [PATCH 089/155] wizard: load existing beaconmcp.yaml so `init` doubles as an editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the wizard always started from a blank ConfigDraft() and happily overwrote whatever was at yaml_path on save — running `beaconmcp init` against an existing config silently destroyed it (and the previous commit worked around that by refusing to run). Now `load_yaml_into_draft()` parses the YAML into the draft model, preserving ${VAR} placeholders rather than resolving them, so the round-trip is lossless. `beaconmcp init` without args edits the existing file; `--blank` starts fresh. The --force overwrite guard is gone — it's no longer needed. --- README.md | 5 +- src/beaconmcp/__main__.py | 26 ++------ src/beaconmcp/wizard.py | 136 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 140 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index f6d43d1..0c99746 100644 --- a/README.md +++ b/README.md @@ -123,11 +123,12 @@ The install script creates a `beaconmcp` system user, installs the package in ed Two ways to produce `beaconmcp.yaml`: -**Guided (TUI wizard).** A terminal UI walks you through each capability (Proxmox nodes, SSH, BMC, server) with a live YAML preview on the right and adds `${VAR}` placeholders to `.env` for the secrets you'll fill in after: +**Guided (TUI wizard).** A terminal UI walks you through each capability (Proxmox nodes, SSH, BMC, server) with a live YAML preview on the right and adds `${VAR}` placeholders to `.env` for the secrets you'll fill in after. The same command also **edits an existing** `beaconmcp.yaml` — it parses the file into the wizard, so you can tweak and re-save without losing anything: ```bash pip install 'beaconmcp[wizard]' # pulls the optional textual dep -beaconmcp init # writes beaconmcp.yaml + extends .env +beaconmcp init # creates OR edits beaconmcp.yaml, extends .env +beaconmcp init --blank # force a fresh draft even if the YAML exists ``` Arrow keys to browse sections, `enter` to open forms, `ctrl+s` to save without quitting, `q` to exit. diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index fec15c3..57d8e39 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -62,19 +62,20 @@ def main(): # --- init (interactive TUI wizard) --- init_parser = sub.add_parser( "init", - help="Interactive TUI to build a beaconmcp.yaml (needs 'beaconmcp[wizard]')", + help="Interactive TUI to create or edit beaconmcp.yaml (needs 'beaconmcp[wizard]')", ) init_parser.add_argument( "--config", type=Path, default=None, - help="Output path (default: ./beaconmcp.yaml)", + help="YAML path to create or edit (default: ./beaconmcp.yaml)", ) init_parser.add_argument( "--env", type=Path, default=Path(".env"), help="Path to .env where referenced ${VAR} names are appended", ) init_parser.add_argument( - "--force", action="store_true", - help="Overwrite an existing beaconmcp.yaml (the wizard does not load it)", + "--blank", action="store_true", + help="Start from an empty draft even if the YAML already exists " + "(the existing file is only overwritten when you save)", ) # --- auth --- @@ -109,22 +110,7 @@ def _cmd_init(args): yaml_path = args.config if args.config else Path(os.environ.get("BEACONMCP_CONFIG", "beaconmcp.yaml")) env_path = args.env - - # The wizard always starts from a blank draft and will overwrite - # whatever is at yaml_path on save. Refuse to proceed if a config - # already exists so we don't silently clobber it. - if yaml_path.exists() and not args.force: - print( - f"ERROR: {yaml_path} already exists.\n" - f"The wizard does not load existing configs — running it would " - f"overwrite this file with an empty config.\n" - f"Edit the YAML directly, or pass --force to start from scratch " - f"(back up first).", - file=sys.stderr, - ) - sys.exit(1) - - sys.exit(run_wizard(yaml_path=yaml_path, env_path=env_path)) + sys.exit(run_wizard(yaml_path=yaml_path, env_path=env_path, start_blank=args.blank)) def _cmd_validate_config(args): diff --git a/src/beaconmcp/wizard.py b/src/beaconmcp/wizard.py index c43b958..e42151c 100644 --- a/src/beaconmcp/wizard.py +++ b/src/beaconmcp/wizard.py @@ -17,6 +17,7 @@ from __future__ import annotations import os +import re from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable @@ -265,6 +266,110 @@ def render_yaml(draft: ConfigDraft) -> str: return "\n".join(lines).rstrip() + "\n" +# --------------------------------------------------------------------------- +# YAML loading — inverse of render_yaml. Preserves ${VAR} placeholders +# rather than resolving them so the draft round-trips cleanly. +# --------------------------------------------------------------------------- + + +_ENV_REF = re.compile(r"^\$\{([A-Z_][A-Z0-9_]*)\}$") + + +def _env_name(value: Any) -> str: + """Return the VAR name from a ``${VAR}`` scalar, or '' if it isn't one.""" + if not isinstance(value, str): + return "" + m = _ENV_REF.match(value.strip()) + return m.group(1) if m else "" + + +def load_yaml_into_draft(path: Path) -> ConfigDraft: + """Parse ``beaconmcp.yaml`` into a ``ConfigDraft`` for the wizard. + + Unknown or malformed sections are skipped rather than raised — this is + an editing convenience, not a validating loader. ``beaconmcp + validate-config`` remains the source of truth. + + Secret fields (``token_secret``, ``password``) are read as raw strings; + if they match ``${VAR}`` the env var name is stored in the ``*_env`` + draft field so saving renders the same placeholder back. + """ + import yaml # lazy: keep import cost off the module load path + + draft = ConfigDraft() + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError): + return draft + if not isinstance(raw, dict): + return draft + + server = raw.get("server") or {} + if isinstance(server, dict): + hosts = server.get("allowed_hosts") + if isinstance(hosts, list): + draft.server.allowed_hosts = [str(h) for h in hosts if h] + origins = server.get("allowed_origins") + if isinstance(origins, list): + draft.server.allowed_origins = [str(o) for o in origins if o] + + proxmox = raw.get("proxmox") or {} + if isinstance(proxmox, dict): + for node in proxmox.get("nodes") or []: + if not isinstance(node, dict): + continue + draft.pve_nodes.append(PVENodeDraft( + name=str(node.get("name") or ""), + host=str(node.get("host") or ""), + token_id=str(node.get("token_id") or ""), + token_secret_env=_env_name(node.get("token_secret")), + )) + + ssh = raw.get("ssh") + if isinstance(ssh, dict): + draft.ssh.enabled = True + draft.ssh.vmid_to_ip = str(ssh.get("vmid_to_ip") or "") + draft.ssh.inherit_proxmox_nodes = bool(ssh.get("inherit_proxmox_nodes", False)) + defaults = ssh.get("defaults") or {} + if isinstance(defaults, dict): + draft.ssh.defaults.user = str(defaults.get("user") or "root") + port = defaults.get("port") + draft.ssh.defaults.port = int(port) if isinstance(port, int) else 22 + draft.ssh.defaults.key_file = str(defaults.get("key_file") or "") + draft.ssh.defaults.password_env = _env_name(defaults.get("password")) + for host in ssh.get("hosts") or []: + if not isinstance(host, dict): + continue + port = host.get("port") + draft.ssh.hosts.append(SSHHostDraft( + name=str(host.get("name") or ""), + host=str(host.get("host") or ""), + user=str(host.get("user") or "root"), + port=int(port) if isinstance(port, int) else 22, + password_env=_env_name(host.get("password")), + key_file=str(host.get("key_file") or ""), + )) + else: + # No ssh block means SSH is disabled in the saved config. + draft.ssh.enabled = False + + bmc = raw.get("bmc") or {} + if isinstance(bmc, dict): + for dev in bmc.get("devices") or []: + if not isinstance(dev, dict): + continue + draft.bmc_devices.append(BMCDeviceDraft( + id=str(dev.get("id") or ""), + type=str(dev.get("type") or "hp_ilo"), + host=str(dev.get("host") or ""), + user=str(dev.get("user") or ""), + password_env=_env_name(dev.get("password")), + jump_host=str(dev.get("jump_host") or ""), + )) + + return draft + + # --------------------------------------------------------------------------- # Textual app # --------------------------------------------------------------------------- @@ -892,9 +997,14 @@ class ConfigWizardApp(App[None]): Binding("ctrl+s", "quick_save", "Save"), ] - def __init__(self, yaml_path: Path, env_path: Path) -> None: + def __init__( + self, + yaml_path: Path, + env_path: Path, + draft: ConfigDraft | None = None, + ) -> None: super().__init__() - self.draft = ConfigDraft() + self.draft = draft if draft is not None else ConfigDraft() self.yaml_path = yaml_path self.env_path = env_path self._current_section = "proxmox" @@ -1013,8 +1123,19 @@ def _merge_env_placeholders(env_path: Path, names: list[str]) -> None: # --------------------------------------------------------------------------- -def run_wizard(yaml_path: Path | None = None, env_path: Path | None = None) -> int: - """Launch the wizard. Returns a process exit code.""" +def run_wizard( + yaml_path: Path | None = None, + env_path: Path | None = None, + *, + start_blank: bool = False, +) -> int: + """Launch the wizard. Returns a process exit code. + + If ``yaml_path`` already exists and ``start_blank`` is False, the file + is parsed into the draft so the user edits the existing config + in-place. ``start_blank=True`` discards whatever's on disk — the + caller is responsible for confirming this is safe. + """ if _WIZARD_IMPORT_ERROR is not None: print( "The interactive wizard needs the optional 'textual' dependency.\n" @@ -1026,5 +1147,10 @@ def run_wizard(yaml_path: Path | None = None, env_path: Path | None = None) -> i yaml_path = yaml_path or Path(os.environ.get("BEACONMCP_CONFIG", "beaconmcp.yaml")) env_path = env_path or Path(".env") - ConfigWizardApp(yaml_path=yaml_path, env_path=env_path).run() + + draft: ConfigDraft | None = None + if yaml_path.exists() and not start_blank: + draft = load_yaml_into_draft(yaml_path) + + ConfigWizardApp(yaml_path=yaml_path, env_path=env_path, draft=draft).run() return 0 From d2d728b0ae9c634aac80eadf473b0c616ef9688d Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Sat, 18 Apr 2026 19:48:25 +0200 Subject: [PATCH 090/155] wizard: cover server port/session_key/dyn-reg, dashboard, proxmox verify_ssl The wizard hit every capability (Proxmox, SSH, BMC) but stopped short on two important bits of beaconmcp.yaml: the whole features.dashboard block, and server fields beyond allowed_hosts/origins. Editing an existing config through the wizard silently dropped those sections on save. Added: - Server panel now edits host/port, session_key env, and allow_dynamic_registration (ChatGPT-style DCR toggle). - Proxmox panel exposes verify_ssl. - New Dashboard section: enabled toggle, gemini_api_key env, public_url, mcp_mode (local/remote), 5h + weekly USD limits. - Literal-secret round-tripping (token_secret_literal / password_literal) so inlined secrets aren't silently dropped by the lossy loader path. - Timestamped .bak sibling on every save to make an accidental clobber recoverable. - run_wizard now refuses to start on a parse error (instead of swallowing it and rewriting the file blank). The loader and renderer are round-trip compatible; verified end-to-end on a config that exercises every new field. --- src/beaconmcp/wizard.py | 345 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 322 insertions(+), 23 deletions(-) diff --git a/src/beaconmcp/wizard.py b/src/beaconmcp/wizard.py index e42151c..e77ecd3 100644 --- a/src/beaconmcp/wizard.py +++ b/src/beaconmcp/wizard.py @@ -18,6 +18,7 @@ import os import re +import sys from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable @@ -71,6 +72,10 @@ class PVENodeDraft: host: str = "" token_id: str = "" token_secret_env: str = "" # env var name, rendered as ${NAME} + # Raw (non-${VAR}) secret read from an existing YAML. Round-trip + # preservation for users who inlined their token — we re-emit it + # verbatim if token_secret_env is empty. + token_secret_literal: str = "" @dataclass @@ -82,6 +87,7 @@ class SSHDefaultsDraft: # user can start typing. password_env: str = "" key_file: str = "" + password_literal: str = "" @dataclass @@ -92,6 +98,7 @@ class SSHHostDraft: port: int = 22 password_env: str = "" key_file: str = "" + password_literal: str = "" @dataclass @@ -111,10 +118,13 @@ class BMCDeviceDraft: user: str = "" password_env: str = "" jump_host: str = "" # references ssh.hosts[].name + password_literal: str = "" @dataclass class ServerDraft: + host: str = "0.0.0.0" + port: int = 8420 allowed_hosts: list[str] = field(default_factory=lambda: ["127.0.0.1:*", "localhost:*", "[::1]:*"]) allowed_origins: list[str] = field( default_factory=lambda: [ @@ -124,14 +134,28 @@ class ServerDraft: "https://gemini.google.com", ] ) + session_key_env: str = "" # env var name + allow_dynamic_registration: bool = False + + +@dataclass +class DashboardDraft: + enabled: bool = True + gemini_api_key_env: str = "" # env var name + limit_5h_usd: float = 2.0 + limit_week_usd: float = 10.0 + public_url: str = "" + mcp_mode: str = "local" # "local" | "remote" @dataclass class ConfigDraft: server: ServerDraft = field(default_factory=ServerDraft) pve_nodes: list[PVENodeDraft] = field(default_factory=list) + verify_ssl: bool = False # proxmox.verify_ssl ssh: SSHDraft = field(default_factory=SSHDraft) bmc_devices: list[BMCDeviceDraft] = field(default_factory=list) + dashboard: DashboardDraft = field(default_factory=DashboardDraft) def referenced_env_vars(self) -> list[str]: """Collect every ``${VAR}`` name the draft references. @@ -140,6 +164,8 @@ def referenced_env_vars(self) -> list[str]: one file to fill in after the wizard exits. """ names: list[str] = [] + if self.server.session_key_env: + names.append(self.server.session_key_env) for n in self.pve_nodes: if n.token_secret_env: names.append(n.token_secret_env) @@ -152,6 +178,8 @@ def referenced_env_vars(self) -> list[str]: for d in self.bmc_devices: if d.password_env: names.append(d.password_env) + if self.dashboard.enabled and self.dashboard.gemini_api_key_env: + names.append(self.dashboard.gemini_api_key_env) # Dedupe while preserving order seen: set[str] = set() out: list[str] = [] @@ -188,8 +216,8 @@ def render_yaml(draft: ConfigDraft) -> str: # Server lines.append("server:") - lines.append(" host: 0.0.0.0") - lines.append(" port: 8420") + lines.append(f" host: {_q(draft.server.host)}") + lines.append(f" port: {draft.server.port}") if draft.server.allowed_hosts: lines.append(" allowed_hosts:") for h in draft.server.allowed_hosts: @@ -198,18 +226,27 @@ def render_yaml(draft: ConfigDraft) -> str: lines.append(" allowed_origins:") for o in draft.server.allowed_origins: lines.append(f" - {o}") + if draft.server.session_key_env: + lines.append(f" session_key: ${{{draft.server.session_key_env}}}") + if draft.server.allow_dynamic_registration: + lines.append(" allow_dynamic_registration: true") lines.append("") # Proxmox if draft.pve_nodes: lines.append("proxmox:") - lines.append(" verify_ssl: false") + lines.append(f" verify_ssl: {'true' if draft.verify_ssl else 'false'}") lines.append(" nodes:") for n in draft.pve_nodes: lines.append(f" - name: {_q(n.name)}") lines.append(f" host: {_q(n.host)}") lines.append(f" token_id: {_q(n.token_id)}") - secret = f"${{{n.token_secret_env}}}" if n.token_secret_env else '""' + if n.token_secret_env: + secret = f"${{{n.token_secret_env}}}" + elif n.token_secret_literal: + secret = _q(n.token_secret_literal) + else: + secret = '""' lines.append(f" token_secret: {secret}") lines.append("") @@ -232,6 +269,8 @@ def render_yaml(draft: ConfigDraft) -> str: lines.append(f" key_file: {_q(d.key_file)}") elif d.password_env: lines.append(f" password: ${{{d.password_env}}}") + elif d.password_literal: + lines.append(f" password: {_q(d.password_literal)}") if draft.ssh.inherit_proxmox_nodes: lines.append(" inherit_proxmox_nodes: true") if draft.ssh.hosts: @@ -246,6 +285,8 @@ def render_yaml(draft: ConfigDraft) -> str: lines.append(f" key_file: {_q(h.key_file)}") elif h.password_env: lines.append(f" password: ${{{h.password_env}}}") + elif h.password_literal: + lines.append(f" password: {_q(h.password_literal)}") lines.append("") # BMC @@ -257,12 +298,44 @@ def render_yaml(draft: ConfigDraft) -> str: lines.append(f" type: {b.type}") lines.append(f" host: {_q(b.host)}") lines.append(f" user: {_q(b.user)}") - secret = f"${{{b.password_env}}}" if b.password_env else '""' + if b.password_env: + secret = f"${{{b.password_env}}}" + elif b.password_literal: + secret = _q(b.password_literal) + else: + secret = '""' lines.append(f" password: {secret}") if b.jump_host: lines.append(f" jump_host: {_q(b.jump_host)}") lines.append("") + # Features (dashboard). Only emit when the user has departed from the + # defaults — keeps the generated file readable. + dash = draft.dashboard + non_default = ( + not dash.enabled + or dash.gemini_api_key_env + or dash.public_url + or dash.mcp_mode != "local" + or dash.limit_5h_usd != 2.0 + or dash.limit_week_usd != 10.0 + ) + if non_default: + lines.append("features:") + lines.append(" dashboard:") + lines.append(f" enabled: {'true' if dash.enabled else 'false'}") + if dash.gemini_api_key_env: + lines.append(f" gemini_api_key: ${{{dash.gemini_api_key_env}}}") + if dash.public_url: + lines.append(f" public_url: {_q(dash.public_url)}") + if dash.mcp_mode and dash.mcp_mode != "local": + lines.append(f" mcp_mode: {_q(dash.mcp_mode)}") + if dash.limit_5h_usd != 2.0 or dash.limit_week_usd != 10.0: + lines.append(" limits:") + lines.append(f" per_5h_usd: {dash.limit_5h_usd}") + lines.append(f" per_week_usd: {dash.limit_week_usd}") + lines.append("") + return "\n".join(lines).rstrip() + "\n" @@ -272,15 +345,29 @@ def render_yaml(draft: ConfigDraft) -> str: # --------------------------------------------------------------------------- -_ENV_REF = re.compile(r"^\$\{([A-Z_][A-Z0-9_]*)\}$") +# Accept any shell-valid identifier, case-insensitive. Earlier versions +# of the loader required uppercase and silently dropped secrets that +# didn't match — a lossy round-trip we're not repeating. +_ENV_REF = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$") -def _env_name(value: Any) -> str: - """Return the VAR name from a ``${VAR}`` scalar, or '' if it isn't one.""" +def _split_secret(value: Any) -> tuple[str, str]: + """Classify a secret scalar. + + Returns ``(env_name, literal)`` where exactly one side is populated: + - ``(NAME, "")`` when ``value`` is ``${NAME}`` + - ``("", raw)`` when ``value`` is a non-empty string that isn't a ``${VAR}`` + - ``("", "")`` when ``value`` is empty / missing / non-string + """ if not isinstance(value, str): - return "" - m = _ENV_REF.match(value.strip()) - return m.group(1) if m else "" + return "", "" + s = value.strip() + if not s: + return "", "" + m = _ENV_REF.match(s) + if m: + return m.group(1), "" + return "", s def load_yaml_into_draft(path: Path) -> ConfigDraft: @@ -297,32 +384,45 @@ def load_yaml_into_draft(path: Path) -> ConfigDraft: import yaml # lazy: keep import cost off the module load path draft = ConfigDraft() - try: - raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except (OSError, yaml.YAMLError): - return draft + # Deliberately not swallowing yaml.YAMLError / OSError here: a + # parse failure used to silently return an empty draft, which — + # combined with the wizard overwriting on save — wiped users' + # configs. run_wizard() now catches and refuses to start. + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} if not isinstance(raw, dict): return draft server = raw.get("server") or {} if isinstance(server, dict): + if server.get("host"): + draft.server.host = str(server["host"]) + if isinstance(server.get("port"), int): + draft.server.port = int(server["port"]) hosts = server.get("allowed_hosts") if isinstance(hosts, list): draft.server.allowed_hosts = [str(h) for h in hosts if h] origins = server.get("allowed_origins") if isinstance(origins, list): draft.server.allowed_origins = [str(o) for o in origins if o] + sk_env, _ = _split_secret(server.get("session_key")) + draft.server.session_key_env = sk_env + draft.server.allow_dynamic_registration = bool( + server.get("allow_dynamic_registration", False) + ) proxmox = raw.get("proxmox") or {} if isinstance(proxmox, dict): + draft.verify_ssl = bool(proxmox.get("verify_ssl", False)) for node in proxmox.get("nodes") or []: if not isinstance(node, dict): continue + env_name, literal = _split_secret(node.get("token_secret")) draft.pve_nodes.append(PVENodeDraft( name=str(node.get("name") or ""), host=str(node.get("host") or ""), token_id=str(node.get("token_id") or ""), - token_secret_env=_env_name(node.get("token_secret")), + token_secret_env=env_name, + token_secret_literal=literal, )) ssh = raw.get("ssh") @@ -336,17 +436,21 @@ def load_yaml_into_draft(path: Path) -> ConfigDraft: port = defaults.get("port") draft.ssh.defaults.port = int(port) if isinstance(port, int) else 22 draft.ssh.defaults.key_file = str(defaults.get("key_file") or "") - draft.ssh.defaults.password_env = _env_name(defaults.get("password")) + env_name, literal = _split_secret(defaults.get("password")) + draft.ssh.defaults.password_env = env_name + draft.ssh.defaults.password_literal = literal for host in ssh.get("hosts") or []: if not isinstance(host, dict): continue port = host.get("port") + env_name, literal = _split_secret(host.get("password")) draft.ssh.hosts.append(SSHHostDraft( name=str(host.get("name") or ""), host=str(host.get("host") or ""), user=str(host.get("user") or "root"), port=int(port) if isinstance(port, int) else 22, - password_env=_env_name(host.get("password")), + password_env=env_name, + password_literal=literal, key_file=str(host.get("key_file") or ""), )) else: @@ -358,15 +462,38 @@ def load_yaml_into_draft(path: Path) -> ConfigDraft: for dev in bmc.get("devices") or []: if not isinstance(dev, dict): continue + env_name, literal = _split_secret(dev.get("password")) draft.bmc_devices.append(BMCDeviceDraft( id=str(dev.get("id") or ""), type=str(dev.get("type") or "hp_ilo"), host=str(dev.get("host") or ""), user=str(dev.get("user") or ""), - password_env=_env_name(dev.get("password")), + password_env=env_name, + password_literal=literal, jump_host=str(dev.get("jump_host") or ""), )) + features = raw.get("features") or {} + if isinstance(features, dict): + dash_raw = features.get("dashboard") or {} + if isinstance(dash_raw, dict): + draft.dashboard.enabled = bool(dash_raw.get("enabled", True)) + gk_env, _ = _split_secret(dash_raw.get("gemini_api_key")) + draft.dashboard.gemini_api_key_env = gk_env + if dash_raw.get("public_url"): + draft.dashboard.public_url = str(dash_raw["public_url"]) + if dash_raw.get("mcp_mode"): + draft.dashboard.mcp_mode = str(dash_raw["mcp_mode"]).strip().lower() + limits = dash_raw.get("limits") or {} + if isinstance(limits, dict): + try: + if "per_5h_usd" in limits: + draft.dashboard.limit_5h_usd = float(limits["per_5h_usd"]) + if "per_week_usd" in limits: + draft.dashboard.limit_week_usd = float(limits["per_week_usd"]) + except (TypeError, ValueError): + pass + return draft @@ -379,6 +506,7 @@ def load_yaml_into_draft(path: Path) -> ConfigDraft: ("ssh", "SSH"), ("bmc", "BMC devices"), ("server", "Server"), + ("dashboard", "Dashboard"), ("save", "Save & exit"), ] @@ -554,6 +682,9 @@ def compose(self) -> ComposeResult: "address will be reused for SSH inheritance.", classes="hint", ) + with Horizontal(classes="form-row"): + yield Label("verify_ssl:") + yield Switch(value=self.draft.verify_ssl, id="pve-verify-ssl") yield DataTable(id="pve-table", cursor_type="row", zebra_stripes=True) with Horizontal(classes="form-actions"): yield Button("Add", id="pve-add", variant="primary") @@ -565,6 +696,11 @@ def on_mount(self) -> None: table.add_columns("name", "host", "token_id", "secret env") self._refresh_table() + def on_switch_changed(self, event: Switch.Changed) -> None: + if event.switch.id == "pve-verify-ssl": + self.draft.verify_ssl = event.value + self.on_change() + def _refresh_table(self) -> None: table = self.query_one("#pve-table", DataTable) table.clear() @@ -912,21 +1048,63 @@ def __init__(self, draft: ConfigDraft, on_change: Callable[[], None]) -> None: def compose(self) -> ComposeResult: yield Static("Server", classes="section-heading") yield Static( - "DNS-rebinding allowlist + CORS origins. One entry per line.", + "Bind address/port, DNS-rebinding allowlist + CORS origins. " + "One entry per line for the list fields.", classes="hint", ) + srv = self.draft.server + with Horizontal(classes="form-row"): + yield Label("host:") + yield Input(value=srv.host, id="srv-host", placeholder="0.0.0.0") + with Horizontal(classes="form-row"): + yield Label("port:") + yield Input(value=str(srv.port), id="srv-port", placeholder="8420") yield Static("allowed_hosts") yield TextArea( - "\n".join(self.draft.server.allowed_hosts), + "\n".join(srv.allowed_hosts), id="srv-hosts", show_line_numbers=False, ) yield Static("allowed_origins") yield TextArea( - "\n".join(self.draft.server.allowed_origins), + "\n".join(srv.allowed_origins), id="srv-origins", show_line_numbers=False, ) + yield Static("Session key env (${VAR} name) — leave empty to auto-generate") + yield Input( + value=srv.session_key_env, + id="srv-sessionkey", + placeholder="BEACONMCP_SESSION_KEY", + ) + with Horizontal(classes="form-row"): + yield Label("Dynamic reg:") + yield Switch( + value=srv.allow_dynamic_registration, id="srv-dynreg" + ) + yield Static( + "Dynamic registration lets clients without a pre-provisioned " + "client_id (notably ChatGPT) self-register via a dashboard-minted " + "slug. Off by default.", + classes="hint", + ) + + def on_input_changed(self, event: Input.Changed) -> None: + srv = self.draft.server + if event.input.id == "srv-host": + srv.host = event.value.strip() or "0.0.0.0" + elif event.input.id == "srv-port": + raw = event.value.strip() + if raw.isdigit(): + srv.port = int(raw) + elif event.input.id == "srv-sessionkey": + srv.session_key_env = event.value.strip() + self.on_change() + + def on_switch_changed(self, event: Switch.Changed) -> None: + if event.switch.id == "srv-dynreg": + self.draft.server.allow_dynamic_registration = event.value + self.on_change() def on_text_area_changed(self, event: TextArea.Changed) -> None: lines = [line.strip() for line in event.text_area.text.splitlines() if line.strip()] @@ -937,6 +1115,93 @@ def on_text_area_changed(self, event: TextArea.Changed) -> None: self.on_change() +class _DashboardPanel(Static): + def __init__(self, draft: ConfigDraft, on_change: Callable[[], None]) -> None: + super().__init__() + self.draft = draft + self.on_change = on_change + + def compose(self) -> ComposeResult: + yield Static("Dashboard", classes="section-heading") + yield Static( + "Optional web panel (/app/login, /app/chat, /app/tokens). " + "Runs an AI chat backed by Gemini; leave the key empty to " + "disable the chat while keeping the panel for token management.", + classes="hint", + ) + dash = self.draft.dashboard + with Horizontal(classes="form-row"): + yield Label("Enabled:") + yield Switch(value=dash.enabled, id="dash-enabled") + yield Static("Gemini API key env (${VAR} name)") + yield Input( + value=dash.gemini_api_key_env, + id="dash-gemini", + placeholder="GEMINI_API_KEY (leave empty to disable chat)", + ) + with Horizontal(classes="form-row"): + yield Label("Public URL:") + yield Input( + value=dash.public_url, + id="dash-url", + placeholder="https://beacon.example.com (for OAuth redirects)", + ) + with Horizontal(classes="form-row"): + yield Label("MCP mode:") + yield Input( + value=dash.mcp_mode, + id="dash-mode", + placeholder="local | remote", + ) + yield Static( + "Spending caps for Gemini chat (USD). Dashboard stops answering " + "when either threshold is hit.", + classes="hint", + ) + with Horizontal(classes="form-row"): + yield Label("5h limit $:") + yield Input( + value=str(dash.limit_5h_usd), + id="dash-5h", + placeholder="2.0", + ) + with Horizontal(classes="form-row"): + yield Label("Weekly limit $:") + yield Input( + value=str(dash.limit_week_usd), + id="dash-week", + placeholder="10.0", + ) + + def on_switch_changed(self, event: Switch.Changed) -> None: + if event.switch.id == "dash-enabled": + self.draft.dashboard.enabled = event.value + self.on_change() + + def on_input_changed(self, event: Input.Changed) -> None: + dash = self.draft.dashboard + if event.input.id == "dash-gemini": + dash.gemini_api_key_env = event.value.strip() + elif event.input.id == "dash-url": + dash.public_url = event.value.strip() + elif event.input.id == "dash-mode": + mode = event.value.strip().lower() + if mode in ("local", "remote"): + dash.mcp_mode = mode + elif not mode: + dash.mcp_mode = "local" + elif event.input.id in ("dash-5h", "dash-week"): + try: + val = float(event.value.strip()) + except ValueError: + return + if event.input.id == "dash-5h": + dash.limit_5h_usd = val + else: + dash.limit_week_usd = val + self.on_change() + + class _SavePanel(Static): def __init__( self, @@ -1065,6 +1330,8 @@ def _show_section(self, key: str) -> None: panel = _BMCPanel(self.draft, self._refresh_preview) elif key == "server": panel = _ServerPanel(self.draft, self._refresh_preview) + elif key == "dashboard": + panel = _DashboardPanel(self.draft, self._refresh_preview) elif key == "save": panel = _SavePanel( self.draft, self.yaml_path, self.env_path, self._write_files @@ -1086,10 +1353,31 @@ def action_quick_save(self) -> None: def _write_files(self, yaml_path: Path, env_path: Path) -> None: yaml_path.parent.mkdir(parents=True, exist_ok=True) + _backup_existing(yaml_path) yaml_path.write_text(render_yaml(self.draft), encoding="utf-8") _merge_env_placeholders(env_path, self.draft.referenced_env_vars()) +def _backup_existing(path: Path) -> None: + """Copy ``path`` to a timestamped sibling before overwriting. + + The wizard rewrites the YAML from the draft, which means any field + the loader didn't understand is lost on save. A timestamped backup + makes that recoverable instead of catastrophic. + """ + if not path.exists(): + return + from datetime import datetime + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup = path.with_name(f"{path.name}.bak.{stamp}") + try: + backup.write_bytes(path.read_bytes()) + except OSError: + # A failed backup shouldn't block saving, but it shouldn't + # silently succeed either — surface it via preview later. + pass + + def _merge_env_placeholders(env_path: Path, names: list[str]) -> None: """Ensure every referenced env var has a line in ``.env``. @@ -1150,7 +1438,18 @@ def run_wizard( draft: ConfigDraft | None = None if yaml_path.exists() and not start_blank: - draft = load_yaml_into_draft(yaml_path) + try: + draft = load_yaml_into_draft(yaml_path) + except Exception as exc: # noqa: BLE001 - YAML, OS, encoding, ... + print( + f"ERROR: could not parse existing {yaml_path}: {exc}\n" + f"Refusing to start the wizard because saving would " + f"overwrite the file with an empty config.\n" + f"Fix the YAML by hand, or rerun with --blank to start " + f"fresh (back up the file first).", + file=sys.stderr, + ) + return 1 ConfigWizardApp(yaml_path=yaml_path, env_path=env_path, draft=draft).run() return 0 From d5a0aada300fe932b6b40bdd69be31df14098ef2 Mon Sep 17 00:00:00 2001 From: Lony <66854264+Showdown76py@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:49:10 +0200 Subject: [PATCH 091/155] Fix formatting in architecture section of README --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0c99746..8d27a27 100644 --- a/README.md +++ b/README.md @@ -43,20 +43,20 @@ BeaconMCP exposes a Proxmox VE cluster, the hardware underneath it (HP iLO, gene ``` Clients (Claude, ChatGPT, Gemini) - │ - │ HTTPS (reverse proxy / tunnel) - ▼ + │ + │ HTTPS (reverse proxy / tunnel) + ▼ ┌──────────────────────────────────┐ -│ BeaconMCP (HTTP :8420) │ +│ BeaconMCP (HTTP :8420) │ │ ├── proxmox/ → Proxmox API │ │ ├── ssh/ → SSH :22 │ │ ├── bmc/ → iLO / IPMI │ │ └── dashboard/ → /app/* │ └──────────────────────────────────┘ - │ - │ managed cluster - ▼ - Proxmox nodes (N) · BMC devices (N) + │ + │ managed cluster + ▼ +Proxmox nodes (N) · BMC devices (N) ``` BeaconMCP runs on any host that can reach the Proxmox API of every declared node and the BMC management network. It speaks MCP over Streamable HTTP and is typically placed behind a reverse proxy with DNS-rebinding protection configured via `server.allowed_hosts` in the YAML. From b6b9a8dc668039914576d961671981cf45519adf Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Sat, 18 Apr 2026 19:58:34 +0200 Subject: [PATCH 092/155] config: clearer error when a referenced env var is set but empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously an empty ${VAR} resolved to "" silently, then _required() blamed the YAML with "missing required field 'token_secret'". That's the opposite of helpful — the YAML is fine; the .env placeholder is just unfilled (which is exactly the state the wizard leaves .env in). Now _resolve_env_refs raises early pointing at the offending env var and .env path. --- src/beaconmcp/config.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/beaconmcp/config.py b/src/beaconmcp/config.py index 5ef500c..bc2a806 100644 --- a/src/beaconmcp/config.py +++ b/src/beaconmcp/config.py @@ -683,13 +683,23 @@ def _resolve_env_refs( m = _ENV_REF.match(value) if m: env_name = m.group(1) + location = ".".join(_crumbs) or "" if env_name not in os.environ: - location = ".".join(_crumbs) or "" raise ConfigError( f"{path}: environment variable ${{{env_name}}} referenced " f"at '{location}' is not set." ) - return os.environ[env_name] + resolved = os.environ[env_name] + # Reject empty values here rather than letting _required() later + # blame the YAML ("missing required field") — the YAML is fine, + # the .env placeholder is just unfilled. + if resolved == "": + raise ConfigError( + f"{path}: environment variable ${{{env_name}}} referenced " + f"at '{location}' is set but empty. Fill in a value in " + f"your .env (or unset the variable to get a clearer error)." + ) + return resolved return value From ec49b62e2184bf1f75c34fc60f024cb0953303fa Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Sat, 18 Apr 2026 20:01:58 +0200 Subject: [PATCH 093/155] wizard: give allowed_hosts/allowed_origins a bounded height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unconstrained TextAreas in the Server panel were each defaulting to height: 1fr, so they fought for whatever VerticalScroll space remained after the other form rows — collapsing into unusable strips when the panel got taller. Pin them at height: 7 via a reusable .list-area class and label them with a .field-label for clarity. --- src/beaconmcp/wizard.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/beaconmcp/wizard.py b/src/beaconmcp/wizard.py index e77ecd3..b2275ee 100644 --- a/src/beaconmcp/wizard.py +++ b/src/beaconmcp/wizard.py @@ -572,6 +572,20 @@ def load_yaml_into_draft(path: Path) -> ConfigDraft: margin: 1 0; } +/* In-panel list editors (allowed_hosts, allowed_origins, ...). Without + a bound, TextArea defaults to 1fr and several of them in the same + VerticalScroll fight each other into unusable thin strips. */ +.list-area { + height: 7; + margin-bottom: 1; +} + +.field-label { + color: $text-muted; + text-style: bold; + margin-top: 1; +} + .form-row { layout: horizontal; height: auto; @@ -1059,19 +1073,24 @@ def compose(self) -> ComposeResult: with Horizontal(classes="form-row"): yield Label("port:") yield Input(value=str(srv.port), id="srv-port", placeholder="8420") - yield Static("allowed_hosts") + yield Static("allowed_hosts (one per line)", classes="field-label") yield TextArea( "\n".join(srv.allowed_hosts), id="srv-hosts", show_line_numbers=False, + classes="list-area", ) - yield Static("allowed_origins") + yield Static("allowed_origins (one per line)", classes="field-label") yield TextArea( "\n".join(srv.allowed_origins), id="srv-origins", show_line_numbers=False, + classes="list-area", + ) + yield Static( + "Session key env (${VAR} name) — leave empty to auto-generate", + classes="field-label", ) - yield Static("Session key env (${VAR} name) — leave empty to auto-generate") yield Input( value=srv.session_key_env, id="srv-sessionkey", From a6e01d75bf996700a80da9479f6c2d6829b6a2dc Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:04:07 +0200 Subject: [PATCH 094/155] docs: align README / docs / tests with unified ssh_run & proxmox_run Upstream already merged the unified run tools and the confirmation gate in src/beaconmcp/dashboard/chat.py (_NEEDS_CONFIRMATION + _tool_call_requires_confirmation, which exempts poll-only calls). The user-facing docs and the confirmation test still referenced the old *_exec_command* names. This sync: - README: tool table lists ssh_run / proxmox_run; Security section uses the unified names and mentions the exec_id poll exemption. - docs/dashboard.md, docs/troubleshooting.md: same alignment, plus a concrete ssh_run(exec_id=...) example for the LXC-upgrade workflow. - tests/test_dashboard_chat.py: SSE confirm-event test uses ssh_run; new coverage for _tool_call_requires_confirmation asserts: * unified names with 'command' prompt, * unified names with only 'exec_id' are read-only and skip the modal, * legacy *_exec_command* names still prompt (defense-in-depth), * unrelated tools never prompt. --- README.md | 18 +++++------- docs/dashboard.md | 4 +-- docs/troubleshooting.md | 4 +-- tests/test_dashboard_chat.py | 54 +++++++++++++++++++++++++++++------- 4 files changed, 55 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 8d27a27..ab29d6e 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ BeaconMCP exposes a Proxmox VE cluster, the hardware underneath it (HP iLO, gene - **Independent capabilities.** Enable only what you have: a full Proxmox cluster, a couple of VPS reachable by SSH, a rack with IPMI BMCs only, or any combination. The server registers tools per capability, so an SSH-only deployment never exposes `proxmox_*` tools. - **Three deployment modes out of the box:** - *Proxmox + BMC + SSH* — the reference setup (a Proxmox cluster with iLO/IPMI hardware). - - *SSH-only* — point it at a handful of VPS or bare-metal servers; get `ssh_exec_command` / `ssh_exec_command_async` tools backed by per-host credentials. + - *SSH-only* — point it at a handful of VPS or bare-metal servers; get the unified `ssh_run` tool backed by per-host credentials. - *Proxmox-only* or *BMC-only* — mix and match as your inventory grows. - **30+ MCP tools** across four modules: Proxmox (monitoring, VM lifecycle, system), SSH (per-host multi-target), BMC (hardware power/health), and security. - **N nodes, N BMC devices, N SSH hosts.** No hard-coded counts. Each SSH host carries its own credentials (password or key file) and is declared under `ssh.hosts[]`. @@ -239,11 +239,11 @@ Common keys: > **Never let an LLM execute shell commands on infrastructure you care about without reading the command first.** -BeaconMCP exposes tools that cause irreversible changes: `ssh_exec_command*`, `proxmox_exec_command*`, `bmc_power_off`, `proxmox_vm_stop`, `proxmox_vm_create`, and more. Models do not always grasp the consequences of a command — an errant `rm -rf`, a `systemctl stop` on the wrong unit, a `pct destroy` mistaken for `pct stop`. A few working rules: +BeaconMCP exposes tools that cause irreversible changes: `ssh_run`, `proxmox_run`, `bmc_power_off`, `proxmox_vm_stop`, `proxmox_vm_create`, `vm_bulk_action`, and more. Models do not always grasp the consequences of a command — an errant `rm -rf`, a `systemctl stop` on the wrong unit, a `pct destroy` mistaken for `pct stop`. A few working rules: - **Disable auto-approve** on every external MCP client (Claude Desktop, Gemini CLI, ChatGPT MCP). Keep per-call approval enabled; refuse "always allow this tool". -- **Read the `command` argument** before approving any `ssh_exec_command*` or `proxmox_exec_command*` call. Ask: if this ran against the wrong VM or host, could I recover? -- **The integrated chat** at `/app/chat` already forces human confirmation for every `ssh_exec_command*` and `proxmox_exec_command*` call. Read the arguments shown on the confirmation card even when you click through fast. No answer within 5 minutes counts as refusal. +- **Read the `command` argument** before approving any `ssh_run` or `proxmox_run` call. Ask: if this ran against the wrong VM or host, could I recover? +- **The integrated chat** at `/app/chat` already forces human confirmation for every `ssh_run` / `proxmox_run` call that carries a `command` (polling-only calls with just `exec_id` are read-only and skip the modal). Read the arguments shown on the confirmation card even when you click through fast. No answer within 5 minutes counts as refusal. - **Prefer read-only tools** (`*_list_*`, `*_status`, `*_get_*`, `get_logs`, `health_status`) for exploration — they cannot break anything and are never gated by confirmation. - **Do not share a `/app/tokens` bearer** with a client you do not fully control. A leaked token grants arbitrary shell access on your Proxmox nodes for 24 hours. @@ -282,17 +282,13 @@ BeaconMCP exposes tools that cause irreversible changes: `ssh_exec_command*`, `p |------|-------------| | `proxmox_storage_status` | Storage pool status. | | `proxmox_network_config` | Network configuration per node. | -| `proxmox_exec_command` | Command inside a VM or container (sync, via QEMU Guest Agent). | -| `proxmox_exec_command_async` | Long-running command (async). | -| `proxmox_exec_get_result` | Fetch the result of an async command. | +| `proxmox_run` | Command inside a VM or container via QEMU Guest Agent. Sync by default; pass `wait=False` to start async, or `exec_id=` to poll an existing session. | -### SSH fallback (4) +### SSH fallback (2) | Tool | Description | |------|-------------| -| `ssh_exec_command` | Command on a host (sync). `host` accepts node names, VMIDs, hostnames, or IPs. | -| `ssh_exec_command_async` | Long-running command (async). | -| `ssh_exec_get_result` | Fetch the result of an async SSH command. | +| `ssh_run` | Command on a host via SSH. `host` accepts node names, VMIDs, hostnames, or IPs. Sync by default; pass `wait=False` to start async, or `exec_id=` to poll. | | `ssh_list_sessions` | List active and recent SSH sessions. | ### BMC — hardware management (8) diff --git a/docs/dashboard.md b/docs/dashboard.md index 0d7c2b6..8c75a15 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -70,14 +70,14 @@ Constraints: ## Mandatory confirmation for shell-capable tools -`ssh_exec_command`, `ssh_exec_command_async`, `proxmox_exec_command`, and `proxmox_exec_command_async` **never** run without manual approval from the chat UI. When Gemini calls one of them: +`ssh_run` and `proxmox_run` **never** run a command without manual approval from the chat UI. A call carrying only `exec_id=` is treated as read-only polling and skips the modal; any call that carries a `command` triggers the gate. When Gemini fires a gated call: 1. The tool card switches to an "approval required" state (orange badge, auto-expanded so arguments are visible). 2. Two buttons: **Approve** / **Reject**. 3. The Gemini turn blocks server-side until the decision is made (5-minute timeout). 4. On rejection, Gemini receives a `FunctionResponse {"error": "user_rejected"}` and can revise its reply. -The allow-list is hard-coded in `src/beaconmcp/dashboard/chat.py` (`_NEEDS_CONFIRMATION`). Only the integrated chat enforces this gate; external MCP clients (Claude Desktop, Gemini CLI, ChatGPT MCP) must enable their own per-call approval mode (see the **Security** section of the root README). +The allow-list is hard-coded in `src/beaconmcp/dashboard/chat.py` (`_NEEDS_CONFIRMATION` + `_needs_confirmation`). Only the integrated chat enforces this gate; external MCP clients (Claude Desktop, Gemini CLI, ChatGPT MCP) must enable their own per-call approval mode (see the **Security** section of the root README). ## Stored data diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d89be12..2e7f113 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -20,7 +20,7 @@ | Dashboard loops between `/app/refresh` and `/app/chat` | Bearer wiped by a service restart while the session cookie is still valid | Enter the TOTP code on the refresh page to mint a new bearer. | | External MCP clients disconnected after a restart | `TokenStore` is in-memory and wiped on restart | Recreate tokens from `/app/tokens` (max 3, 24 h expiry). | | `remote_mode_disabled` error event in the chat | `BEACONMCP_DASHBOARD_MCP_MODE=remote` is set | Remove the variable; only `local` mode is supported. | -| Chat blocked on an SSH/exec card with two buttons | Mandatory confirmation for `ssh_exec_command*` / `proxmox_exec_command*` | Click **Approve** or **Reject**. The card auto-rejects after 5 minutes of inactivity. | +| Chat blocked on an SSH/exec card with two buttons | Mandatory confirmation for `ssh_run` / `proxmox_run` calls carrying a `command` | Click **Approve** or **Reject**. The card auto-rejects after 5 minutes of inactivity. Polling calls (`exec_id=` only) are read-only and skip the modal. | | `Limit reached: max 3 tokens` on the tokens page | 3 named tokens already active for this client | Revoke one before creating a new one. | | `Unknown BMC type 'X'` at startup | `bmc.devices[].type` set to an unsupported value | Valid types: `hp_ilo`, `ipmi`, `idrac` (stub), `supermicro` (stub). | @@ -32,4 +32,4 @@ > **"Upgrade packages on every container."** > -> The Proxmox API does not expose an `exec` endpoint for LXCs, so the model falls back to `proxmox_list_vms` → filters containers → `ssh_exec_command_async` on the host node with `pct exec -- sh -c 'apt update && apt upgrade -y'` per container → polls the results. Every `ssh_exec_command*` and `proxmox_exec_command*` call requires manual approval in the integrated chat; external MCP clients must enable equivalent per-call approval. +> The Proxmox API does not expose an `exec` endpoint for LXCs, so the model falls back to `proxmox_list_vms` → filters containers → `ssh_run(host=, command="pct exec -- sh -c 'apt update && apt upgrade -y'", wait=False)` per container → polls each with `ssh_run(exec_id=…)`. Every `ssh_run` / `proxmox_run` call that carries a `command` requires manual approval in the integrated chat; external MCP clients must enable equivalent per-call approval. diff --git a/tests/test_dashboard_chat.py b/tests/test_dashboard_chat.py index 1488e3f..ad40024 100644 --- a/tests/test_dashboard_chat.py +++ b/tests/test_dashboard_chat.py @@ -566,14 +566,14 @@ def test_chat_persists_history_for_second_turn(app_and_client, engine, deps): def test_chat_stream_ssh_tool_emits_confirm_event(app_and_client, engine, deps): - """ssh_exec_command triggers a tool_confirm_required SSE frame and the + """ssh_run triggers a tool_confirm_required SSE frame and the engine must wait for a decision via /app/api/chat/confirm. """ import threading, time as _t engine.script = FakeScript(events=[ - ToolCallStart(id="tc1", name="ssh_exec_command", args={"host": "pve1", "command": "ls"}), - ToolConfirmRequired(id="tc1", name="ssh_exec_command", args={"host": "pve1", "command": "ls"}), + ToolCallStart(id="tc1", name="ssh_run", args={"host": "pve1", "command": "ls"}), + ToolConfirmRequired(id="tc1", name="ssh_run", args={"host": "pve1", "command": "ls"}), ToolCallEnd(id="tc1", status="ok", preview="ok", duration_ms=50), TextDelta(text="done"), ]) @@ -850,16 +850,50 @@ def test_chat_page_renders_after_login(app_and_client): assert "gemini-3.1-pro-preview" in r.text -def test_needs_confirmation_includes_proxmox_exec(): +def test_needs_confirmation_includes_run_tools(): """Every tool that can fire arbitrary shell on a host/VM must require - human approval -- not just SSH, but also the QEMU Guest Agent exec - path (``proxmox_exec_command`` + its async twin). + human approval -- both the SSH and the QEMU Guest Agent exec paths, + now unified as ``ssh_run`` / ``proxmox_run``. Legacy ``*_exec_command*`` + names stay in the allow-list defensively in case an older MCP server + is still wired up. """ - from beaconmcp.dashboard.chat import _NEEDS_CONFIRMATION - assert "proxmox_exec_command" in _NEEDS_CONFIRMATION - assert "proxmox_exec_command_async" in _NEEDS_CONFIRMATION + from beaconmcp.dashboard.chat import ( + _NEEDS_CONFIRMATION, + _tool_call_requires_confirmation, + ) + + # Unified names: required. + assert "ssh_run" in _NEEDS_CONFIRMATION + assert "proxmox_run" in _NEEDS_CONFIRMATION + # Legacy names: still guarded. assert "ssh_exec_command" in _NEEDS_CONFIRMATION assert "ssh_exec_command_async" in _NEEDS_CONFIRMATION - # The read-only result-fetcher must NOT require a click. + assert "proxmox_exec_command" in _NEEDS_CONFIRMATION + assert "proxmox_exec_command_async" in _NEEDS_CONFIRMATION + + # Sync + async-start (command present) must confirm on unified tools. + assert _tool_call_requires_confirmation( + "ssh_run", {"host": "pve1", "command": "ls"} + ) + assert _tool_call_requires_confirmation( + "proxmox_run", {"node": "pve1", "vmid": 101, "command": "ls"} + ) + assert _tool_call_requires_confirmation( + "ssh_run", {"host": "pve1", "command": "ls", "wait": False} + ) + + # Poll-only call (exec_id, no command) is read-only: no modal. + assert not _tool_call_requires_confirmation("ssh_run", {"exec_id": "abc"}) + assert not _tool_call_requires_confirmation("proxmox_run", {"exec_id": "abc"}) + + # Legacy sync tools still prompt (no poll-exempt shortcut -- they + # always carry a ``command``). + assert _tool_call_requires_confirmation( + "ssh_exec_command", {"host": "pve1", "command": "ls"} + ) + + # Read-only result-fetchers and unrelated tools never confirm. assert "proxmox_exec_get_result" not in _NEEDS_CONFIRMATION assert "ssh_exec_get_result" not in _NEEDS_CONFIRMATION + assert not _tool_call_requires_confirmation("proxmox_list_nodes", {}) + assert not _tool_call_requires_confirmation("cluster_overview", {}) From ff4437856d7f39fcbd9d7578c255e01613ceb1c9 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:30:01 +0200 Subject: [PATCH 095/155] docs/runtime: replace remaining *_exec_command references Review follow-up on #9. The public tool names have been unified as ssh_run / proxmox_run, but a handful of runtime messages, docstrings and the design spec still referenced the pre-unification names. They were harmless (the tools no longer exist, so nothing would be called back), but misleading to operators reading tracebacks or hints. - src/beaconmcp/proxmox/client.py: 'Try ssh_exec_command ...' hint on node-unreachable -> 'Try ssh_run ...' (also fixed stale 'ilo_health_status' -> 'bmc_health_status'). - src/beaconmcp/ssh/client.py: SSH timeout error and resolve_host() docstring now recommend ssh_run(..., wait=False) + ssh_run(exec_id=...). - src/beaconmcp/bmc/tools.py: bmc_power_off / bmc_power_reset docstrings recommend ssh_run(host=..., command='shutdown -h now' / 'reboot') before forcing, matching the new unified API. - docs/dashboard.md: helper name aligned with the actual implementation (_tool_call_requires_confirmation, not _needs_confirmation). - docs/superpowers/specs/2026-04-16-beaconmcp-design.md: prepended a 'superseded by unified run tools' note and rewrote the exec tables to document proxmox_run / ssh_run with their wait/exec_id semantics. Pure text / docstring change; no behaviour difference, tests unchanged (219 passing). --- docs/dashboard.md | 2 +- .../specs/2026-04-16-beaconmcp-design.md | 24 ++++++++++++------- src/beaconmcp/bmc/tools.py | 4 ++-- src/beaconmcp/proxmox/client.py | 4 ++-- src/beaconmcp/ssh/client.py | 9 +++---- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/dashboard.md b/docs/dashboard.md index 8c75a15..9f31b33 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -77,7 +77,7 @@ Constraints: 3. The Gemini turn blocks server-side until the decision is made (5-minute timeout). 4. On rejection, Gemini receives a `FunctionResponse {"error": "user_rejected"}` and can revise its reply. -The allow-list is hard-coded in `src/beaconmcp/dashboard/chat.py` (`_NEEDS_CONFIRMATION` + `_needs_confirmation`). Only the integrated chat enforces this gate; external MCP clients (Claude Desktop, Gemini CLI, ChatGPT MCP) must enable their own per-call approval mode (see the **Security** section of the root README). +The allow-list is hard-coded in `src/beaconmcp/dashboard/chat.py` (`_NEEDS_CONFIRMATION` + `_tool_call_requires_confirmation`). Only the integrated chat enforces this gate; external MCP clients (Claude Desktop, Gemini CLI, ChatGPT MCP) must enable their own per-call approval mode (see the **Security** section of the root README). ## Stored data diff --git a/docs/superpowers/specs/2026-04-16-beaconmcp-design.md b/docs/superpowers/specs/2026-04-16-beaconmcp-design.md index 875b2f8..d71437b 100644 --- a/docs/superpowers/specs/2026-04-16-beaconmcp-design.md +++ b/docs/superpowers/specs/2026-04-16-beaconmcp-design.md @@ -1,5 +1,15 @@ # BeaconMCP -- Proxmox Infrastructure MCP Server +> **Note (post-v1):** the three-tool exec surface described below +> (`proxmox_exec_command` / `_async` / `_get_result` and its SSH twin) +> has been **superseded by the unified `proxmox_run` and `ssh_run` +> tools**. Each unified tool exposes the same three call patterns via +> parameters: sync (default), async start (`wait=False`), and poll +> (`exec_id=…`). The design intent -- auto-detect VM vs CT, in-memory +> session registry, timeout fallback to async -- is preserved; only the +> public tool names changed. See `README.md` § *Available tools* and +> `docs/dashboard.md` for the current contract. + ## Context BeaconMCP is an MCP server that gives Claude direct access to a Proxmox VE infrastructure for diagnostics, VM management, system administration, and hardware management. The motivation: when a server crashes or misbehaves, Claude should be able to diagnose the issue, check hardware health, and propose/execute resolutions -- rather than the user having to manually SSH, check logs, and relay information back and forth. @@ -72,15 +82,13 @@ src/beaconmcp/ |------|-------------|----------------| | `proxmox_storage_status` | Storage status across the cluster | `node` (optional) | | `proxmox_network_config` | Network configuration of a node | `node` | -| `proxmox_exec_command` | Execute a command inside a VM (QEMU Guest Agent) or CT (lxc exec), wait for result | `node`, `vmid`, `command`, `timeout` (default 60s) | -| `proxmox_exec_command_async` | Start a long-running command inside a VM/CT, return exec_id | `node`, `vmid`, `command` | -| `proxmox_exec_get_result` | Get result of an async command by exec_id | `exec_id` | +| `proxmox_run` | Execute a command inside a VM (QEMU Guest Agent) or CT (lxc exec). Sync by default; pass `wait=False` to start async (returns `exec_id`), or `exec_id=…` to poll an existing session. | `node`, `vmid`, `command`, `timeout` (default 60s), `wait`, `exec_id` | **Command execution design:** - The tool auto-detects whether the target is a VM (uses QEMU Guest Agent) or CT (uses Proxmox's built-in lxc exec). The caller does not need to know the difference. -- `proxmox_exec_command` blocks until the command completes or timeout is reached. Returns `{"stdout": "...", "stderr": "...", "exit_code": N}`. -- `proxmox_exec_command_async` returns immediately with `{"exec_id": "...", "status": "running"}`. Internally uses QEMU Guest Agent's native async exec for VMs (start -> PID -> poll) or background execution for CTs. -- `proxmox_exec_get_result` returns `{"exec_id": "...", "status": "running|completed|timeout", "stdout": "...", "stderr": "...", "exit_code": N}`. +- `proxmox_run` (default, `wait=True`) blocks until the command completes or timeout is reached. Returns `{"status": "ok", "stdout": "...", "stderr": "...", "exit_code": N, "duration_s": ...}`. On timeout it auto-switches to async and returns `{"status": "running", "exec_id": "..."}`. +- `proxmox_run(..., wait=False)` returns immediately with `{"status": "running", "exec_id": "..."}`. Internally uses QEMU Guest Agent's native async exec for VMs (start -> PID -> poll) or background execution for CTs. +- `proxmox_run(exec_id="...")` polls an existing session and returns `{"status": "running|ok|failed|timeout", "stdout": "...", "stderr": "...", "exit_code": N}`. - Async exec state is held in-memory in the server process. A dict of `{exec_id: {pid, node, vmid, type, status, output}}`. ### Module iLO @@ -106,9 +114,7 @@ Since iLO is only accessible from the local network, the module establishes an S | Tool | Description | Key Parameters | |------|-------------|----------------| -| `ssh_exec_command` | Execute a command on any host via SSH, wait for result | `host`, `command`, `timeout` (default 60s) | -| `ssh_exec_command_async` | Start a long-running SSH command, return exec_id | `host`, `command` | -| `ssh_exec_get_result` | Get result of an async SSH command | `exec_id` | +| `ssh_run` | Execute a command on any host via SSH. Sync by default; `wait=False` starts async and returns `exec_id`; `exec_id=…` polls an existing session. | `host`, `command`, `timeout` (default 60s), `wait`, `exec_id` | | `ssh_list_sessions` | List active async command sessions with their status | -- | SSH uses password authentication. The `host` parameter accepts: diff --git a/src/beaconmcp/bmc/tools.py b/src/beaconmcp/bmc/tools.py index 1197be1..3e8ed13 100644 --- a/src/beaconmcp/bmc/tools.py +++ b/src/beaconmcp/bmc/tools.py @@ -133,7 +133,7 @@ async def bmc_power_off( Default (force=false) sends an ACPI shutdown (clean, like pressing the power button). force=true immediately cuts power — reserve for fully unresponsive hosts. Prefer ``proxmox_vm_stop`` and - ``ssh_exec_command 'shutdown -h now'`` before forcing. + ``ssh_run(host=..., command='shutdown -h now')`` before forcing. Args: device_id: id of the target BMC. Optional when only one device @@ -151,7 +151,7 @@ async def bmc_power_reset(device_id: str | None = None) -> dict[str, Any]: Last-resort recovery when the host is completely frozen. Equivalent to pressing the physical reset button. Try ``proxmox_vm_restart`` - and ``ssh_exec_command 'reboot'`` first. + and ``ssh_run(host=..., command='reboot')`` first. Args: device_id: id of the target BMC. Optional when only one device diff --git a/src/beaconmcp/proxmox/client.py b/src/beaconmcp/proxmox/client.py index 87cdd84..03f73ea 100644 --- a/src/beaconmcp/proxmox/client.py +++ b/src/beaconmcp/proxmox/client.py @@ -49,8 +49,8 @@ def api_call(self, node_name: str, method: str, path: str, **kwargs: Any) -> Any except (ConnectionError, Timeout) as e: return { "error": f"Node '{node_name}' is unreachable: {e}. " - "Try ssh_exec_command to access the host directly, " - "or ilo_health_status if the server may be physically down." + "Try ssh_run to access the host directly, " + "or bmc_health_status if the server may be physically down." } except Exception as e: return {"error": f"Proxmox API error on '{node_name}': {e}"} diff --git a/src/beaconmcp/ssh/client.py b/src/beaconmcp/ssh/client.py index d09e0fe..2c2a4d5 100644 --- a/src/beaconmcp/ssh/client.py +++ b/src/beaconmcp/ssh/client.py @@ -174,9 +174,10 @@ def resolve(self, identifier: str) -> SSHHost: def resolve_host(self, identifier: str) -> str: """Return the connect-target address for an identifier. - Back-compat helper used by ``ssh_exec_command_async`` to surface the - resolved IP/hostname in its response. Prefer :meth:`resolve` when the - full host spec (port, user, auth) is needed. + Helper used by ``ssh_run`` (and its ``wait=False`` / ``exec_id=…`` + polling paths) to surface the resolved IP/hostname in the tool + response. Prefer :meth:`resolve` when the full host spec (port, + user, auth) is needed. """ return self.resolve(identifier).host @@ -227,7 +228,7 @@ async def exec_command(self, host: str, command: str, timeout: int = 60) -> dict "stderr": "", "exit_code": None, "status": "timeout", - "error": f"Command timed out after {timeout}s. Use ssh_exec_command_async for long-running commands.", + "error": f"Command timed out after {timeout}s. Use ssh_run(..., wait=False) to start async and poll with ssh_run(exec_id=...).", } except SSHNotConfiguredError: raise From 757ad1b6847ed1a32c7c3f1c17ccdeb7f2ab3160 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:46:22 +0200 Subject: [PATCH 096/155] batch A: mechanical fixes, logging, fields=, parallel aggregators * tests/conftest.py: opt-in gate for test_integration.py so plain `pytest` stops logging 10 collection ERRORs. Set BEACONMCP_RUN_INTEGRATION=1 to actually run the live-infra script. * print -> logging in auth.py, config.py, __main__.py; __main__.py wires root logging on import (BEACONMCP_LOG_LEVEL env, stderr). * fields= added to the last gaps: proxmox_vm_status, proxmox_get_tasks, proxmox_vm_config (read-only), bmc_server_info, bmc_health_status, bmc_get_event_log. Uniform filter_fields() usage. * proxmox client: one retry + short backoff on ConnectionError/Timeout so transient TLS/TCP blips don't surface as hard errors. Updated stale suggestion text to ssh_run / bmc_health_status. * Aggregators parallelised: cluster_overview, cluster_health, vm_bulk_action now fan out via threadpool (sync helpers) and asyncio.gather (cluster_health's per-node BMC+status+tasks). Linear in slowest node rather than sum of all nodes. * vm_bulk_action capped at 50 VMIDs per call with dedupe, to prevent accidental runaway bulk ops. Co-Authored-By: Claude Opus 4.7 --- src/beaconmcp/__main__.py | 31 ++++- src/beaconmcp/auth.py | 20 +-- src/beaconmcp/bmc/tools.py | 33 +++-- src/beaconmcp/config.py | 20 ++- src/beaconmcp/proxmox/aggregators.py | 183 +++++++++++++++++---------- src/beaconmcp/proxmox/client.py | 64 +++++++--- src/beaconmcp/proxmox/monitoring.py | 12 +- src/beaconmcp/proxmox/vms.py | 18 ++- tests/conftest.py | 29 +++++ 9 files changed, 287 insertions(+), 123 deletions(-) create mode 100644 tests/conftest.py diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 57d8e39..bacd4e0 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -1,4 +1,5 @@ import argparse +import logging import os import sys from pathlib import Path @@ -10,6 +11,27 @@ load_dotenv() +def _configure_logging() -> None: + """Wire root logging so ``logging.getLogger('beaconmcp.*')`` emits to stderr. + + Honours ``BEACONMCP_LOG_LEVEL`` (default ``INFO``). Runs once at CLI + entry before any module-level ``_logger`` call can fire. Keeps the + format compact so journalctl stays readable. + """ + if logging.getLogger().handlers: + return # already configured (e.g. pytest, embedded use) + level_name = os.environ.get("BEACONMCP_LOG_LEVEL", "INFO").upper() + level = getattr(logging, level_name, logging.INFO) + logging.basicConfig( + level=level, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + stream=sys.stderr, + ) + + +_configure_logging() + + def _apply_legacy_env_shim() -> None: """Propagate deprecated TARKAMCP_* env vars to their BEACONMCP_* counterparts. @@ -24,11 +46,10 @@ def _apply_legacy_env_shim() -> None: new_key = "BEACONMCP_" + key[len("TARKAMCP_"):] if new_key not in os.environ: os.environ[new_key] = os.environ[key] - print( - f"DeprecationWarning: TARKAMCP_* environment variables are deprecated " - f"(found: {', '.join(sorted(legacy))}). Rename to BEACONMCP_*; the " - f"legacy names will be removed in 2.1.", - file=sys.stderr, + logging.getLogger("beaconmcp").warning( + "TARKAMCP_* environment variables are deprecated (found: %s). " + "Rename to BEACONMCP_*; the legacy names will be removed in 2.1.", + ", ".join(sorted(legacy)), ) diff --git a/src/beaconmcp/auth.py b/src/beaconmcp/auth.py index da02413..5b9ed14 100644 --- a/src/beaconmcp/auth.py +++ b/src/beaconmcp/auth.py @@ -19,10 +19,12 @@ import hashlib import hmac import json +import logging import os import secrets -import sys import time + +_logger = logging.getLogger("beaconmcp.auth") from contextvars import ContextVar from dataclasses import dataclass from pathlib import Path @@ -201,10 +203,9 @@ def _load(self) -> None: try: data = json.loads(self._path.read_text()) except json.JSONDecodeError as e: - print( - f"ERROR: {self._path} is not valid JSON ({e}). " - "Fix or delete it before restarting.", - file=sys.stderr, + _logger.error( + "%s is not valid JSON (%s). Fix or delete it before restarting.", + self._path, e, ) raise @@ -234,11 +235,10 @@ def _load(self) -> None: revoked.append(f"{c.get('client_id', '?')} ({c.get('name', '?')})") if revoked: - print( - "WARNING: the following clients were revoked because they " - "predate the 2FA migration (no TOTP secret). Recreate them " - "with `beaconmcp auth create`: " + ", ".join(revoked), - file=sys.stderr, + _logger.warning( + "Revoked %d pre-2FA client(s) with no TOTP secret. " + "Recreate them with `beaconmcp auth create`: %s", + len(revoked), ", ".join(revoked), ) self._save() diff --git a/src/beaconmcp/bmc/tools.py b/src/beaconmcp/bmc/tools.py index 1197be1..6288656 100644 --- a/src/beaconmcp/bmc/tools.py +++ b/src/beaconmcp/bmc/tools.py @@ -12,6 +12,7 @@ from mcp.server.fastmcp import FastMCP +from ..utils import filter_fields from .base import BMCClient, BMCNotConfiguredError, BMCTunnelError @@ -59,34 +60,44 @@ async def bmc_list_devices() -> dict[str, Any]: } @mcp.tool() - async def bmc_server_info(device_id: str | None = None) -> dict[str, Any]: + async def bmc_server_info( + device_id: str | None = None, + fields: list[str] | None = None, + ) -> dict[str, Any]: """Get physical server information (model, serial, firmware) from a BMC. Use to identify the hardware behind a BMC and confirm firmware - levels before issuing power actions. + levels before issuing power actions. Pass ``fields=[...]`` to trim + the response -- iLO in particular returns many keys. Args: device_id: id of the target BMC. Optional when only one device is configured. Use bmc_list_devices to discover valid ids. + fields: optional allow-list of top-level keys to keep. """ try: - return await _resolve(device_id).server_info() + return filter_fields(await _resolve(device_id).server_info(), fields) except (BMCNotConfiguredError, BMCTunnelError) as exc: return {"error": str(exc)} @mcp.tool() - async def bmc_health_status(device_id: str | None = None) -> dict[str, Any]: + async def bmc_health_status( + device_id: str | None = None, + fields: list[str] | None = None, + ) -> dict[str, Any]: """Get hardware health from a BMC: temperatures, fans, power supplies, disks, memory. Most important diagnostic tool when a host becomes unresponsive — - reveals sensor-level failures not visible over the OS. + reveals sensor-level failures not visible over the OS. Pass + ``fields=[...]`` to trim the response. Args: device_id: id of the target BMC. Optional when only one device is configured. Use bmc_list_devices to discover valid ids. + fields: optional allow-list of top-level keys to keep. """ try: - return await _resolve(device_id).health() + return filter_fields(await _resolve(device_id).health(), fields) except (BMCNotConfiguredError, BMCTunnelError) as exc: return {"error": str(exc)} @@ -164,20 +175,24 @@ async def bmc_power_reset(device_id: str | None = None) -> dict[str, Any]: @mcp.tool() async def bmc_get_event_log( - device_id: str | None = None, limit: int = 50 + device_id: str | None = None, + limit: int = 50, + fields: list[str] | None = None, ) -> dict[str, Any]: """Fetch a BMC event log: hardware errors, reboots, power events, PSU/fan faults. Essential for post-mortem analysis of host crashes. Returns the most - recent events (default 50, capped at 200). + recent events (default 50, capped at 200). Pass ``fields=[...]`` to + trim top-level keys of the response. Args: device_id: id of the target BMC. Optional when only one device is configured. Use bmc_list_devices to discover valid ids. limit: maximum number of events to return (1–200). + fields: optional allow-list of top-level keys to keep. """ limit = max(1, min(int(limit), 200)) try: - return await _resolve(device_id).event_log(limit) + return filter_fields(await _resolve(device_id).event_log(limit), fields) except (BMCNotConfiguredError, BMCTunnelError) as exc: return {"error": str(exc)} diff --git a/src/beaconmcp/config.py b/src/beaconmcp/config.py index bc2a806..657fdb3 100644 --- a/src/beaconmcp/config.py +++ b/src/beaconmcp/config.py @@ -14,6 +14,7 @@ from __future__ import annotations +import logging import os import re import sys @@ -24,6 +25,8 @@ import yaml +_logger = logging.getLogger("beaconmcp.config") + # --- Dataclasses ----------------------------------------------------------- @@ -168,10 +171,9 @@ def load(cls, config_path: Path | None = None) -> Config: stacklevel=2, ) return cls._from_legacy_env() - print( - "ERROR: No configuration file found. Create beaconmcp.yaml in the " - "working directory or set BEACONMCP_CONFIG. See beaconmcp.yaml.example.", - file=sys.stderr, + _logger.error( + "No configuration file found. Create beaconmcp.yaml in the " + "working directory or set BEACONMCP_CONFIG. See beaconmcp.yaml.example." ) sys.exit(1) @@ -188,10 +190,7 @@ def from_env(cls) -> Config: def _resolve_config_path(override: Path | None) -> Path | None: if override is not None: if not override.exists(): - print( - f"ERROR: Config file {override} does not exist.", - file=sys.stderr, - ) + _logger.error("Config file %s does not exist.", override) sys.exit(1) return override env_path = os.environ.get("BEACONMCP_CONFIG", "").strip() @@ -199,9 +198,8 @@ def _resolve_config_path(override: Path | None) -> Path | None: p = Path(env_path) if p.exists(): return p - print( - f"ERROR: BEACONMCP_CONFIG={env_path} points at a missing file.", - file=sys.stderr, + _logger.error( + "BEACONMCP_CONFIG=%s points at a missing file.", env_path ) sys.exit(1) for candidate in (Path("beaconmcp.yaml"), Path("/etc/beaconmcp/config.yaml")): diff --git a/src/beaconmcp/proxmox/aggregators.py b/src/beaconmcp/proxmox/aggregators.py index 6e37648..4a2c1e9 100644 --- a/src/beaconmcp/proxmox/aggregators.py +++ b/src/beaconmcp/proxmox/aggregators.py @@ -20,7 +20,9 @@ from __future__ import annotations +import asyncio import fnmatch +from concurrent.futures import ThreadPoolExecutor from typing import Any from mcp.server.fastmcp import FastMCP @@ -34,36 +36,54 @@ # Internal helpers # --------------------------------------------------------------------------- +# Cap on concurrent Proxmox/BMC fan-outs. Small homelab clusters (3-10 nodes) +# fit comfortably; bigger clusters still benefit from parallelism without +# hammering the API with hundreds of simultaneous TLS handshakes. +_MAX_PARALLEL = 8 + + +def _parallel_map(fn: Any, items: list[Any]) -> list[Any]: + """Run ``fn(item)`` for each item in parallel threads, preserving order. + + Keeps aggregator fan-out roughly linear in the slowest node rather than + serial sum-of-all-nodes. Errors bubble back as the function's normal + return shape (each helper already returns error dicts inline), so we + don't catch here. + """ + if not items: + return [] + if len(items) == 1: + return [fn(items[0])] + with ThreadPoolExecutor(max_workers=min(_MAX_PARALLEL, len(items))) as ex: + return list(ex.map(fn, items)) + + def _collect_node_summaries(client: ProxmoxClient) -> list[dict[str, Any]]: """One row per configured node with key health metrics. Mirrors ``proxmox_list_nodes`` but keeps only the fields cluster_overview actually needs to stay token-efficient. """ - out: list[dict[str, Any]] = [] - for node_name in client.configured_nodes: + def _one(node_name: str) -> dict[str, Any]: data = client.get(node_name, "nodes") if isinstance(data, dict) and "error" in data: - out.append({"name": node_name, "status": "unreachable", "error": data["error"]}) - continue + return {"name": node_name, "status": "unreachable", "error": data["error"]} if not isinstance(data, list): - out.append({"name": node_name, "status": "unknown"}) - continue + return {"name": node_name, "status": "unknown"} for node in data: if node.get("node") != node_name: continue - out.append({ + return { "name": node_name, "status": node.get("status", "unknown"), "cpu": round(node.get("cpu", 0) * 100, 1), "mem_used_gb": round(node.get("mem", 0) / 1073741824, 1), "mem_total_gb": round(node.get("maxmem", 0) / 1073741824, 1), "uptime_h": round(node.get("uptime", 0) / 3600, 1), - }) - break - else: - out.append({"name": node_name, "status": "unknown"}) - return out + } + return {"name": node_name, "status": "unknown"} + + return _parallel_map(_one, list(client.configured_nodes)) def _collect_vm_summaries( @@ -75,58 +95,66 @@ def _collect_vm_summaries( helper want to filter/count across the whole set; the per-node nesting shape is already available via ``proxmox_list_vms``. """ - nodes = target_nodes or client.configured_nodes + nodes = list(target_nodes or client.configured_nodes) + + # Each node needs a qemu + lxc fetch. Flatten to (node, vm_type) tuples + # so the whole fan-out runs in parallel instead of 2 * N serial calls. + tasks = [(n, t) for n in nodes for t in ("qemu", "lxc")] + + def _one(task: tuple[str, str]) -> list[dict[str, Any]]: + n, vm_type = task + data = client.get(n, f"nodes/{n}/{vm_type}") + if not isinstance(data, list): + return [] + return [{ + "node": n, + "vmid": vm.get("vmid"), + "name": vm.get("name", ""), + "status": vm.get("status"), + "type": vm_type, + "cpu_pct": round(vm.get("cpu", 0) * 100, 1), + "mem_used_mb": round(vm.get("mem", 0) / 1048576, 0), + } for vm in data] + rows: list[dict[str, Any]] = [] - total = 0 - for n in nodes: - for vm_type in ("qemu", "lxc"): - data = client.get(n, f"nodes/{n}/{vm_type}") - if isinstance(data, dict) and "error" in data: - continue - if not isinstance(data, list): - continue - for vm in data: - rows.append({ - "node": n, - "vmid": vm.get("vmid"), - "name": vm.get("name", ""), - "status": vm.get("status"), - "type": vm_type, - "cpu_pct": round(vm.get("cpu", 0) * 100, 1), - "mem_used_mb": round(vm.get("mem", 0) / 1048576, 0), - }) - total += 1 + for chunk in _parallel_map(_one, tasks): + rows.extend(chunk) rows.sort(key=lambda v: (v.get("node", ""), v.get("vmid", 0))) - return rows, total + return rows, len(rows) def _collect_storage_summaries(client: ProxmoxClient) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for n in client.configured_nodes: + def _per_node(n: str) -> list[dict[str, Any]]: data = client.get(n, f"nodes/{n}/storage") if isinstance(data, dict) and "error" in data: - rows.append({"node": n, "error": data["error"]}) - continue + return [{"node": n, "error": data["error"]}] if not isinstance(data, list): - continue - for s in data: - name = s.get("storage") - if not name: - continue + return [] + # Fan out the per-pool status queries within a node too -- 4+ pools + # per node is common (local, zfs, nfs, cephfs). + pools = [s for s in data if s.get("storage")] + + def _pool(s: dict[str, Any]) -> dict[str, Any]: + name = s["storage"] status = client.get(n, f"nodes/{n}/storage/{name}/status") - used = 0 - total = 0 + used = total = 0 if isinstance(status, dict) and "error" not in status: used = status.get("used", 0) total = status.get("total", 0) - rows.append({ + return { "node": n, "name": name, "type": s.get("type"), "used_gb": round(used / 1073741824, 1), "total_gb": round(total / 1073741824, 1), "usage_pct": round(used / total * 100, 1) if total > 0 else 0, - }) + } + + return _parallel_map(_pool, pools) + + rows: list[dict[str, Any]] = [] + for chunk in _parallel_map(_per_node, list(client.configured_nodes)): + rows.extend(chunk) return rows @@ -252,12 +280,22 @@ async def cluster_health(node: str = "") -> dict[str, Any]: have a BMC device declared with ``jump_host: ``. """ target_nodes = [node] if node else list(proxmox_client.configured_nodes) - results: list[dict[str, Any]] = [] - for n in target_nodes: - status = proxmox_client.get(n, f"nodes/{n}/status") + + async def _one(n: str) -> dict[str, Any]: + # Offload the blocking Proxmox calls to a thread so we can run the + # BMC await + Proxmox fetch in parallel per node, and every node in + # parallel overall via gather. + loop = asyncio.get_running_loop() + status_task = loop.run_in_executor( + None, lambda: proxmox_client.get(n, f"nodes/{n}/status"), + ) + errors_task = loop.run_in_executor( + None, lambda: _recent_errors(proxmox_client, n, limit=20), + ) + bmc_task = _bmc_summary(bmc_registry, config, n) + status, errors, bmc = await asyncio.gather(status_task, errors_task, bmc_task) if isinstance(status, dict) and "error" in status: - results.append({"node": n, "error": status["error"]}) - continue + return {"node": n, "error": status["error"]} entry: dict[str, Any] = { "node": n, "cpu_pct": round(status.get("cpu", 0) * 100, 1), @@ -267,11 +305,12 @@ async def cluster_health(node: str = "") -> dict[str, Any]: "kernel": status.get("kversion"), "pve_version": status.get("pveversion"), } - bmc = await _bmc_summary(bmc_registry, config, n) if bmc is not None: entry["bmc"] = bmc - entry["recent_errors"] = _recent_errors(proxmox_client, n, limit=20) - results.append(entry) + entry["recent_errors"] = errors + return entry + + results = list(await asyncio.gather(*[_one(n) for n in target_nodes])) if node: return results[0] if results else {"error": f"Node {node!r} not configured."} return {"nodes": results} @@ -308,18 +347,31 @@ def vm_bulk_action( Locates each VMID across the cluster, fires the action, and collects per-VM UPIDs (or errors) in one response. ``force`` applies to stop - and restart actions. + and restart actions. Capped at 50 VMs per call to prevent runaway + fan-out; split larger lists client-side. """ valid_actions = {"start", "stop", "restart"} if action not in valid_actions: return {"error": f"Unsupported action {action!r}. Use one of {sorted(valid_actions)}."} - results: list[dict[str, Any]] = [] - for vmid in vmids: + # Hard cap. A typo like `vm_bulk_action(range(1, 10000), "stop")` should + # fail loud, not take down a cluster. 50 covers legit bulk ops on any + # homelab-scale setup. + _MAX_BULK = 50 + if len(vmids) > _MAX_BULK: + return { + "error": f"Too many VMIDs ({len(vmids)}); cap is {_MAX_BULK} per call. " + "Split into multiple calls.", + } + # Dedupe while preserving order -- repeated VMIDs are almost always a + # caller bug and doing the same stop/start twice is never what they want. + seen: set[int] = set() + unique_vmids = [v for v in vmids if not (v in seen or seen.add(v))] + + def _one(vmid: int) -> dict[str, Any]: location = _find_vm_location(proxmox_client, vmid) if not location: - results.append({"vmid": vmid, "error": "not found"}) - continue + return {"vmid": vmid, "error": "not found"} n, vm_type = location endpoint = f"nodes/{n}/{vm_type}/{vmid}/status/{action}" params: dict[str, Any] = {} @@ -327,11 +379,12 @@ def vm_bulk_action( params["forceStop"] = 1 resp = proxmox_client.post(n, endpoint, **params) if isinstance(resp, dict) and "error" in resp: - results.append({"vmid": vmid, "node": n, "error": resp["error"]}) - else: - upid = resp if isinstance(resp, str) else ( - resp.get("upid") if isinstance(resp, dict) else None - ) - results.append({"vmid": vmid, "node": n, "type": vm_type, "upid": upid}) + return {"vmid": vmid, "node": n, "error": resp["error"]} + upid = resp if isinstance(resp, str) else ( + resp.get("upid") if isinstance(resp, dict) else None + ) + return {"vmid": vmid, "node": n, "type": vm_type, "upid": upid} + + results = _parallel_map(_one, unique_vmids) ok = sum(1 for r in results if "upid" in r) return {"action": action, "total": len(results), "ok": ok, "results": results} diff --git a/src/beaconmcp/proxmox/client.py b/src/beaconmcp/proxmox/client.py index 87cdd84..5d5efb0 100644 --- a/src/beaconmcp/proxmox/client.py +++ b/src/beaconmcp/proxmox/client.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging +import time from typing import Any from proxmoxer import ProxmoxAPI @@ -7,6 +9,17 @@ from ..config import Config +_logger = logging.getLogger("beaconmcp.proxmox") + +# Transient-error retry: Proxmox API over the wire frequently hiccups on +# momentary network blips (TCP reset during cluster sync, TLS renegotiation +# behind a reverse proxy, etc). One quick retry with a short backoff covers +# the overwhelming majority without turning sustained outages into slow +# failures. Keep the numbers small and obvious -- callers already get a +# descriptive error dict back if retries don't help. +_RETRY_ATTEMPTS = 2 +_RETRY_BACKOFF_SECONDS = 0.5 + class ProxmoxClient: """Manages connections to one or more Proxmox VE nodes via API tokens.""" @@ -36,24 +49,41 @@ def _get_connection(self, node_name: str) -> ProxmoxAPI: def api_call(self, node_name: str, method: str, path: str, **kwargs: Any) -> Any: """Execute an API call against a Proxmox node. - Returns the result or a dict with 'error' key on failure. + Returns the result or a dict with 'error' key on failure. Transient + network errors get one quick retry; sustained unreachability returns + the descriptive error message. """ - try: - conn = self._get_connection(node_name) - obj = conn - for part in path.strip("/").split("/"): - obj = getattr(obj, part) - return getattr(obj, method)(**kwargs) - except NodeNotFoundError: - raise - except (ConnectionError, Timeout) as e: - return { - "error": f"Node '{node_name}' is unreachable: {e}. " - "Try ssh_exec_command to access the host directly, " - "or ilo_health_status if the server may be physically down." - } - except Exception as e: - return {"error": f"Proxmox API error on '{node_name}': {e}"} + last_exc: Exception | None = None + for attempt in range(_RETRY_ATTEMPTS): + try: + conn = self._get_connection(node_name) + obj = conn + for part in path.strip("/").split("/"): + obj = getattr(obj, part) + return getattr(obj, method)(**kwargs) + except NodeNotFoundError: + raise + except (ConnectionError, Timeout) as e: + last_exc = e + # Drop the cached connection so the retry rebuilds TLS state + # rather than re-using a half-broken socket. + self._connections.pop(node_name, None) + if attempt + 1 < _RETRY_ATTEMPTS: + _logger.warning( + "transient error on %s %s (attempt %d/%d): %s", + node_name, path, attempt + 1, _RETRY_ATTEMPTS, e, + ) + time.sleep(_RETRY_BACKOFF_SECONDS) + continue + return { + "error": f"Node '{node_name}' is unreachable: {e}. " + "Try ssh_run to access the host directly, " + "or bmc_health_status if the server may be physically down." + } + except Exception as e: + return {"error": f"Proxmox API error on '{node_name}': {e}"} + # Defensive fallback -- loop should always return above. + return {"error": f"Node '{node_name}' is unreachable: {last_exc}"} def get(self, node_name: str, path: str, **kwargs: Any) -> Any: return self.api_call(node_name, "get", path, **kwargs) diff --git a/src/beaconmcp/proxmox/monitoring.py b/src/beaconmcp/proxmox/monitoring.py index 6983108..fe0bc82 100644 --- a/src/beaconmcp/proxmox/monitoring.py +++ b/src/beaconmcp/proxmox/monitoring.py @@ -130,12 +130,14 @@ def proxmox_list_vms(node: str = "", fields: list[str] | None = None) -> dict[st return {"vms": by_node, "total": total} @mcp.tool() - def proxmox_vm_status(node: str, vmid: int) -> dict[str, Any]: + def proxmox_vm_status(node: str, vmid: int, fields: list[str] | None = None) -> dict[str, Any]: """Get detailed status of a specific VM or container: CPU, RAM, disk I/O, network I/O, uptime. Use after proxmox_list_vms to drill into a specific VM. Provide both the node name and VMID. Auto-detects whether the target is a QEMU VM or LXC container. + Pass ``fields=[...]`` to trim the response to only the keys you need + (e.g. ``["name", "status", "cpu_pct"]``). Returns: {node, vmid, type, name, status, cpu_pct, cpus, mem_used_mb, mem_max_mb, disk_read_mb, disk_write_mb, net_in_mb, net_out_mb, uptime_h, pid, config_summary: {cores, mem_mb, description}}. @@ -173,7 +175,7 @@ def proxmox_vm_status(node: str, vmid: int) -> dict[str, Any]: "mem_mb": config_data.get("memory"), "description": config_data.get("description", ""), } - return result + return filter_fields(result, fields) return {"error": f"VM/CT {vmid} not found on node '{node}'. Check the VMID and node name."} @@ -222,11 +224,13 @@ def proxmox_get_logs(node: str, source: str = "syslog", limit: int = 50) -> dict return {"node": node, "source": "syslog", "lines": [], "raw": str(data)} @mcp.tool() - def proxmox_get_tasks(node: str = "", limit: int = 20) -> dict[str, Any]: + def proxmox_get_tasks(node: str = "", limit: int = 20, fields: list[str] | None = None) -> dict[str, Any]: """List recent Proxmox tasks across the cluster: migrations, backups, VM operations. Use to check what operations have been running or to investigate failed tasks. Omit 'node' to list tasks from all configured nodes. + Pass ``fields=[...]`` to trim each entry to only the keys you need + (e.g. ``["upid", "status"]``). Returns: {"tasks": {"": [{upid, type, status, user, starttime, endtime}]}, "total": N}. Per-node errors appear as {"error": "..."} entries in that node's list. @@ -250,7 +254,7 @@ def proxmox_get_tasks(node: str = "", limit: int = 20) -> dict[str, Any]: "starttime": t.get("starttime"), "endtime": t.get("endtime"), }) - by_node[n] = entries + by_node[n] = filter_fields(entries, fields) total += sum(1 for e in entries if "upid" in e) return {"tasks": by_node, "total": total} diff --git a/src/beaconmcp/proxmox/vms.py b/src/beaconmcp/proxmox/vms.py index 29ef311..69d12d4 100644 --- a/src/beaconmcp/proxmox/vms.py +++ b/src/beaconmcp/proxmox/vms.py @@ -4,6 +4,7 @@ from mcp.server.fastmcp import FastMCP +from ..utils import filter_fields from .client import ProxmoxClient @@ -144,12 +145,20 @@ def proxmox_vm_migrate(node: str, vmid: int, target_node: str) -> dict[str, Any] } @mcp.tool() - def proxmox_vm_config(node: str, vmid: int, updates: dict[str, Any] | None = None) -> dict[str, Any]: + def proxmox_vm_config( + node: str, + vmid: int, + updates: dict[str, Any] | None = None, + fields: list[str] | None = None, + ) -> dict[str, Any]: """Read or modify the configuration of a VM or container. Without 'updates': returns the full current configuration. With 'updates': applies the provided config changes (e.g., {"memory": 4096, "cores": 4}). Use to inspect or change VM settings like memory, CPU cores, network, disks, etc. + Pass ``fields=[...]`` (read-only mode) to trim the returned ``config`` + blob -- helpful because full VM configs can be large (dozens of + disk/net/hostpci keys). Ignored when ``updates`` is given. """ vm_type = _detect_vm_type(client, node, vmid) if not vm_type: @@ -159,7 +168,12 @@ def proxmox_vm_config(node: str, vmid: int, updates: dict[str, Any] | None = Non data = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/config") if isinstance(data, dict) and "error" in data: return data - return {"vmid": vmid, "node": node, "type": vm_type, "config": data} + return { + "vmid": vmid, + "node": node, + "type": vm_type, + "config": filter_fields(data, fields), + } result = client.put(node, f"nodes/{node}/{vm_type}/{vmid}/config", **updates) if isinstance(result, dict) and "error" in result: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..646b098 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +"""Shared pytest configuration. + +``test_integration.py`` is a standalone script with its own ``TestRunner`` +and is designed to be executed as ``python tests/test_integration.py`` +against live infrastructure. Its ``test_*`` functions take a ``runner`` +and ``tools`` positional argument, which pytest tries (and fails) to +resolve as fixtures -- producing a pile of ERRORs on every ``pytest`` +run even when nothing destructive would have happened. + +We tell pytest to skip that file at collection time unless the opt-in +environment variable ``BEACONMCP_RUN_INTEGRATION=1`` is set. The script +path stays runnable as a plain Python program. +""" + +from __future__ import annotations + +import os + + +def _run_integration_enabled() -> bool: + return os.environ.get("BEACONMCP_RUN_INTEGRATION", "").strip().lower() in ( + "1", "true", "yes", "on", + ) + + +# Collected by pytest; a relative path listed here is skipped entirely. +collect_ignore: list[str] = [] +if not _run_integration_enabled(): + collect_ignore.append("test_integration.py") From 031f71d155748a80f6a30db4a8645e6ebe336254 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:54:51 +0200 Subject: [PATCH 097/155] batch C: structured audit log + Prometheus /metrics endpoint * New `beaconmcp.audit` module -- one JSON line per event, fire-and-forget. Auto-redacts keys matching password/secret/token/client_secret/totp/bearer at any nesting depth. Emits to the `beaconmcp.audit` logger so operators can route with a standard logging filter (dedicated file, Loki, etc.). Wired into `/oauth/token`: `auth.token.fail` on bad creds, `auth.token.issue` on mint. * New `beaconmcp.metrics` -- tiny Prometheus text-format registry with no `prometheus_client` dep. Counter + Histogram (cumulative buckets, `le="+Inf"` total, `_sum` and `_count` series). Thread-safe. Standard metrics registered at import: - beaconmcp_tool_calls_total{tool,status} - beaconmcp_tool_latency_ms{tool} (bucketed 5ms .. 30s) - beaconmcp_auth_events_total{kind,outcome} - beaconmcp_http_requests_total{path,status} * `/metrics` Starlette route -- unauthenticated (values expose no secrets; access control belongs to the reverse proxy / network ACL). Whitelisted in the auth middleware alongside `/health`. * 10 new unit tests (metrics + audit) incl. redaction + emit-never-raises. * conftest.py duplicated so PR C runs pytest cleanly standalone. Co-Authored-By: Claude Opus 4.7 --- src/beaconmcp/__main__.py | 18 +++++ src/beaconmcp/audit.py | 68 ++++++++++++++++++ src/beaconmcp/metrics.py | 146 ++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 29 ++++++++ tests/test_audit.py | 41 +++++++++++ tests/test_metrics.py | 50 +++++++++++++ 6 files changed, 352 insertions(+) create mode 100644 src/beaconmcp/audit.py create mode 100644 src/beaconmcp/metrics.py create mode 100644 tests/conftest.py create mode 100644 tests/test_audit.py create mode 100644 tests/test_metrics.py diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 57d8e39..d350667 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -223,8 +223,10 @@ def _run_http(mcp, host: str, port: int): from urllib.parse import urlencode, urlparse + from . import audit from . import auth from .auth import ClientStore, CodeStore, TokenStore, current_bearer_token + from .metrics import REGISTRY, auth_events, http_requests from .server import config env_cf = os.environ.get("BEACONMCP_CLIENTS_FILE") @@ -690,6 +692,8 @@ async def oauth_token(request: Request) -> Response: client_secret = body.get("client_secret", "") if not client_store.verify(client_id, client_secret): + auth_events.inc(kind="token", outcome="invalid_client") + audit.emit("auth.token.fail", client_id=client_id, reason="invalid_client") return JSONResponse({"error": "invalid_client"}, status_code=401) if grant_type == "client_credentials": @@ -716,6 +720,8 @@ async def oauth_token(request: Request) -> Response: ) totp_record_success(client_id) token, expires_in = token_store.issue(client_id) + auth_events.inc(kind="token", outcome="ok") + audit.emit("auth.token.issue", client_id=client_id, grant_type=grant_type) return JSONResponse({ "access_token": token, "token_type": "bearer", @@ -751,11 +757,22 @@ async def oauth_register(_request: Request) -> Response: async def health(_request: Request) -> Response: return JSONResponse({"status": "ok", "server": "beaconmcp"}) + async def metrics(_request: Request) -> Response: + # Prometheus text exposition format. Unauthenticated by design -- + # scrape access is usually controlled via network ACL / reverse + # proxy rather than a bearer. No labels leak secrets; all values + # are counters/histograms. If you need auth, front with nginx. + return Response( + REGISTRY.render(), + media_type="text/plain; version=0.0.4; charset=utf-8", + ) + async def auth_middleware(request: Request, call_next): path = request.url.path if path in ( "/", "/health", + "/metrics", "/oauth/token", "/oauth/authorize", "/oauth/register", @@ -1013,6 +1030,7 @@ async def lifespan(_app): app = Starlette( routes=[ Route("/health", health), + Route("/metrics", metrics), Route("/.well-known/oauth-authorization-server", oauth_metadata), Route("/.well-known/oauth-protected-resource", protected_resource_metadata), Route("/.well-known/oauth-protected-resource/mcp", protected_resource_metadata), diff --git a/src/beaconmcp/audit.py b/src/beaconmcp/audit.py new file mode 100644 index 0000000..310768c --- /dev/null +++ b/src/beaconmcp/audit.py @@ -0,0 +1,68 @@ +"""Structured audit log for BeaconMCP. + +Every auth event (login success/failure, token mint, client revoke) and +every MCP tool invocation can be fed through :func:`emit` to produce a +single line of JSON on the audit sink. The sink defaults to the +``beaconmcp.audit`` logger (which inherits the root config wired in +``__main__._configure_logging``) so operators can point it at a +dedicated file with a standard ``logging`` filter, without the rest of +the code caring how bytes land on disk. + +Design: + +* One line of JSON per event. Keys are stable so the file can be + grep'd / shipped to Loki / ingested into Elasticsearch without + schema maintenance on our side. +* Event timestamps use UTC ISO-8601 with microseconds. +* Secrets are *never* in the payload -- callers pass ``client_id`` and + high-level tool args, and ``_redact`` masks anything that looks like + a secret (keys matching ``password``, ``secret``, ``token``, ...). +* Fire-and-forget: emitting never raises. If the underlying logger + explodes the caller keeps going. +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from typing import Any + +_logger = logging.getLogger("beaconmcp.audit") + +# Argument keys whose values are always masked before emission. +_REDACT_KEYS = frozenset({ + "password", "secret", "token", "token_secret", "client_secret", + "api_key", "authorization", "totp", "bearer", +}) + + +def _redact(value: Any) -> Any: + """Walk ``value`` replacing obviously-sensitive leaf values with ``***``.""" + if isinstance(value, dict): + return { + k: ("***" if k.lower() in _REDACT_KEYS else _redact(v)) + for k, v in value.items() + } + if isinstance(value, list): + return [_redact(v) for v in value] + return value + + +def emit(event: str, **fields: Any) -> None: + """Write one audit event as a JSON line. + + ``event`` is a short dotted identifier (``tool.call``, ``auth.login``, + ``auth.token.issue``, ...). Any number of additional keyword fields + can be attached; they're redacted and merged into the JSON record. + """ + record = { + "ts": datetime.now(timezone.utc).isoformat(timespec="microseconds"), + "event": event, + } + for k, v in fields.items(): + record[k] = _redact(v) + try: + _logger.info(json.dumps(record, default=str, ensure_ascii=False)) + except Exception: # noqa: BLE001 -- audit must never break a request + pass diff --git a/src/beaconmcp/metrics.py b/src/beaconmcp/metrics.py new file mode 100644 index 0000000..ec313b0 --- /dev/null +++ b/src/beaconmcp/metrics.py @@ -0,0 +1,146 @@ +"""Minimal Prometheus text-format metrics for BeaconMCP. + +Deliberately avoids ``prometheus_client`` to keep the dependency tree +small. Two primitive counter types cover everything we need right now: + +* :class:`Counter` -- monotonic integer counter, optionally labelled. +* :class:`Histogram` -- fixed-bucket histogram over milliseconds. + +The :class:`Registry` collects them and renders the Prometheus text +exposition format on demand. Thread-safe via a single registry lock. + +Usage:: + + from beaconmcp.metrics import REGISTRY, tool_calls, tool_latency_ms + + tool_calls.inc(tool="proxmox_run", status="ok") + tool_latency_ms.observe(123.4, tool="proxmox_run") + + text = REGISTRY.render() # served at /metrics +""" + +from __future__ import annotations + +import threading +import time +from contextlib import contextmanager +from typing import Iterator + + +def _labels_key(labels: dict[str, str]) -> tuple[tuple[str, str], ...]: + return tuple(sorted(labels.items())) + + +def _format_labels(labels: tuple[tuple[str, str], ...]) -> str: + if not labels: + return "" + parts = [f'{k}="{str(v).replace(chr(92), chr(92) + chr(92)).replace(chr(34), chr(92) + chr(34))}"' for k, v in labels] + return "{" + ",".join(parts) + "}" + + +class Counter: + def __init__(self, name: str, help_text: str) -> None: + self.name = name + self.help = help_text + self._values: dict[tuple[tuple[str, str], ...], int] = {} + self._lock = threading.Lock() + + def inc(self, amount: int = 1, **labels: str) -> None: + key = _labels_key(labels) + with self._lock: + self._values[key] = self._values.get(key, 0) + amount + + def render(self) -> str: + lines = [f"# HELP {self.name} {self.help}", f"# TYPE {self.name} counter"] + with self._lock: + snapshot = dict(self._values) + for key, value in snapshot.items(): + lines.append(f"{self.name}{_format_labels(key)} {value}") + return "\n".join(lines) + + +class Histogram: + """Fixed-bucket histogram. Buckets are upper bounds in milliseconds.""" + + # Covers <10ms cached calls all the way to 30-second BMC round trips. + DEFAULT_BUCKETS_MS: tuple[float, ...] = ( + 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, + ) + + def __init__(self, name: str, help_text: str, buckets_ms: tuple[float, ...] | None = None) -> None: + self.name = name + self.help = help_text + self._buckets: tuple[float, ...] = tuple(buckets_ms or self.DEFAULT_BUCKETS_MS) + # Per-label bucket counts + running sum/count. + self._counts: dict[tuple[tuple[str, str], ...], list[int]] = {} + self._sum: dict[tuple[tuple[str, str], ...], float] = {} + self._total: dict[tuple[tuple[str, str], ...], int] = {} + self._lock = threading.Lock() + + def observe(self, value_ms: float, **labels: str) -> None: + key = _labels_key(labels) + with self._lock: + counts = self._counts.setdefault(key, [0] * len(self._buckets)) + for i, upper in enumerate(self._buckets): + if value_ms <= upper: + counts[i] += 1 + self._sum[key] = self._sum.get(key, 0.0) + value_ms + self._total[key] = self._total.get(key, 0) + 1 + + @contextmanager + def time(self, **labels: str) -> Iterator[None]: + start = time.monotonic() + try: + yield + finally: + self.observe((time.monotonic() - start) * 1000.0, **labels) + + def render(self) -> str: + lines = [f"# HELP {self.name} {self.help}", f"# TYPE {self.name} histogram"] + with self._lock: + counts = {k: list(v) for k, v in self._counts.items()} + sums = dict(self._sum) + totals = dict(self._total) + for key, bucket_counts in counts.items(): + # ``observe`` already increments every bucket whose upper bound + # is >= the value, so each slot holds the cumulative count -- + # render them directly, Prometheus-style. + for i, upper in enumerate(self._buckets): + labels_with_le = tuple(sorted(key + (("le", str(upper)),))) + lines.append(f"{self.name}_bucket{_format_labels(labels_with_le)} {bucket_counts[i]}") + labels_inf = tuple(sorted(key + (("le", "+Inf"),))) + lines.append(f"{self.name}_bucket{_format_labels(labels_inf)} {totals[key]}") + lines.append(f"{self.name}_sum{_format_labels(key)} {sums[key]}") + lines.append(f"{self.name}_count{_format_labels(key)} {totals[key]}") + return "\n".join(lines) + + +class Registry: + def __init__(self) -> None: + self._metrics: list[Counter | Histogram] = [] + + def register(self, metric: Counter | Histogram) -> Counter | Histogram: + self._metrics.append(metric) + return metric + + def render(self) -> str: + parts = [m.render() for m in self._metrics] + return "\n".join(parts) + "\n" + + +# --- Default registry + standard metrics ----------------------------------- + +REGISTRY = Registry() + +tool_calls: Counter = REGISTRY.register( # type: ignore[assignment] + Counter("beaconmcp_tool_calls_total", "Total MCP tool invocations, by tool and status.") +) +tool_latency_ms: Histogram = REGISTRY.register( # type: ignore[assignment] + Histogram("beaconmcp_tool_latency_ms", "Tool call latency in milliseconds, by tool.") +) +auth_events: Counter = REGISTRY.register( # type: ignore[assignment] + Counter("beaconmcp_auth_events_total", "Auth events, by kind (login, token, refresh) and outcome.") +) +http_requests: Counter = REGISTRY.register( # type: ignore[assignment] + Counter("beaconmcp_http_requests_total", "HTTP requests to BeaconMCP endpoints, by path and status.") +) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..646b098 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +"""Shared pytest configuration. + +``test_integration.py`` is a standalone script with its own ``TestRunner`` +and is designed to be executed as ``python tests/test_integration.py`` +against live infrastructure. Its ``test_*`` functions take a ``runner`` +and ``tools`` positional argument, which pytest tries (and fails) to +resolve as fixtures -- producing a pile of ERRORs on every ``pytest`` +run even when nothing destructive would have happened. + +We tell pytest to skip that file at collection time unless the opt-in +environment variable ``BEACONMCP_RUN_INTEGRATION=1`` is set. The script +path stays runnable as a plain Python program. +""" + +from __future__ import annotations + +import os + + +def _run_integration_enabled() -> bool: + return os.environ.get("BEACONMCP_RUN_INTEGRATION", "").strip().lower() in ( + "1", "true", "yes", "on", + ) + + +# Collected by pytest; a relative path listed here is skipped entirely. +collect_ignore: list[str] = [] +if not _run_integration_enabled(): + collect_ignore.append("test_integration.py") diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..c124bac --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,41 @@ +"""Tests for the JSON-lines audit logger.""" + +from __future__ import annotations + +import json +import logging + +from beaconmcp import audit + + +def test_emit_writes_json_line(caplog) -> None: + with caplog.at_level(logging.INFO, logger="beaconmcp.audit"): + audit.emit("auth.login", client_id="c1", outcome="ok") + rec = caplog.records[-1] + data = json.loads(rec.getMessage()) + assert data["event"] == "auth.login" + assert data["client_id"] == "c1" + assert data["outcome"] == "ok" + assert "ts" in data + + +def test_redacts_sensitive_fields(caplog) -> None: + with caplog.at_level(logging.INFO, logger="beaconmcp.audit"): + audit.emit( + "tool.call", + tool="ssh_run", + args={"host": "pve1", "password": "hunter2", "nested": {"token": "abc"}}, + ) + data = json.loads(caplog.records[-1].getMessage()) + assert data["args"]["host"] == "pve1" + assert data["args"]["password"] == "***" + assert data["args"]["nested"]["token"] == "***" + + +def test_emit_never_raises(monkeypatch) -> None: + def boom(_msg: str) -> None: + raise RuntimeError("sink died") + + monkeypatch.setattr(audit._logger, "info", boom) + # Should swallow the exception -- audit must never break a request. + audit.emit("anything", x=1) diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..dfcfebe --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,50 @@ +"""Tests for the in-process Prometheus-format metrics.""" + +from __future__ import annotations + +from beaconmcp.metrics import Counter, Histogram, Registry + + +def test_counter_increments_with_labels() -> None: + c = Counter("foo_total", "help") + c.inc(tool="ssh_run", status="ok") + c.inc(tool="ssh_run", status="ok") + c.inc(tool="ssh_run", status="err") + out = c.render() + assert 'foo_total{status="ok",tool="ssh_run"} 2' in out + assert 'foo_total{status="err",tool="ssh_run"} 1' in out + + +def test_histogram_buckets_and_sum() -> None: + h = Histogram("lat_ms", "help", buckets_ms=(10, 100)) + h.observe(5, tool="x") + h.observe(50, tool="x") + h.observe(200, tool="x") + out = h.render() + # 5ms: fits bucket <=10, <=100, +Inf + # 50ms: fits <=100, +Inf + # 200ms: only +Inf + assert 'lat_ms_bucket{le="10",tool="x"} 1' in out + assert 'lat_ms_bucket{le="100",tool="x"} 2' in out + assert 'lat_ms_bucket{le="+Inf",tool="x"} 3' in out + assert "lat_ms_sum" in out + assert 'lat_ms_count{tool="x"} 3' in out + + +def test_registry_concats() -> None: + r = Registry() + r.register(Counter("a_total", "a")) + r.register(Counter("b_total", "b")) + out = r.render() + assert "# TYPE a_total counter" in out + assert "# TYPE b_total counter" in out + # No empty tail. + assert out.endswith("\n") + + +def test_histogram_time_context_manager() -> None: + h = Histogram("t_ms", "help") + with h.time(tool="y"): + pass + out = h.render() + assert 't_ms_count{tool="y"} 1' in out From 2d82425946e0006f83d4293c28498f964ba739af Mon Sep 17 00:00:00 2001 From: ailcope <54411234+Ailcope@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:56:07 +0200 Subject: [PATCH 098/155] Update src/beaconmcp/proxmox/aggregators.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/beaconmcp/proxmox/aggregators.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/beaconmcp/proxmox/aggregators.py b/src/beaconmcp/proxmox/aggregators.py index 4a2c1e9..d0c2578 100644 --- a/src/beaconmcp/proxmox/aggregators.py +++ b/src/beaconmcp/proxmox/aggregators.py @@ -354,19 +354,20 @@ def vm_bulk_action( if action not in valid_actions: return {"error": f"Unsupported action {action!r}. Use one of {sorted(valid_actions)}."} + # Dedupe while preserving order -- repeated VMIDs are almost always a + # caller bug and doing the same stop/start twice is never what they want. + seen: set[int] = set() + unique_vmids = [v for v in vmids if not (v in seen or seen.add(v))] + # Hard cap. A typo like `vm_bulk_action(range(1, 10000), "stop")` should # fail loud, not take down a cluster. 50 covers legit bulk ops on any # homelab-scale setup. _MAX_BULK = 50 - if len(vmids) > _MAX_BULK: + if len(unique_vmids) > _MAX_BULK: return { - "error": f"Too many VMIDs ({len(vmids)}); cap is {_MAX_BULK} per call. " + "error": f"Too many VMIDs ({len(unique_vmids)} unique); cap is {_MAX_BULK} per call. " "Split into multiple calls.", } - # Dedupe while preserving order -- repeated VMIDs are almost always a - # caller bug and doing the same stop/start twice is never what they want. - seen: set[int] = set() - unique_vmids = [v for v in vmids if not (v in seen or seen.add(v))] def _one(vmid: int) -> dict[str, Any]: location = _find_vm_location(proxmox_client, vmid) From 2571b805c3282855a9f447e013841728b722723a Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Sun, 19 Apr 2026 00:30:07 +0200 Subject: [PATCH 099/155] docs: clarify proxmox_run LXC behavior --- README.md | 4 ++-- docs/superpowers/specs/2026-04-16-beaconmcp-design.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ab29d6e..2d1ad2a 100644 --- a/README.md +++ b/README.md @@ -276,13 +276,13 @@ BeaconMCP exposes tools that cause irreversible changes: `ssh_run`, `proxmox_run | `proxmox_vm_migrate` | Migrate across nodes. | | `proxmox_vm_config` | Read or update configuration. | -### Proxmox — system (5) +### Proxmox — system (3) | Tool | Description | |------|-------------| | `proxmox_storage_status` | Storage pool status. | | `proxmox_network_config` | Network configuration per node. | -| `proxmox_run` | Command inside a VM or container via QEMU Guest Agent. Sync by default; pass `wait=False` to start async, or `exec_id=` to poll an existing session. | +| `proxmox_run` | Command inside a QEMU VM via QEMU Guest Agent. Sync by default; pass `wait=False` to start async, or `exec_id=` to poll an existing session. For LXC containers, use `ssh_run` on the node with `pct exec -- `. | ### SSH fallback (2) diff --git a/docs/superpowers/specs/2026-04-16-beaconmcp-design.md b/docs/superpowers/specs/2026-04-16-beaconmcp-design.md index d71437b..ccdf688 100644 --- a/docs/superpowers/specs/2026-04-16-beaconmcp-design.md +++ b/docs/superpowers/specs/2026-04-16-beaconmcp-design.md @@ -82,12 +82,12 @@ src/beaconmcp/ |------|-------------|----------------| | `proxmox_storage_status` | Storage status across the cluster | `node` (optional) | | `proxmox_network_config` | Network configuration of a node | `node` | -| `proxmox_run` | Execute a command inside a VM (QEMU Guest Agent) or CT (lxc exec). Sync by default; pass `wait=False` to start async (returns `exec_id`), or `exec_id=…` to poll an existing session. | `node`, `vmid`, `command`, `timeout` (default 60s), `wait`, `exec_id` | +| `proxmox_run` | Execute a command inside a QEMU VM (QEMU Guest Agent). Sync by default; pass `wait=False` to start async (returns `exec_id`), or `exec_id=…` to poll an existing session. LXC exec is not exposed by the Proxmox API; use `ssh_run` + `pct exec` on the host node. | `node`, `vmid`, `command`, `timeout` (default 60s), `wait`, `exec_id` | **Command execution design:** -- The tool auto-detects whether the target is a VM (uses QEMU Guest Agent) or CT (uses Proxmox's built-in lxc exec). The caller does not need to know the difference. +- The tool auto-detects whether the target is a VM or CT. VMs execute via QEMU Guest Agent; CTs return an actionable error that points to `ssh_run` + `pct exec -- ` on the node. - `proxmox_run` (default, `wait=True`) blocks until the command completes or timeout is reached. Returns `{"status": "ok", "stdout": "...", "stderr": "...", "exit_code": N, "duration_s": ...}`. On timeout it auto-switches to async and returns `{"status": "running", "exec_id": "..."}`. -- `proxmox_run(..., wait=False)` returns immediately with `{"status": "running", "exec_id": "..."}`. Internally uses QEMU Guest Agent's native async exec for VMs (start -> PID -> poll) or background execution for CTs. +- `proxmox_run(..., wait=False)` returns immediately with `{"status": "running", "exec_id": "..."}`. Internally uses QEMU Guest Agent's native async exec for VMs (start -> PID -> poll). - `proxmox_run(exec_id="...")` polls an existing session and returns `{"status": "running|ok|failed|timeout", "stdout": "...", "stderr": "...", "exit_code": N}`. - Async exec state is held in-memory in the server process. A dict of `{exec_id: {pid, node, vmid, type, status, output}}`. From 0f20fd7076ba36c9ed1755bd1a22339aa4100959 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:51:31 +0200 Subject: [PATCH 100/155] batch B: SSH known_hosts + per-IP rate-limit on auth endpoints * SSH host-key verification is finally configurable. Two new fields under `ssh:` in beaconmcp.yaml: - `known_hosts: ` -> asyncssh pins to that file, unknown keys refused. Recommended for anything reachable over the public net. - `strict_host_key_checking: true` (without `known_hosts`) -> use the system `~/.ssh/known_hosts`. Defaults preserve the old trusted-LAN behaviour (accept any key) so existing homelab deployments keep working unchanged. hp_ilo jump-host tunneling uses the same settings. * New `beaconmcp.ratelimit` -- tiny in-memory sliding-window limiter with thread-safe bucket store + best-effort client-IP extraction (honors X-Forwarded-For when present). Wired into: - `/oauth/token` (30 req / 60s / IP) -> returns 429 with Retry-After. - `/app/login` (10 req / 60s / IP) -> renders the login page with a locked banner and HTTP 429. Complements the existing per-client TOTP lockout, which only fires once a valid client_id is known. Both limiters are optional (pass `None` to skip) so tests and embedded uses don't need the state. * 5 new ratelimit unit tests; conftest.py duplicated here so PR B can run `pytest` cleanly even before PR A merges (both files are identical). --- beaconmcp.yaml.example | 35 ++++++------- src/beaconmcp/__main__.py | 23 +++++++- src/beaconmcp/bmc/hp_ilo.py | 9 +++- src/beaconmcp/config.py | 19 +++++++ src/beaconmcp/dashboard/app.py | 22 ++++++++ src/beaconmcp/ratelimit.py | 96 ++++++++++++++++++++++++++++++++++ src/beaconmcp/ssh/client.py | 30 +++++++++-- tests/conftest.py | 29 ++++++++++ tests/test_ratelimit.py | 65 +++++++++++++++++++++++ 9 files changed, 305 insertions(+), 23 deletions(-) create mode 100644 src/beaconmcp/ratelimit.py create mode 100644 tests/conftest.py create mode 100644 tests/test_ratelimit.py diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index 0583fe2..d8c41f3 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -21,24 +21,15 @@ version: 1 server: host: 0.0.0.0 port: 8420 - # Host header allowlist for DNS-rebinding protection. Include the public - # FQDN behind the reverse proxy; 127.0.0.1 and localhost are already safe. - allowed_hosts: - - mcp.example.com - - "127.0.0.1:*" - - "localhost:*" - - "[::1]:*" - # CORS origin allowlist. Browser-based MCP clients send a CORS preflight - # before calling /mcp; their origin MUST be listed here. Desktop / CLI - # clients (Claude Desktop, Gemini CLI, Cursor, VS Code, Mistral Vibe, - # OpenCode, …) are NOT browser-based and don't need an entry. - allowed_origins: - - https://claude.ai - - https://chatgpt.com - - https://chat.openai.com - - https://chat.mistral.ai - - https://www.perplexity.ai - - https://gemini.google.com + + # CORS and Host header validation + allowed_hosts: ["*"] + allowed_origins: ["*"] + + # Trust X-Forwarded-For from these direct peers to avoid IP spoofing bypassing rate limits + trusted_proxies: ["127.0.0.1", "::1"] + + # Persistent storage for OAuth clients and TOTP secrets clients_file: /opt/beaconmcp/clients.json session_key: ${BEACONMCP_SESSION_KEY} # optional, generated if omitted # Enable the OAuth Dynamic Client Registration bootstrap flow used by @@ -108,6 +99,14 @@ ssh: # just works without repeating credentials per node. inherit_proxmox_nodes: true + # Host-key verification. Unset (default) means "accept any key on first + # contact" -- fine on a trusted LAN, unsafe over the public internet. + # Set `known_hosts` to an OpenSSH-format file to pin keys, or leave it + # unset and flip `strict_host_key_checking: true` to use the system + # `~/.ssh/known_hosts` instead. + # known_hosts: /etc/beaconmcp/known_hosts + # strict_host_key_checking: true + hosts: - name: vps1 host: 198.51.100.10 diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 57d8e39..14c2711 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -225,8 +225,16 @@ def _run_http(mcp, host: str, port: int): from . import auth from .auth import ClientStore, CodeStore, TokenStore, current_bearer_token + from .ratelimit import RateLimiter, client_ip from .server import config + # Per-IP rate limit for auth-adjacent endpoints. Numbers sized so a + # human-driven login (with a few retries) always fits, while automated + # brute-force dies fast. TOTP lockout already guards per-client; this + # covers the "wrong client_id" probing the TOTP guard can't see. + _token_limiter = RateLimiter(limit=30, window_seconds=60.0) + _login_limiter = RateLimiter(limit=10, window_seconds=60.0) + env_cf = os.environ.get("BEACONMCP_CLIENTS_FILE") clients_path = Path(env_cf) if env_cf else config.server.clients_file client_store = ClientStore(clients_path) @@ -675,6 +683,14 @@ async def oauth_authorize_post(request: Request) -> Response: return Response(status_code=302, headers={"Location": location}) async def oauth_token(request: Request) -> Response: + ip = client_ip(request, tuple(config.server.trusted_proxies)) + if not _token_limiter.check(ip): + retry = _token_limiter.retry_after(ip) + return JSONResponse( + {"error": "rate_limited", "error_description": "too many requests"}, + status_code=429, + headers={"Retry-After": str(retry)}, + ) try: if request.headers.get("content-type", "").startswith("application/json"): raw = await request.json() @@ -1008,6 +1024,8 @@ async def lifespan(_app): client_store, token_store, totp_locked, totp_record_failure, totp_record_success, dyn_reg=dyn_reg_store, shared_database=shared_database, + login_limiter=_login_limiter, + trusted_proxies=tuple(config.server.trusted_proxies), ) app = Starlette( @@ -1047,7 +1065,8 @@ async def lifespan(_app): def _build_dashboard_routes(client_store, token_store, totp_locked, totp_record_failure, totp_record_success, - *, dyn_reg=None, shared_database=None): + *, dyn_reg=None, shared_database=None, + login_limiter=None, trusted_proxies=()): """Build dashboard routes if enabled. Returns [] when disabled.""" from . import dashboard if not dashboard.is_enabled(): @@ -1118,6 +1137,8 @@ def _float_env(name: str, default: float) -> float: mcp_public_url=mcp_public_url, mcp_mode=mcp_mode, dyn_reg=dyn_reg, + login_limiter=login_limiter, + trusted_proxies=trusted_proxies, ) return build_dashboard_routes(deps) diff --git a/src/beaconmcp/bmc/hp_ilo.py b/src/beaconmcp/bmc/hp_ilo.py index 9834e33..5dfa7e7 100644 --- a/src/beaconmcp/bmc/hp_ilo.py +++ b/src/beaconmcp/bmc/hp_ilo.py @@ -66,7 +66,14 @@ async def _resolve_endpoint(self) -> tuple[str, int]: ) try: - self._tunnel = await _connect_to_host(jump_spec) + kh = self._config.ssh.known_hosts if self._config.ssh else None + strict = ( + self._config.ssh.strict_host_key_checking + if self._config.ssh else False + ) + self._tunnel = await _connect_to_host( + jump_spec, known_hosts=kh, strict_host_key_checking=strict, + ) self._tunnel_listener = await self._tunnel.forward_local_port( "", 0, self._device.host, 443 ) diff --git a/src/beaconmcp/config.py b/src/beaconmcp/config.py index bc2a806..cb05109 100644 --- a/src/beaconmcp/config.py +++ b/src/beaconmcp/config.py @@ -96,6 +96,17 @@ class SSHConfig: # and credentials from ``ssh.defaults``. Restores the pre-2.0 ergonomic # where a single credential block covered every Proxmox node. inherit_proxmox_nodes: bool = False + # Path to an OpenSSH-style known_hosts file. When set, asyncssh verifies + # every connection's host key against this file and refuses unknown + # keys (no TOFU). Default (None) preserves the previous behaviour of + # accepting any key -- appropriate for a trusted LAN but not for + # anything reachable over the public internet. + known_hosts: str | None = None + # When ``known_hosts`` is unset but this is true, still refuse unknown + # keys by asking asyncssh to build an in-memory known_hosts from the + # user's ``~/.ssh/known_hosts``. Kept separate from ``known_hosts`` so + # the YAML can opt into strict mode without naming a file. + strict_host_key_checking: bool = False @dataclass @@ -104,6 +115,7 @@ class ServerConfig: port: int = 8420 allowed_hosts: list[str] = field(default_factory=list) allowed_origins: list[str] = field(default_factory=list) + trusted_proxies: list[str] = field(default_factory=list) clients_file: Path = Path("/opt/beaconmcp/clients.json") session_key: str | None = None # Enables the OAuth Dynamic Client Registration path used by clients @@ -446,6 +458,10 @@ def _build(cls, raw: dict) -> Config: vmid_to_ip=ssh_raw.get("vmid_to_ip"), defaults=defaults, inherit_proxmox_nodes=inherit_flag, + known_hosts=ssh_raw.get("known_hosts") or None, + strict_host_key_checking=_bool( + ssh_raw.get("strict_host_key_checking", False) + ), ) srv_raw = raw.get("server") or {} @@ -454,6 +470,7 @@ def _build(cls, raw: dict) -> Config: port=int(srv_raw.get("port", 8420)), allowed_hosts=list(srv_raw.get("allowed_hosts") or []), allowed_origins=list(srv_raw.get("allowed_origins") or []), + trusted_proxies=list(srv_raw.get("trusted_proxies") or []), clients_file=Path( srv_raw.get("clients_file", "/opt/beaconmcp/clients.json") ), @@ -621,6 +638,8 @@ def mask(value: str) -> str: "ssh": ( { "vmid_to_ip": self.ssh.vmid_to_ip, + "known_hosts": self.ssh.known_hosts, + "strict_host_key_checking": self.ssh.strict_host_key_checking, "hosts": [ { "name": h.name, diff --git a/src/beaconmcp/dashboard/app.py b/src/beaconmcp/dashboard/app.py index 707d7da..3224f47 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -91,6 +91,10 @@ class DashboardDeps: # /app/connectors page is hidden and the slug-scoped OAuth endpoints # are not mounted. dyn_reg: DynamicSlugStore | None = None + # Per-IP limiter guarding /app/login against brute-force. Optional so + # tests/embedding paths can skip the limiter entirely. + login_limiter: object | None = None + trusted_proxies: tuple[str, ...] = () # --------------------------------------------------------------------------- @@ -258,6 +262,24 @@ async def login_post(request: Request) -> Response: if not await csrf.verify(request): return JSONResponse({"error": "csrf"}, status_code=403) + # Per-IP rate limit. Comes before CSRF was fine too, but putting it + # after CSRF keeps the error ordering consistent with /oauth/token. + limiter = deps.login_limiter + if limiter is not None: + from ..ratelimit import client_ip as _client_ip # local import: avoid cycle at module load + ip = _client_ip(request, deps.trusted_proxies) + if not limiter.check(ip): # type: ignore[attr-defined] + retry = limiter.retry_after(ip) # type: ignore[attr-defined] + return _render( + "login.html", + request, + client_id="", + next="", + banner=f"Too many attempts from this address. Retry in {retry}s.", + locked=True, + status_code=429, + ) + form = await request.form() def _v(name: str) -> str: diff --git a/src/beaconmcp/ratelimit.py b/src/beaconmcp/ratelimit.py new file mode 100644 index 0000000..2d40254 --- /dev/null +++ b/src/beaconmcp/ratelimit.py @@ -0,0 +1,96 @@ +"""Tiny in-memory sliding-window rate limiter. + +Covers the auth-adjacent endpoints (``/oauth/token``, ``/app/login``) so a +compromised or malicious client can't brute-force ``client_secret`` / TOTP +at line speed. The existing per-client TOTP lockout only triggers after a +valid-client-bad-TOTP pattern; this limiter fires earlier, on the *IP*, +regardless of which client_id is being tried. + +The bucket lives in-process: if you run multiple BeaconMCP instances +behind a load balancer each instance gets its own count. That's fine for +the single-host homelab target; deploy a real limiter (nginx, Traefik) in +front if you need global state. +""" + +from __future__ import annotations + +import threading +import time +from collections import deque +from dataclasses import dataclass, field + + +@dataclass +class _Bucket: + events: deque[float] = field(default_factory=deque) + + +class RateLimiter: + """Sliding-window limiter: N events per ``window_seconds`` per key. + + ``check(key)`` returns True if the event is allowed (and records it), + False if it should be rejected. Keys are opaque strings -- we use the + client IP for auth endpoints. + """ + + def __init__(self, *, limit: int, window_seconds: float) -> None: + self._limit = limit + self._window = window_seconds + self._buckets: dict[str, _Bucket] = {} + self._lock = threading.Lock() + + def check(self, key: str) -> bool: + now = time.monotonic() + cutoff = now - self._window + with self._lock: + bucket = self._buckets.get(key) + if bucket is None: + bucket = _Bucket() + self._buckets[key] = bucket + # Drop expired events. + while bucket.events and bucket.events[0] < cutoff: + bucket.events.popleft() + if len(bucket.events) >= self._limit: + return False + bucket.events.append(now) + # Opportunistic GC: if the bucket map grows large, drop any + # bucket whose deque is now empty. Cheap enough to run inline. + if len(self._buckets) > 1024: + empty = [k for k, b in self._buckets.items() if not b.events] + for k in empty: + del self._buckets[k] + return True + + def retry_after(self, key: str) -> int: + """Seconds until ``key`` can make another request (0 if allowed now). + + Used to populate the ``Retry-After`` response header. + """ + with self._lock: + bucket = self._buckets.get(key) + if bucket is None or not bucket.events: + return 0 + oldest = bucket.events[0] + return max(0, int(self._window - (time.monotonic() - oldest)) + 1) + + +def client_ip(request: object, trusted_proxies: tuple[str, ...] = ()) -> str: + """Best-effort client IP for a Starlette ``Request``. + + Honors ``X-Forwarded-For`` (takes the first entry) only when the direct + peer is in ``trusted_proxies``. Otherwise, falls back to the direct peer. + This prevents a direct client from spoofing their IP to bypass limiters. + """ + client = getattr(request, "client", None) + direct_peer = getattr(client, "host", None) if client is not None else None + + headers = getattr(request, "headers", None) + if headers is not None and direct_peer in trusted_proxies: + fwd = headers.get("x-forwarded-for") if hasattr(headers, "get") else None + if fwd: + # Take the left-most entry (original client, per RFC 7239 common usage). + return fwd.split(",")[0].strip() + + if direct_peer: + return str(direct_peer) + return "unknown" diff --git a/src/beaconmcp/ssh/client.py b/src/beaconmcp/ssh/client.py index d09e0fe..27bf9e5 100644 --- a/src/beaconmcp/ssh/client.py +++ b/src/beaconmcp/ssh/client.py @@ -54,19 +54,37 @@ def _prune_ssh_sessions() -> None: del _ssh_sessions[eid] -async def _connect_to_host(spec: SSHHost) -> asyncssh.SSHClientConnection: +async def _connect_to_host( + spec: SSHHost, + *, + known_hosts: str | None = None, + strict_host_key_checking: bool = False, +) -> asyncssh.SSHClientConnection: """Open an asyncssh connection to a declared host using its auth method. Exposed at module level so BMC jump-host tunneling in ``bmc/hp_ilo.py`` can reuse the same auth plumbing (password vs. key_file, port override, trusted host keys) instead of duplicating it. + + Host-key verification: + * ``known_hosts`` (path): asyncssh loads the file and refuses unknown keys. + * ``strict_host_key_checking=True`` with no ``known_hosts``: use the + caller's ``~/.ssh/known_hosts`` (asyncssh's default when the kwarg + is omitted entirely). + * Neither: pass ``known_hosts=None`` -- accept any key. Default to keep + existing trusted-LAN deployments working unchanged. """ connect_kwargs: dict[str, Any] = { "host": spec.host, "port": spec.port, "username": spec.user, - "known_hosts": None, # infra is trusted } + if known_hosts: + connect_kwargs["known_hosts"] = os.path.expanduser(known_hosts) + elif not strict_host_key_checking: + # Trusted-LAN default. + connect_kwargs["known_hosts"] = None + # else: omit the kwarg -> asyncssh uses ~/.ssh/known_hosts automatically. if spec.password: connect_kwargs["password"] = spec.password elif spec.key_file: @@ -204,7 +222,13 @@ async def _get_connection(self, identifier: str) -> asyncssh.SSHClientConnection pass del _connection_cache[cache_key] - conn = await _connect_to_host(host_spec) + kh = self._config.ssh.known_hosts if self._config.ssh else None + strict = ( + self._config.ssh.strict_host_key_checking if self._config.ssh else False + ) + conn = await _connect_to_host( + host_spec, known_hosts=kh, strict_host_key_checking=strict, + ) _connection_cache[cache_key] = (conn, time.time()) return conn diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..646b098 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +"""Shared pytest configuration. + +``test_integration.py`` is a standalone script with its own ``TestRunner`` +and is designed to be executed as ``python tests/test_integration.py`` +against live infrastructure. Its ``test_*`` functions take a ``runner`` +and ``tools`` positional argument, which pytest tries (and fails) to +resolve as fixtures -- producing a pile of ERRORs on every ``pytest`` +run even when nothing destructive would have happened. + +We tell pytest to skip that file at collection time unless the opt-in +environment variable ``BEACONMCP_RUN_INTEGRATION=1`` is set. The script +path stays runnable as a plain Python program. +""" + +from __future__ import annotations + +import os + + +def _run_integration_enabled() -> bool: + return os.environ.get("BEACONMCP_RUN_INTEGRATION", "").strip().lower() in ( + "1", "true", "yes", "on", + ) + + +# Collected by pytest; a relative path listed here is skipped entirely. +collect_ignore: list[str] = [] +if not _run_integration_enabled(): + collect_ignore.append("test_integration.py") diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py new file mode 100644 index 0000000..cb31652 --- /dev/null +++ b/tests/test_ratelimit.py @@ -0,0 +1,65 @@ +"""Tests for the in-memory sliding-window rate limiter.""" + +from __future__ import annotations + +import time + +from beaconmcp.ratelimit import RateLimiter, client_ip + + +def test_allows_up_to_limit_then_blocks() -> None: + rl = RateLimiter(limit=3, window_seconds=60.0) + assert rl.check("1.2.3.4") is True + assert rl.check("1.2.3.4") is True + assert rl.check("1.2.3.4") is True + assert rl.check("1.2.3.4") is False + + +def test_keys_are_independent() -> None: + rl = RateLimiter(limit=2, window_seconds=60.0) + assert rl.check("a") is True + assert rl.check("a") is True + assert rl.check("a") is False + # Different key: fresh budget. + assert rl.check("b") is True + + +def test_window_expiry_frees_slots() -> None: + rl = RateLimiter(limit=2, window_seconds=0.05) + assert rl.check("k") is True + assert rl.check("k") is True + assert rl.check("k") is False + time.sleep(0.08) + # Old events aged out. + assert rl.check("k") is True + + +def test_retry_after_nonzero_when_blocked() -> None: + rl = RateLimiter(limit=1, window_seconds=60.0) + assert rl.check("x") is True + assert rl.check("x") is False + assert rl.retry_after("x") > 0 + + +def test_client_ip_prefers_forwarded_for() -> None: + class _H: + def __init__(self, fwd: str | None) -> None: + self._fwd = fwd + def get(self, k: str) -> str | None: + if k.lower() == "x-forwarded-for": + return self._fwd + return None + + class _Client: + host = "10.0.0.1" + + class _Req: + def __init__(self, fwd: str | None) -> None: + self.headers = _H(fwd) + self.client = _Client() + + # Trusted proxy -> parse X-Forwarded-For + assert client_ip(_Req("203.0.113.7, 10.0.0.1"), trusted_proxies=("10.0.0.1",)) == "203.0.113.7" + assert client_ip(_Req(" 1.2.3.4 "), trusted_proxies=("10.0.0.1",)) == "1.2.3.4" + # Untrusted proxy -> return peer IP directly + assert client_ip(_Req("203.0.113.7"), trusted_proxies=("127.0.0.1",)) == "10.0.0.1" From f64bb35809d6510f6c6936dc6d6390f35a9f86ab Mon Sep 17 00:00:00 2001 From: Lony <66854264+Showdown76py@users.noreply.github.com> Date: Sun, 19 Apr 2026 00:44:36 +0200 Subject: [PATCH 101/155] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/beaconmcp/__main__.py | 28 +++++++++++++++++++++- src/beaconmcp/audit.py | 14 +++++------ src/beaconmcp/server.py | 49 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index d350667..1e0e317 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -226,9 +226,34 @@ def _run_http(mcp, host: str, port: int): from . import audit from . import auth from .auth import ClientStore, CodeStore, TokenStore, current_bearer_token - from .metrics import REGISTRY, auth_events, http_requests + from .ratelimit import RateLimiter, client_ip from .server import config + from .metrics import REGISTRY, auth_events, http_requests + + class MetricsMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + start = time.monotonic() + path = request.url.path + + # Group dashboard and static paths to avoid cardinality explosion + if path.startswith("/app/api/conversations"): + path = "/app/api/conversations" + elif path.startswith("/app/"): + path = "/app" + elif path.startswith("/static/"): + path = "/static" + + try: + response = await call_next(request) + status = str(response.status_code) + return response + except Exception: + status = "500" + raise + finally: + http_requests.inc(path=path, status=status) + env_cf = os.environ.get("BEACONMCP_CLIENTS_FILE") clients_path = Path(env_cf) if env_cf else config.server.clients_file client_store = ClientStore(clients_path) @@ -1028,6 +1053,7 @@ async def lifespan(_app): ) app = Starlette( + middleware=[Middleware(MetricsMiddleware)], routes=[ Route("/health", health), Route("/metrics", metrics), diff --git a/src/beaconmcp/audit.py b/src/beaconmcp/audit.py index 310768c..1868103 100644 --- a/src/beaconmcp/audit.py +++ b/src/beaconmcp/audit.py @@ -41,7 +41,7 @@ def _redact(value: Any) -> Any: """Walk ``value`` replacing obviously-sensitive leaf values with ``***``.""" if isinstance(value, dict): return { - k: ("***" if k.lower() in _REDACT_KEYS else _redact(v)) + k: ("***" if isinstance(k, str) and k.lower() in _REDACT_KEYS else _redact(v)) for k, v in value.items() } if isinstance(value, list): @@ -56,13 +56,13 @@ def emit(event: str, **fields: Any) -> None: ``auth.token.issue``, ...). Any number of additional keyword fields can be attached; they're redacted and merged into the JSON record. """ - record = { - "ts": datetime.now(timezone.utc).isoformat(timespec="microseconds"), - "event": event, - } - for k, v in fields.items(): - record[k] = _redact(v) try: + record = { + "ts": datetime.now(timezone.utc).isoformat(timespec="microseconds"), + "event": event, + } + for k, v in fields.items(): + record[k] = _redact(v) _logger.info(json.dumps(record, default=str, ensure_ascii=False)) except Exception: # noqa: BLE001 -- audit must never break a request pass diff --git a/src/beaconmcp/server.py b/src/beaconmcp/server.py index 1c6b8b7..34e94b5 100644 --- a/src/beaconmcp/server.py +++ b/src/beaconmcp/server.py @@ -18,6 +18,10 @@ from .ssh.client import SSHClient from .ssh.tools import register_ssh_tools +from functools import wraps +import time +from .metrics import tool_calls, tool_latency_ms + config = Config.load() proxmox_client = ProxmoxClient(config) ssh_client = SSHClient(config) @@ -121,6 +125,51 @@ def _build_instructions() -> str: ) +# Wrap mcp.tool to inject metrics tracking +_orig_tool = mcp.tool +def _metric_tool(*args, **kwargs): + def decorator(func): + tool_name = func.__name__ + @wraps(func) + async def async_wrapper(*f_args, **f_kwargs): + start = time.monotonic() + status = "ok" + try: + return await func(*f_args, **f_kwargs) + except Exception: + status = "error" + raise + finally: + latency = (time.monotonic() - start) * 1000 + tool_calls.inc(tool=tool_name, status=status) + tool_latency_ms.observe(latency, tool=tool_name) + + @wraps(func) + def sync_wrapper(*f_args, **f_kwargs): + start = time.monotonic() + status = "ok" + try: + return func(*f_args, **f_kwargs) + except Exception: + status = "error" + raise + finally: + latency = (time.monotonic() - start) * 1000 + tool_calls.inc(tool=tool_name, status=status) + tool_latency_ms.observe(latency, tool=tool_name) + + + import inspect + if inspect.iscoroutinefunction(func): + wrapped = async_wrapper + else: + wrapped = sync_wrapper + + return _orig_tool(*args, **kwargs)(wrapped) + return decorator +mcp.tool = _metric_tool + + @mcp.resource("beaconmcp://infrastructure") def get_infrastructure() -> str: """Infrastructure context: node topology, naming conventions, and access constraints.""" From 8e599650001374745252238b1718dee3c6d0e6e3 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Sun, 19 Apr 2026 18:12:49 +0200 Subject: [PATCH 102/155] Harden auth rate-limit IP trust and add Cloudflare proxy macro --- README.md | 3 +- beaconmcp.yaml.example | 31 +++++++++-- src/beaconmcp/config.py | 59 +++++++++++++++++++- src/beaconmcp/ratelimit.py | 109 +++++++++++++++++++++++++++++++------ tests/test_config_yaml.py | 52 ++++++++++++++++++ tests/test_ratelimit.py | 56 ++++++++++++++++--- 6 files changed, 275 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 8d27a27..a74b69d 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ curl http://localhost:8420/health ### 5. Expose publicly -Place BeaconMCP behind a reverse proxy that terminates TLS and forwards the public hostname to `http://localhost:8420`. Declare that hostname under `server.allowed_hosts` in `beaconmcp.yaml`; without it the MCP SDK rejects incoming requests with `421 Misdirected Request` (DNS-rebinding protection). +Place BeaconMCP behind a reverse proxy that terminates TLS and forwards the public hostname to `http://localhost:8420`. Declare that hostname under `server.allowed_hosts` in `beaconmcp.yaml`; without it the MCP SDK rejects incoming requests with `421 Misdirected Request` (DNS-rebinding protection). If you're proxying through Cloudflare, add `cloudflare` to `server.trusted_proxies` so BeaconMCP can safely trust forwarded client IPs for auth rate limiting. --- @@ -226,6 +226,7 @@ Common keys: |---------|-------| | `server.allowed_hosts` | DNS-rebinding allowlist — **must** include the public FQDN behind your reverse proxy. | | `server.allowed_origins` | CORS allowlist for browser-based MCP clients. | +| `server.trusted_proxies` | Direct peers allowed to supply `X-Forwarded-For` (IPs or CIDRs). Use `cloudflare` to auto-expand Cloudflare edge ranges. | | `proxmox.nodes[]` | One entry per Proxmox node. Needs an API token per node. Prefer a **LAN IP** in `host:` (e.g. `10.0.0.1`) — it's the one string that works for both the Proxmox API and for SSH inheritance. `localhost` is OK when BeaconMCP runs directly on that node. Only use an FQDN with a reverse-proxy port (e.g. `:443`) for nodes you can't reach on the LAN, and declare those explicitly under `ssh.hosts[]` with their real SSH address. | | `ssh.hosts[]` | One entry per SSH target (VPS, Proxmox node, jump box, …). Each entry carries its own `user` + exactly one of `password` / `key_file`. Names may match `proxmox.nodes[].name`. | | `ssh.defaults` + `ssh.inherit_proxmox_nodes` | Homelab shortcut. Set `defaults:` (user + password/key_file) and flip `inherit_proxmox_nodes: true` — every Proxmox node becomes SSH-reachable under its own name with those defaults, no duplication. Explicit `ssh.hosts[]` entries still win when they match a node by name or address. | diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index d8c41f3..1d507da 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -21,13 +21,32 @@ version: 1 server: host: 0.0.0.0 port: 8420 - - # CORS and Host header validation - allowed_hosts: ["*"] - allowed_origins: ["*"] - # Trust X-Forwarded-For from these direct peers to avoid IP spoofing bypassing rate limits - trusted_proxies: ["127.0.0.1", "::1"] + # Host header allowlist for DNS-rebinding protection. Include the public + # FQDN behind the reverse proxy; localhost entries are useful for local dev. + allowed_hosts: + - mcp.example.com + - "127.0.0.1:*" + - "localhost:*" + - "[::1]:*" + + # CORS origin allowlist for browser-based MCP clients. + allowed_origins: + - https://claude.ai + - https://chatgpt.com + - https://chat.openai.com + - https://chat.mistral.ai + - https://www.perplexity.ai + - https://gemini.google.com + + # Trust X-Forwarded-For only from direct peers you operate. + # IPs and CIDRs are accepted. + # - Keep loopback entries when your reverse proxy runs on the same host. + # - Add `cloudflare` to auto-expand to Cloudflare's published proxy CIDRs. + trusted_proxies: + - "127.0.0.1" + - "::1" + # - cloudflare # Persistent storage for OAuth clients and TOTP secrets clients_file: /opt/beaconmcp/clients.json diff --git a/src/beaconmcp/config.py b/src/beaconmcp/config.py index cb05109..32dcbdc 100644 --- a/src/beaconmcp/config.py +++ b/src/beaconmcp/config.py @@ -470,7 +470,7 @@ def _build(cls, raw: dict) -> Config: port=int(srv_raw.get("port", 8420)), allowed_hosts=list(srv_raw.get("allowed_hosts") or []), allowed_origins=list(srv_raw.get("allowed_origins") or []), - trusted_proxies=list(srv_raw.get("trusted_proxies") or []), + trusted_proxies=_parse_trusted_proxies(srv_raw.get("trusted_proxies")), clients_file=Path( srv_raw.get("clients_file", "/opt/beaconmcp/clients.json") ), @@ -606,6 +606,7 @@ def mask(value: str) -> str: "port": self.server.port, "allowed_hosts": self.server.allowed_hosts, "allowed_origins": self.server.allowed_origins, + "trusted_proxies": self.server.trusted_proxies, "clients_file": str(self.server.clients_file), "session_key": mask(self.server.session_key or ""), "allow_dynamic_registration": self.server.allow_dynamic_registration, @@ -679,6 +680,32 @@ class ConfigError(Exception): _ENV_REF = re.compile(r"^\$\{([A-Z_][A-Z0-9_]*)\}$") +_CLOUDFLARE_PROXY_CIDRS: tuple[str, ...] = ( + # Snapshot from https://www.cloudflare.com/ips/ + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", + "2400:cb00::/32", + "2606:4700::/32", + "2803:f800::/32", + "2405:b500::/32", + "2405:8100::/32", + "2a06:98c0::/29", + "2c0f:f248::/32", +) + def _resolve_env_refs( value: Any, *, path: Path, _crumbs: tuple[str, ...] = () @@ -734,6 +761,36 @@ def _bool(value: Any) -> bool: return str(value).strip().lower() in ("1", "true", "yes", "on") +def _parse_trusted_proxies(value: Any) -> list[str]: + if value is None: + return [] + if not isinstance(value, list): + raise ConfigError( + "server.trusted_proxies: must be a list of IPs/CIDRs or 'cloudflare'." + ) + + out: list[str] = [] + seen: set[str] = set() + for i, item in enumerate(value): + if not isinstance(item, str): + raise ConfigError( + f"server.trusted_proxies[{i}]: must be a string (IP, CIDR, or 'cloudflare')." + ) + token = item.strip() + if not token: + continue + if token.lower() == "cloudflare": + for cidr in _CLOUDFLARE_PROXY_CIDRS: + if cidr not in seen: + out.append(cidr) + seen.add(cidr) + continue + if token not in seen: + out.append(token) + seen.add(token) + return out + + def _strip_port(host: str) -> str: """Return ``host`` without a trailing ``:port`` component. diff --git a/src/beaconmcp/ratelimit.py b/src/beaconmcp/ratelimit.py index 2d40254..b72824d 100644 --- a/src/beaconmcp/ratelimit.py +++ b/src/beaconmcp/ratelimit.py @@ -14,6 +14,7 @@ from __future__ import annotations +import ipaddress import threading import time from collections import deque @@ -38,6 +39,18 @@ def __init__(self, *, limit: int, window_seconds: float) -> None: self._window = window_seconds self._buckets: dict[str, _Bucket] = {} self._lock = threading.Lock() + self._last_gc = 0.0 + + @staticmethod + def _prune_bucket(bucket: _Bucket, cutoff: float) -> None: + while bucket.events and bucket.events[0] <= cutoff: + bucket.events.popleft() + + def _collect_stale_buckets_locked(self, cutoff: float) -> None: + for key, bucket in list(self._buckets.items()): + self._prune_bucket(bucket, cutoff) + if not bucket.events: + del self._buckets[key] def check(self, key: str) -> bool: now = time.monotonic() @@ -48,17 +61,15 @@ def check(self, key: str) -> bool: bucket = _Bucket() self._buckets[key] = bucket # Drop expired events. - while bucket.events and bucket.events[0] < cutoff: - bucket.events.popleft() + self._prune_bucket(bucket, cutoff) if len(bucket.events) >= self._limit: return False bucket.events.append(now) - # Opportunistic GC: if the bucket map grows large, drop any - # bucket whose deque is now empty. Cheap enough to run inline. - if len(self._buckets) > 1024: - empty = [k for k, b in self._buckets.items() if not b.events] - for k in empty: - del self._buckets[k] + # Opportunistic GC: once the map is large, reclaim stale keys whose + # events are all outside the window. + if len(self._buckets) > 1024 or (now - self._last_gc) >= self._window: + self._collect_stale_buckets_locked(cutoff) + self._last_gc = now return True def retry_after(self, key: str) -> int: @@ -70,27 +81,89 @@ def retry_after(self, key: str) -> int: bucket = self._buckets.get(key) if bucket is None or not bucket.events: return 0 + cutoff = time.monotonic() - self._window + self._prune_bucket(bucket, cutoff) + if not bucket.events: + del self._buckets[key] + return 0 oldest = bucket.events[0] return max(0, int(self._window - (time.monotonic() - oldest)) + 1) +def _coerce_ip(value: object) -> str | None: + raw = str(value).strip() + if not raw: + return None + try: + return str(ipaddress.ip_address(raw)) + except ValueError: + pass + if raw.startswith("[") and "]" in raw: + try: + return str(ipaddress.ip_address(raw[1 : raw.index("]")])) + except ValueError: + pass + if raw.count(":") == 1: + host, _, port = raw.rpartition(":") + if host and port.isdigit(): + try: + return str(ipaddress.ip_address(host)) + except ValueError: + pass + return None + + +def _is_trusted_proxy(ip_value: str, trusted_proxies: tuple[str, ...]) -> bool: + try: + ip_obj = ipaddress.ip_address(ip_value) + except ValueError: + return False + + for raw_rule in trusted_proxies: + rule = raw_rule.strip() + if not rule: + continue + if "/" in rule: + try: + if ip_obj in ipaddress.ip_network(rule, strict=False): + return True + except ValueError: + continue + continue + rule_ip = _coerce_ip(rule) + if rule_ip is None: + continue + if ip_obj == ipaddress.ip_address(rule_ip): + return True + return False + + def client_ip(request: object, trusted_proxies: tuple[str, ...] = ()) -> str: """Best-effort client IP for a Starlette ``Request``. - Honors ``X-Forwarded-For`` (takes the first entry) only when the direct - peer is in ``trusted_proxies``. Otherwise, falls back to the direct peer. - This prevents a direct client from spoofing their IP to bypass limiters. + Honors ``X-Forwarded-For`` only when the direct peer is trusted. In that + case we walk the chain from right to left and return the first untrusted + hop, which avoids left-most spoofing when proxies append to the header. """ client = getattr(request, "client", None) direct_peer = getattr(client, "host", None) if client is not None else None - + direct_peer_raw = str(direct_peer) if direct_peer is not None else "" + direct_ip = _coerce_ip(direct_peer_raw) + headers = getattr(request, "headers", None) - if headers is not None and direct_peer in trusted_proxies: + if headers is not None and direct_ip and _is_trusted_proxy(direct_ip, trusted_proxies): fwd = headers.get("x-forwarded-for") if hasattr(headers, "get") else None if fwd: - # Take the left-most entry (original client, per RFC 7239 common usage). - return fwd.split(",")[0].strip() - - if direct_peer: - return str(direct_peer) + chain: list[str] = [ + ip for ip in (_coerce_ip(part) for part in fwd.split(",")) if ip is not None + ] + chain.append(direct_ip) + for hop in reversed(chain): + if not _is_trusted_proxy(hop, trusted_proxies): + return hop + + if direct_ip: + return direct_ip + if direct_peer_raw: + return direct_peer_raw return "unknown" diff --git a/tests/test_config_yaml.py b/tests/test_config_yaml.py index 693bb98..9bcc44a 100644 --- a/tests/test_config_yaml.py +++ b/tests/test_config_yaml.py @@ -560,6 +560,58 @@ def test_redacted_masks_secrets(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) assert "***" in redacted["ssh"]["hosts"][0]["password"] +def test_server_trusted_proxies_cloudflare_macro_expands( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("VPS_PW", "pw") + path = _write( + tmp_path / "beaconmcp.yaml", + """ + version: 1 + server: + trusted_proxies: + - cloudflare + - 127.0.0.1 + - 127.0.0.1 + ssh: + hosts: + - name: vps1 + host: 198.51.100.10 + user: root + password: ${VPS_PW} + """, + ) + + cfg = Config.load(config_path=path) + assert "173.245.48.0/20" in cfg.server.trusted_proxies + assert "2a06:98c0::/29" in cfg.server.trusted_proxies + assert cfg.server.trusted_proxies.count("127.0.0.1") == 1 + assert "trusted_proxies" in cfg.redacted()["server"] + + +def test_server_trusted_proxies_must_be_list( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("VPS_PW", "pw") + path = _write( + tmp_path / "beaconmcp.yaml", + """ + version: 1 + server: + trusted_proxies: cloudflare + ssh: + hosts: + - name: vps1 + host: 198.51.100.10 + user: root + password: ${VPS_PW} + """, + ) + + with pytest.raises(ConfigError, match="server.trusted_proxies"): + Config.load(config_path=path) + + def test_get_ssh_host_accessors( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py index cb31652..fb7cdf5 100644 --- a/tests/test_ratelimit.py +++ b/tests/test_ratelimit.py @@ -41,10 +41,21 @@ def test_retry_after_nonzero_when_blocked() -> None: assert rl.retry_after("x") > 0 -def test_client_ip_prefers_forwarded_for() -> None: +def test_gc_reclaims_stale_buckets() -> None: + rl = RateLimiter(limit=1, window_seconds=0.01) + for i in range(1100): + assert rl.check(f"k{i}") is True + time.sleep(0.03) + # Any new check past 1024 buckets triggers stale-bucket collection. + assert rl.check("fresh") is True + assert len(rl._buckets) == 1 + + +def test_client_ip_uses_rightmost_untrusted_hop() -> None: class _H: def __init__(self, fwd: str | None) -> None: self._fwd = fwd + def get(self, k: str) -> str | None: if k.lower() == "x-forwarded-for": return self._fwd @@ -54,12 +65,39 @@ class _Client: host = "10.0.0.1" class _Req: - def __init__(self, fwd: str | None) -> None: + def __init__(self, fwd: str | None, *, peer: str = "10.0.0.1") -> None: self.headers = _H(fwd) - self.client = _Client() - - # Trusted proxy -> parse X-Forwarded-For - assert client_ip(_Req("203.0.113.7, 10.0.0.1"), trusted_proxies=("10.0.0.1",)) == "203.0.113.7" - assert client_ip(_Req(" 1.2.3.4 "), trusted_proxies=("10.0.0.1",)) == "1.2.3.4" - # Untrusted proxy -> return peer IP directly - assert client_ip(_Req("203.0.113.7"), trusted_proxies=("127.0.0.1",)) == "10.0.0.1" + c = _Client() + c.host = peer + self.client = c + + # Trusted direct proxy + spoofed left-most value: + # proxy appends the real client to XFF, so we must not return the spoof. + assert ( + client_ip( + _Req("198.51.100.66, 203.0.113.7"), + trusted_proxies=("10.0.0.1",), + ) + == "203.0.113.7" + ) + + # CIDR rules are accepted for trusted proxies. + assert ( + client_ip( + _Req("203.0.113.9", peer="10.1.2.3"), + trusted_proxies=("10.0.0.0/8",), + ) + == "203.0.113.9" + ) + + # Untrusted direct peer -> ignore XFF entirely. + assert ( + client_ip( + _Req("203.0.113.7", peer="192.0.2.8"), + trusted_proxies=("127.0.0.1",), + ) + == "192.0.2.8" + ) + + # Direct peer with no trust config -> use peer IP. + assert client_ip(_Req(None, peer="203.0.113.10"), trusted_proxies=()) == "203.0.113.10" From 64d8876dd0cfb75de873a4f4949839ff2bfdcf6f Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:13:15 +0200 Subject: [PATCH 103/155] chore: generalize AI assistant terminology across docs and config --- .dockerignore | 2 +- .env.example | 2 +- README.md | 18 ++++---- beaconmcp.yaml.example | 4 +- deploy/install.sh | 2 +- docs/clients.md | 14 +++---- docs/dashboard.md | 6 +-- .../specs/2026-04-16-beaconmcp-design.md | 16 +++---- src/beaconmcp/__main__.py | 4 +- src/beaconmcp/auth.py | 8 ++-- src/beaconmcp/dashboard/__init__.py | 2 +- src/beaconmcp/dashboard/chat.py | 2 +- src/beaconmcp/dashboard/static/app.css | 10 ++--- src/beaconmcp/dashboard/templates/chat.html | 2 +- src/beaconmcp/dashboard/templates/tokens.html | 42 +++++++++---------- src/beaconmcp/server.py | 2 +- src/beaconmcp/wizard.py | 2 +- tests/test_trusted_redirect.py | 14 +++---- 18 files changed, 76 insertions(+), 76 deletions(-) diff --git a/.dockerignore b/.dockerignore index 8f020e5..6abe271 100644 --- a/.dockerignore +++ b/.dockerignore @@ -43,7 +43,7 @@ docs deploy/install.sh deploy/beaconmcp.service .playwright-mcp -.claude +.assistant # OS cruft .DS_Store diff --git a/.env.example b/.env.example index 794cccb..ece0c9f 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,7 @@ SSH_PASSWORD=change-me # BEACONMCP_PORT=8420 # BEACONMCP_HOST=0.0.0.0 # BEACONMCP_ALLOWED_HOSTS=mcp.example.com,127.0.0.1:*,localhost:*,[::1]:* -# BEACONMCP_ALLOWED_ORIGINS=https://claude.ai,https://chat.openai.com,https://gemini.google.com +# BEACONMCP_ALLOWED_ORIGINS=https://assistant.ai,https://chat.openai.com,https://gemini.google.com # BEACONMCP_DASHBOARD_ENABLED=true # BEACONMCP_DASHBOARD_PUBLIC_URL=https://mcp.example.com # BEACONMCP_DASHBOARD_LIMIT_5H_USD=2.0 diff --git a/README.md b/README.md index 8d27a27..a65a7d3 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ **Remote MCP server for Proxmox VE clusters, BMC-managed hardware, and SSH hosts.** -Works with **Claude** (web, mobile, desktop) • **ChatGPT** • **Gemini** (CLI, API) +Works with **Assistant** (web, mobile, desktop) • **ChatGPT** • **Gemini** (CLI, API) [Installation](#installation) • [Connecting clients](#connecting-clients) • [Tools](#available-tools) • [Tests](#tests) @@ -42,7 +42,7 @@ BeaconMCP exposes a Proxmox VE cluster, the hardware underneath it (HP iLO, gene ## Architecture ``` -Clients (Claude, ChatGPT, Gemini) +Clients (Assistant, ChatGPT, Gemini) │ │ HTTPS (reverse proxy / tunnel) ▼ @@ -99,7 +99,7 @@ Initial setup (run once, while the container is up): ```bash docker compose exec beaconmcp beaconmcp validate-config -docker compose exec beaconmcp beaconmcp auth create --name "Claude Web" +docker compose exec beaconmcp beaconmcp auth create --name "Assistant Web" curl http://localhost:8420/health # should return {"status":"ok",...} ``` @@ -151,7 +151,7 @@ beaconmcp validate-config ### 3. Provision an OAuth client ```bash -beaconmcp auth create --name "Claude Web" +beaconmcp auth create --name "Assistant Web" ``` The CLI prints a client id, a client secret, and a TOTP seed (with an ASCII QR code). **Both secrets are displayed exactly once.** Scan the QR into an authenticator app (Google Authenticator, Authy, 1Password) immediately, or store the raw seed in a secrets manager. @@ -184,9 +184,9 @@ Place BeaconMCP behind a reverse proxy that terminates TLS and forwards the publ > > Unattended services (scheduled jobs, CI pipelines) occasionally need machine-held TOTP. That case — with its required precautions and warnings — is covered separately in [docs/totp-automation.md](docs/totp-automation.md). Read it end-to-end before considering automation. -### Claude (web, mobile, desktop) +### Assistant (web, mobile, desktop) -Claude performs the full OAuth 2.1 flow against BeaconMCP, so there is no long-lived bearer to store on its side — you type the TOTP into the authorization page whenever a new token is issued. +Assistant performs the full OAuth 2.1 flow against BeaconMCP, so there is no long-lived bearer to store on its side — you type the TOTP into the authorization page whenever a new token is issued. 1. **Settings → Integrations → Add custom connector.** 2. Fill in: @@ -195,9 +195,9 @@ Claude performs the full OAuth 2.1 flow against BeaconMCP, so there is no long-l - **OAuth Client ID** and **OAuth Client Secret** from `beaconmcp auth create`. 3. **Add.** -On first use (and after each 24-hour token expiry) Claude redirects to the BeaconMCP authorization page. Read the current 6-digit code from your authenticator app and type it in. Claude never holds the TOTP seed, and a leaked session cannot mint a new token without a fresh code from your phone. +On first use (and after each 24-hour token expiry) Assistant redirects to the BeaconMCP authorization page. Read the current 6-digit code from your authenticator app and type it in. Assistant never holds the TOTP seed, and a leaked session cannot mint a new token without a fresh code from your phone. -**Important — CORS allowlist.** Every browser-based MCP client (Claude Web, ChatGPT, Le Chat, Perplexity, Gemini Web) sends a CORS preflight before it can reach `/mcp`. Add each client's origin to `server.allowed_origins` in `beaconmcp.yaml` (see [`beaconmcp.yaml.example`](beaconmcp.yaml.example)). Desktop and CLI clients don't need this. +**Important — CORS allowlist.** Every browser-based MCP client (Assistant Web, ChatGPT, Le Chat, Perplexity, Gemini Web) sends a CORS preflight before it can reach `/mcp`. Add each client's origin to `server.allowed_origins` in `beaconmcp.yaml` (see [`beaconmcp.yaml.example`](beaconmcp.yaml.example)). Desktop and CLI clients don't need this. ### Other clients @@ -241,7 +241,7 @@ Common keys: BeaconMCP exposes tools that cause irreversible changes: `ssh_exec_command*`, `proxmox_exec_command*`, `bmc_power_off`, `proxmox_vm_stop`, `proxmox_vm_create`, and more. Models do not always grasp the consequences of a command — an errant `rm -rf`, a `systemctl stop` on the wrong unit, a `pct destroy` mistaken for `pct stop`. A few working rules: -- **Disable auto-approve** on every external MCP client (Claude Desktop, Gemini CLI, ChatGPT MCP). Keep per-call approval enabled; refuse "always allow this tool". +- **Disable auto-approve** on every external MCP client (Assistant Desktop, Gemini CLI, ChatGPT MCP). Keep per-call approval enabled; refuse "always allow this tool". - **Read the `command` argument** before approving any `ssh_exec_command*` or `proxmox_exec_command*` call. Ask: if this ran against the wrong VM or host, could I recover? - **The integrated chat** at `/app/chat` already forces human confirmation for every `ssh_exec_command*` and `proxmox_exec_command*` call. Read the arguments shown on the confirmation card even when you click through fast. No answer within 5 minutes counts as refusal. - **Prefer read-only tools** (`*_list_*`, `*_status`, `*_get_*`, `get_logs`, `health_status`) for exploration — they cannot break anything and are never gated by confirmation. diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index 0583fe2..38a8d28 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -30,10 +30,10 @@ server: - "[::1]:*" # CORS origin allowlist. Browser-based MCP clients send a CORS preflight # before calling /mcp; their origin MUST be listed here. Desktop / CLI - # clients (Claude Desktop, Gemini CLI, Cursor, VS Code, Mistral Vibe, + # clients (Assistant Desktop, Gemini CLI, Cursor, VS Code, Mistral Vibe, # OpenCode, …) are NOT browser-based and don't need an entry. allowed_origins: - - https://claude.ai + - https://assistant.ai - https://chatgpt.com - https://chat.openai.com - https://chat.mistral.ai diff --git a/deploy/install.sh b/deploy/install.sh index 35df92d..ae1965f 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -94,7 +94,7 @@ echo "Next steps:" echo " 1. Edit /opt/beaconmcp/beaconmcp.yaml to describe your infrastructure." echo " 2. Edit /opt/beaconmcp/.env with the secrets it references." echo " 3. Validate the config: beaconmcp validate-config" -echo " 4. Create a client: beaconmcp auth create --name 'Claude Web'" +echo " 4. Create a client: beaconmcp auth create --name 'Assistant Web'" echo " 5. Start the service: systemctl start beaconmcp" echo " 6. Confirm it is up: curl http://localhost:8420/health" echo " 7. Put it behind a reverse proxy (HTTPS) pointing at localhost:8420." diff --git a/docs/clients.md b/docs/clients.md index a72e708..7e03650 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -3,7 +3,7 @@ BeaconMCP exposes a single MCP endpoint (`https:///mcp`) and three auth paths the dashboard helps you drive: -- **OAuth 2.1 (pre-registered client)** — Claude, Codex, Le Chat, Gemini +- **OAuth 2.1 (pre-registered client)** — Assistant, Codex, Le Chat, Gemini CLI, Antigravity, OpenCode, Cursor, VS Code. Provision a `client_id` / `client_secret` pair via `beaconmcp auth create` and paste them into the client's config. Standard OAuth 2.1 authorization @@ -28,7 +28,7 @@ auth paths the dashboard helps you drive: > is checked against a fixed list in > [`src/beaconmcp/auth.py`](../src/beaconmcp/auth.py) (constant > `TRUSTED_REDIRECT_PREFIXES`). It covers every client documented here -> — consumer and enterprise web URLs (claude.ai, chatgpt.com, +> — consumer and enterprise web URLs (assistant.ai, chatgpt.com, > chat.mistral.ai, …), the OS URI schemes used by desktop clients > (`vscode://`, `cursor://`), and HTTP loopback for CLI tools > (`http://localhost:*`, `http://127.0.0.1:*`). If a new client shows @@ -39,7 +39,7 @@ auth paths the dashboard helps you drive: > callback. > **CORS allowlist — required for every web client.** -> Browser-based MCP clients (Claude Web, ChatGPT Web, Le Chat, Perplexity, +> Browser-based MCP clients (Assistant Web, ChatGPT Web, Le Chat, Perplexity, > Gemini Web) fire a CORS preflight before they can reach `/mcp`. If the > request origin is missing from `server.allowed_origins` in > `beaconmcp.yaml`, every call fails silently with a browser console @@ -48,14 +48,14 @@ auth paths the dashboard helps you drive: > ```yaml > server: > allowed_origins: -> - https://claude.ai +> - https://assistant.ai > - https://chatgpt.com > - https://chat.mistral.ai > - https://www.perplexity.ai > - https://gemini.google.com > ``` > -> Desktop / CLI clients (Claude Desktop, Gemini CLI, Cursor, VS Code, +> Desktop / CLI clients (Assistant Desktop, Gemini CLI, Cursor, VS Code, > Mistral Vibe, OpenCode) are not browser-based and don't need an entry. The dashboard's [`/app/tokens`](../src/beaconmcp/dashboard/templates/tokens.html) @@ -325,7 +325,7 @@ If the native HTTP transport misbehaves, fall back to `mcp-remote`: ### Le Chat (OAuth 2.1) -Le Chat speaks OAuth 2.1 natively. Same flow as Claude — point it at +Le Chat speaks OAuth 2.1 natively. Same flow as Assistant — point it at the bare `/mcp` URL and it handles the rest. 1. In Le Chat: *Intelligence → Connecteurs → Ajouter un connecteur → Connecteur MCP personnalisé*. @@ -464,6 +464,6 @@ Any client that can send a bearer on `https:///mcp` works the same way: create a token from `/app/tokens` after typing your TOTP, configure the client to send `Authorization: Bearer `, revoke from the same page when you are done. If the client natively speaks OAuth 2.1 -(like Claude) or OAuth + DCR (like ChatGPT / OpenCode), prefer those flows +(like Assistant) or OAuth + DCR (like ChatGPT / OpenCode), prefer those flows — they keep the TOTP prompt at the authorization page instead of relying on a stored bearer. diff --git a/docs/dashboard.md b/docs/dashboard.md index 0d7c2b6..bd80542 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -4,7 +4,7 @@ Optional web panel served by BeaconMCP on the same origin as the MCP endpoint (` - **`/app/login`** — exchanges a client id + client secret + TOTP code for an MCP bearer, and stores it in a 90-day HttpOnly session cookie. Removes the need to issue `curl` requests from a phone. - **`/app/chat`** — multi-conversation chat with Gemini 2.5 Flash/Pro (stable) or Gemini 3 Flash / 3.1 Pro (preview, Google allowlist required). **Requires `GEMINI_API_KEY`.** -- **`/app/tokens`** — generates named bearers so external MCP clients (Gemini web, ChatGPT, Claude Desktop) can be wired up without the OAuth dance. **Works without `GEMINI_API_KEY`.** +- **`/app/tokens`** — generates named bearers so external MCP clients (Gemini web, ChatGPT, Assistant Desktop) can be wired up without the OAuth dance. **Works without `GEMINI_API_KEY`.** ## Enabling @@ -77,7 +77,7 @@ Constraints: 3. The Gemini turn blocks server-side until the decision is made (5-minute timeout). 4. On rejection, Gemini receives a `FunctionResponse {"error": "user_rejected"}` and can revise its reply. -The allow-list is hard-coded in `src/beaconmcp/dashboard/chat.py` (`_NEEDS_CONFIRMATION`). Only the integrated chat enforces this gate; external MCP clients (Claude Desktop, Gemini CLI, ChatGPT MCP) must enable their own per-call approval mode (see the **Security** section of the root README). +The allow-list is hard-coded in `src/beaconmcp/dashboard/chat.py` (`_NEEDS_CONFIRMATION`). Only the integrated chat enforces this gate; external MCP clients (Assistant Desktop, Gemini CLI, ChatGPT MCP) must enable their own per-call approval mode (see the **Security** section of the root README). ## Stored data @@ -127,7 +127,7 @@ The dashboard keeps its own MCP session (`streamablehttp_client` + `ClientSessio `TokenStore` lives in memory. After `systemctl restart beaconmcp`, bearers are invalidated while dashboard sessions (SQLite) persist. The dashboard detects this by calling `TokenStore.validate()` on every sensitive route; when a bearer is gone but the session timestamp is still valid, the user is routed to `/app/refresh` to enter a fresh TOTP code and mint a new bearer. -Consequence for externally-issued tokens (`/app/tokens`): a service restart forces every Gemini-web / ChatGPT / Claude-Desktop integration to regenerate its token. If this is operationally annoying, move `TokenStore` to SQLite (not done today). +Consequence for externally-issued tokens (`/app/tokens`): a service restart forces every Gemini-web / ChatGPT / Assistant-Desktop integration to regenerate its token. If this is operationally annoying, move `TokenStore` to SQLite (not done today). ## Disabling entirely diff --git a/docs/superpowers/specs/2026-04-16-beaconmcp-design.md b/docs/superpowers/specs/2026-04-16-beaconmcp-design.md index 875b2f8..7ad8673 100644 --- a/docs/superpowers/specs/2026-04-16-beaconmcp-design.md +++ b/docs/superpowers/specs/2026-04-16-beaconmcp-design.md @@ -2,7 +2,7 @@ ## Context -BeaconMCP is an MCP server that gives Claude direct access to a Proxmox VE infrastructure for diagnostics, VM management, system administration, and hardware management. The motivation: when a server crashes or misbehaves, Claude should be able to diagnose the issue, check hardware health, and propose/execute resolutions -- rather than the user having to manually SSH, check logs, and relay information back and forth. +BeaconMCP is an MCP server that gives Assistant direct access to a Proxmox VE infrastructure for diagnostics, VM management, system administration, and hardware management. The motivation: when a server crashes or misbehaves, Assistant should be able to diagnose the issue, check hardware health, and propose/execute resolutions -- rather than the user having to manually SSH, check logs, and relay information back and forth. **Infrastructure:** - **pve1.example.com** -- Proxmox VE node (active), exposed on the internet via HTTPS @@ -152,14 +152,14 @@ PVE_VERIFY_SSL=false # Set to true if using valid SSL certificates ## Error Handling -- **Node unreachable:** Tools return a clear error message indicating which node is unreachable, rather than raising exceptions. Claude can then suggest remediation (check iLO, try SSH, etc.). +- **Node unreachable:** Tools return a clear error message indicating which node is unreachable, rather than raising exceptions. Assistant can then suggest remediation (check iLO, try SSH, etc.). - **Authentication failures:** Logged and returned as structured errors with guidance (check token, check password, etc.). - **Command timeouts:** Async commands that exceed timeout are marked as `timeout` status. Partial output is preserved. - **iLO tunnel failure:** If pve1 (jump host) is unreachable, iLO tools return an error explaining that iLO is only accessible through pve1. -## Claude Code Integration +## Assistant Code Integration -Add to `~/.claude/settings.json` or project `.claude/settings.json`: +Add to `~/.assistant/settings.json` or project `.assistant/settings.json`: ```json { @@ -190,13 +190,13 @@ Or use a `.env` file in the project directory and configure only the command. - Run an async command and poll for result 3. **iLO:** Test tunnel creation + health check against the real iLO 4. **SSH:** Test direct SSH command execution on pve1 -5. **End-to-end:** Start the MCP server, use it from Claude Code to diagnose a real scenario (e.g., "why is pve2 down?") +5. **End-to-end:** Start the MCP server, use it from Assistant Code to diagnose a real scenario (e.g., "why is pve2 down?") ## MCP Resources & Prompts ### Infrastructure Context Resource -An `infrastructure.yaml` file at the project root provides contextual information about the infrastructure. The MCP server exposes it as a resource so Claude can read it automatically. +An `infrastructure.yaml` file at the project root provides contextual information about the infrastructure. The MCP server exposes it as a resource so Assistant can read it automatically. ```yaml # infrastructure.yaml @@ -228,7 +228,7 @@ notes: - "API tokens must be created on each Proxmox node before use" ``` -The server exposes this as `beaconmcp://infrastructure` -- a readable resource that provides Claude with the full infrastructure context. +The server exposes this as `beaconmcp://infrastructure` -- a readable resource that provides Assistant with the full infrastructure context. ### MCP Prompt: Infrastructure Overview @@ -254,4 +254,4 @@ Reference: `/docs/prompt-engineering-guide.md` -- sections 4.1 through 4.6. - Proxmox built-in firewall management (can be added later) - Backup management (can be added later via Proxmox Backup Server API) - User/permission management on Proxmox -- Automated alerting/monitoring (this is a tool for Claude, not a monitoring stack) +- Automated alerting/monitoring (this is a tool for Assistant, not a monitoring stack) diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 1e0e317..951f258 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -83,7 +83,7 @@ def main(): auth_sub = auth_parser.add_subparsers(dest="auth_command") create_parser = auth_sub.add_parser("create", help="Create a new client") - create_parser.add_argument("--name", required=True, help="Client name (e.g. 'Claude Web', 'My iPhone')") + create_parser.add_argument("--name", required=True, help="Client name (e.g. 'Assistant Web', 'My iPhone')") create_parser.add_argument("--clients-file", type=Path, default=None, help="Path to clients.json") list_parser = auth_sub.add_parser("list", help="List all clients") @@ -312,7 +312,7 @@ async def oauth_metadata(request: Request) -> Response: async def protected_resource_metadata(request: Request) -> Response: # RFC 9728 - required by the MCP 2025-06-18 spec so that clients - # (Claude Web in particular) can discover which authorization server + # (Assistant Web in particular) can discover which authorization server # protects the /mcp resource. We act as our own authorization server. issuer = _issuer(request) return JSONResponse({ diff --git a/src/beaconmcp/auth.py b/src/beaconmcp/auth.py index da02413..96d89f6 100644 --- a/src/beaconmcp/auth.py +++ b/src/beaconmcp/auth.py @@ -3,7 +3,7 @@ Supports two grants on top of a pre-provisioned client store: - ``client_credentials`` for non-interactive clients (scripts, server-to-server) - ``authorization_code`` with mandatory PKCE (S256) for browser-based clients - such as Claude Web / mobile connectors + such as Assistant Web / mobile connectors Dynamic client registration (RFC 7591) is available through a narrow, opt-in path: the dashboard mints a single-use bootstrap URL that lets a @@ -79,9 +79,9 @@ def revoke_current_token() -> bool: # Add a new client's origin here BEFORE flipping # ``allow_dynamic_registration`` on for it, not after. TRUSTED_REDIRECT_PREFIXES: tuple[str, ...] = ( - # Anthropic / Claude - "https://claude.ai/", - "https://claude.com/", + # Anthropic / Assistant + "https://assistant.ai/", + "https://assistant.com/", # OpenAI / ChatGPT + Codex + platform "https://chatgpt.com/", "https://chat.openai.com/", diff --git a/src/beaconmcp/dashboard/__init__.py b/src/beaconmcp/dashboard/__init__.py index 3ad02d1..194aa96 100644 --- a/src/beaconmcp/dashboard/__init__.py +++ b/src/beaconmcp/dashboard/__init__.py @@ -4,7 +4,7 @@ MCP endpoint. It is always-on unless explicitly disabled via ``BEACONMCP_DASHBOARD_ENABLED=false`` — the Tokens API page stays useful for users who only want to wire external MCP clients (Gemini web, -ChatGPT, Claude Desktop). The integrated chat panel is gated by +ChatGPT, Assistant Desktop). The integrated chat panel is gated by ``GEMINI_API_KEY`` on top of that (see :func:`has_chat`). """ diff --git a/src/beaconmcp/dashboard/chat.py b/src/beaconmcp/dashboard/chat.py index e2726d1..e468976 100644 --- a/src/beaconmcp/dashboard/chat.py +++ b/src/beaconmcp/dashboard/chat.py @@ -880,7 +880,7 @@ async def title(self, *, model: str, user_text: str) -> str | None: _MAX_TOOL_ROUNDS = 50 # Keep MCP tool orchestration conversational: one tool at a time lets -# the model add a short sentence between calls (Copilot/Claude style). +# the model add a short sentence between calls (Copilot/Assistant style). _MAX_FUNCTION_CALLS_PER_ROUND = 1 diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index f4226fd..a9020c4 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -1800,7 +1800,7 @@ details.cors > summary svg { color: var(--accent); } #tab-bearer:checked ~ .tab-panel[data-panel="bearer"] { display: block; } .view-by-platform input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; } -#p-claude:checked ~ .inner-tabs label[for="p-claude"], +#p-assistant:checked ~ .inner-tabs label[for="p-assistant"], #p-chatgpt:checked ~ .inner-tabs label[for="p-chatgpt"], #p-gemini:checked ~ .inner-tabs label[for="p-gemini"], #p-mistral:checked ~ .inner-tabs label[for="p-mistral"], @@ -1812,7 +1812,7 @@ details.cors > summary svg { color: var(--accent); } border-bottom-color: var(--accent); font-weight: 600; } -#p-claude:checked ~ .tab-panel[data-panel="claude"], +#p-assistant:checked ~ .tab-panel[data-panel="assistant"], #p-chatgpt:checked ~ .tab-panel[data-panel="chatgpt"], #p-gemini:checked ~ .tab-panel[data-panel="gemini"], #p-mistral:checked ~ .tab-panel[data-panel="mistral"], @@ -1868,14 +1868,14 @@ details.cors > summary svg { color: var(--accent); } .tab-panel input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; } .sub-panel { display: none; animation: fade 200ms var(--ease-out); } -/* Claude variants */ +/* Assistant variants */ #cv-web:checked ~ .sub-tabs label[for="cv-web"], #cv-desktop:checked ~ .sub-tabs label[for="cv-desktop"] { background: var(--bg-elev); color: var(--fg); box-shadow: 0 1px 1px rgba(0,0,0,0.05); } -#cv-web:checked ~ .sub-panel[data-sub="claude-web"], -#cv-desktop:checked ~ .sub-panel[data-sub="claude-desktop"] { display: block; } +#cv-web:checked ~ .sub-panel[data-sub="assistant-web"], +#cv-desktop:checked ~ .sub-panel[data-sub="assistant-desktop"] { display: block; } /* ChatGPT variants */ #gv-web:checked ~ .sub-tabs label[for="gv-web"], diff --git a/src/beaconmcp/dashboard/templates/chat.html b/src/beaconmcp/dashboard/templates/chat.html index 5dab659..f23a511 100644 --- a/src/beaconmcp/dashboard/templates/chat.html +++ b/src/beaconmcp/dashboard/templates/chat.html @@ -60,7 +60,7 @@ diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index 9777d8f..8ad6e61 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -49,7 +49,7 @@

    API access

    A browser calling a different host has to pass a CORS preflight. Web UIs - (Claude, ChatGPT, Le Chat, + (Assistant, ChatGPT, Le Chat, Perplexity, Gemini Web) all do this before they can reach /mcp. If the origin isn't listed, every request fails silently in the browser console. @@ -57,13 +57,13 @@

    API access

    Add the origins you plan to use to beaconmcp.yaml:

    server:
       allowed_origins:
    -    - https://claude.ai
    +    - https://assistant.ai
         - https://chatgpt.com
         - https://chat.mistral.ai
         - https://www.perplexity.ai
         - https://gemini.google.com

    - Desktop apps and CLI clients (Claude Desktop, Gemini CLI, Cursor, VS Code, Mistral Vibe, + Desktop apps and CLI clients (Assistant Desktop, Gemini CLI, Cursor, VS Code, Mistral Vibe, OpenCode) don't run in a browser and don't need an entry here.

    @@ -115,7 +115,7 @@

    OAuth 2.1 with a pre-registered client

    - Claude + Assistant Codex Le Chat Gemini CLI @@ -127,7 +127,7 @@

    OAuth 2.1 with a pre-registered client

    1 · Provision credentials on the server

    One client per install so you can revoke granularly:

    -
    beaconmcp auth create --name "Claude iPhone"
    +
    beaconmcp auth create --name "Assistant iPhone"

    The CLI prints a client_id, a client_secret, and a TOTP QR code (scan it immediately — the secret is shown once).

    2 · Paste credentials into the client
    @@ -270,7 +270,7 @@

    Static bearer tokens

    {# VIEW: BY PLATFORM #} {# ============================================================ #}
    - + {% if dcr_enabled %}{% endif %} @@ -279,7 +279,7 @@

    Static bearer tokens

    - {# ---- Claude ---- #} -
    + {# ---- Assistant ---- #} +
    -

    Claude

    +

    Assistant

    OAuth 2.1

    - Claude uses OAuth with a user-provided client. Same CLI command creates credentials + Assistant uses OAuth with a user-provided client. Same CLI command creates credentials for every surface — what changes is where you paste them.

    - - + + -
    +
      -
    1. On the server:
      beaconmcp auth create --name "Claude Web"
    2. -
    3. claude.ai or iOS/Android app → Settings → Integrations → Add custom connector.
    4. +
    5. On the server:
      beaconmcp auth create --name "Assistant Web"
    6. +
    7. assistant.ai or iOS/Android app → Settings → Integrations → Add custom connector.
    8. URL: {{ mcp_url }}. Paste client_id / client_secret.
    9. -
    10. Type your TOTP on the authorization page when Claude redirects you.
    11. +
    12. Type your TOTP on the authorization page when Assistant redirects you.
    -
    -

    Claude Desktop loads MCP servers from a local JSON config.

    -
    // ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)
    -// %APPDATA%\Claude\claude_desktop_config.json (Windows)
    +          
    +

    Assistant Desktop loads MCP servers from a local JSON config.

    +
    // ~/Library/Application Support/Assistant/assistant_desktop_config.json (macOS)
    +// %APPDATA%\Assistant\assistant_desktop_config.json (Windows)
     {
       "mcpServers": {
         "beaconmcp": {
    diff --git a/src/beaconmcp/server.py b/src/beaconmcp/server.py
    index 34e94b5..09dac80 100644
    --- a/src/beaconmcp/server.py
    +++ b/src/beaconmcp/server.py
    @@ -45,7 +45,7 @@ def _csv_env(name: str, default: list[str]) -> list[str]:
     )
     _allowed_origins = config.server.allowed_origins or _csv_env(
         "BEACONMCP_ALLOWED_ORIGINS",
    -    ["https://claude.ai", "https://chat.openai.com", "https://gemini.google.com"],
    +    ["https://assistant.ai", "https://chat.openai.com", "https://gemini.google.com"],
     )
     
     
    diff --git a/src/beaconmcp/wizard.py b/src/beaconmcp/wizard.py
    index b2275ee..37937f2 100644
    --- a/src/beaconmcp/wizard.py
    +++ b/src/beaconmcp/wizard.py
    @@ -128,7 +128,7 @@ class ServerDraft:
         allowed_hosts: list[str] = field(default_factory=lambda: ["127.0.0.1:*", "localhost:*", "[::1]:*"])
         allowed_origins: list[str] = field(
             default_factory=lambda: [
    -            "https://claude.ai",
    +            "https://assistant.ai",
                 "https://chatgpt.com",
                 "https://chat.mistral.ai",
                 "https://gemini.google.com",
    diff --git a/tests/test_trusted_redirect.py b/tests/test_trusted_redirect.py
    index bec01d8..98a883e 100644
    --- a/tests/test_trusted_redirect.py
    +++ b/tests/test_trusted_redirect.py
    @@ -2,7 +2,7 @@
     
     The allowlist gates every redirect_uri that reaches :class:`/oauth/authorize`
     or the DCR endpoint. If this check ever misfires — false-negative
    -breaking Claude; false-positive enabling an attacker's callback — the
    +breaking Assistant; false-positive enabling an attacker's callback — the
     whole OAuth surface is at risk. Guard it with explicit cases.
     """
     
    @@ -24,9 +24,9 @@
     @pytest.mark.parametrize(
         "uri",
         [
    -        # Claude
    -        "https://claude.ai/api/organizations/xyz/mcp/callback",
    -        "https://claude.com/some/path",
    +        # Assistant
    +        "https://assistant.ai/api/organizations/xyz/mcp/callback",
    +        "https://assistant.com/some/path",
             # ChatGPT family (consumer + enterprise + Codex)
             "https://chatgpt.com/connector_platform_oauth/callback",
             "https://chat.openai.com/oauth/callback",
    @@ -69,14 +69,14 @@ def test_trusted_origins_accepted(uri: str) -> None:
             "https://evil.example.com/cb",
             "https://attacker.xyz/callback",
             # Typo-squats of real origins
    -        "https://claude-ai.com/cb",          # hyphenated fake
    +        "https://assistant-ai.com/cb",          # hyphenated fake
             "https://chat.mistral.ai.evil.com/cb",  # subdomain confusion
             "https://chatgpt.co/cb",                # TLD typo
             # Valid-looking but not-whitelisted Google domains
             "https://mail.google.com/oauth/cb",
             "https://accounts.google.com/oauth/cb",
             # Non-HTTP(S) schemes we don't trust
    -        "ftp://claude.ai/cb",
    +        "ftp://assistant.ai/cb",
             "file:///etc/passwd",
             "javascript:alert(1)",
             "data:text/html,
    +
    +
     
     
    -
    +
    BeaconMCP
    -

    Authorize access

    -

    Enter the 6-digit code from your authenticator to grant access to {html.escape(client_name)}.

    - {banner} -
    - - Client: {html.escape(normalized["client_id"])} -
    -
    + +
    +

    Authorize access

    +

    Enter the 6-digit code from your authenticator to grant access to {html.escape(client_name)}.

    + {banner} +
    + + Client: {html.escape(client_id)} +
    + + {hidden} -
    - - - - - - +
    + + + + + + +
    + + + + + +
    + +
    + +

    Two-factor confirmed

    +

    You're about to grant {html.escape(client_name)} access to this BeaconMCP server.

    + + + +
    +
    +
    Access expires
    +
    {_fmt(access_expires_at)}
    +
    +
    +
    This approval expires
    +
    {_fmt(ticket_expires_at)}
    +
    +
    +

    The client will have to go through two-factor again once its access expires.

    + + - - - + + +
    + + +
    +
    - + """ return HTMLResponse(page) + def _authorize_redirect(normalized: dict[str, str]) -> Response: + """Mint the authorization code and bounce back to the OAuth client.""" + redirect_uri = normalized["redirect_uri"] + code = code_store.issue( + normalized["client_id"], + redirect_uri, + normalized["code_challenge"], + normalized["code_challenge_method"], + ) + query = {"code": code} + if normalized["state"]: + query["state"] = normalized["state"] + parsed = urlparse(redirect_uri) + sep = "&" if parsed.query else "?" + location = f"{redirect_uri}{sep}{urlencode(query)}" + return Response(status_code=302, headers={"Location": location}) + + def _authorize_approved( + request: Request, normalized: dict[str, str], *, as_json: bool = False, + ) -> Response: + """Second factor cleared: hand the operator the confirmation screen. + + ``as_json`` forces the JSON shape for callers that are JSON-only by + construction (the passkey endpoints), independently of whether the + client remembered to send the mode header. + """ + ticket, ticket_expires_at = _issue_pending(normalized) + access_expires_at = time.time() + TokenStore.TOKEN_TTL + if as_json or _wants_json_authorize(request): + return JSONResponse({ + "ok": True, + "ticket": ticket, + "ticket_expires_at": ticket_expires_at, + "access_expires_at": access_expires_at, + "passkeys_enabled": _authorize_passkeys_enabled( + request, normalized["client_id"], + ), + }) + return _authorize_page( + normalized, + request=request, + ticket=ticket, + ticket_expires_at=ticket_expires_at, + access_expires_at=access_expires_at, + ) + + def _authorize_failed( + request: Request, + normalized: dict[str, str], + message: str, + *, + status: int = 401, + locked: bool = False, + ) -> Response: + if _wants_json_authorize(request): + return JSONResponse( + {"ok": False, "error": message, "locked": locked}, + status_code=status, + ) + return _authorize_page( + normalized, request=request, error=message, locked=locked, + ) + async def oauth_authorize_get(request: Request) -> Response: normalized, err = _validate_authorize_params(dict(request.query_params)) if err is not None: return err - return _render_authorize_form( - normalized, locked=totp_locked(normalized["client_id"]) + return _authorize_page( + normalized, + request=request, + locked=totp_locked(normalized["client_id"]), ) async def oauth_authorize_post(request: Request) -> Response: @@ -927,7 +1588,11 @@ async def oauth_authorize_post(request: Request) -> Response: client_id = normalized["client_id"] if totp_locked(client_id): - return _render_authorize_form(normalized, locked=True) + return _authorize_failed( + request, normalized, + "Too many attempts. Try again in 5 minutes.", + status=429, locked=True, + ) code_totp = body.get("totp", "") totp_result = client_store.check_totp(client_id, code_totp) @@ -941,9 +1606,9 @@ async def oauth_authorize_post(request: Request) -> Response: reason=totp_result.value, ip=client_ip(request, tuple(config.server.trusted_proxies)), ) - return _render_authorize_form( - normalized, - error=( + return _authorize_failed( + request, normalized, + ( TOTP_REPLAY_MESSAGE if totp_result is TotpResult.REPLAY else "Incorrect code. Check that your device clock is in sync." @@ -952,24 +1617,195 @@ async def oauth_authorize_post(request: Request) -> Response: ) totp_record_success(client_id) audit.emit( - "auth.authorize.ok", client_id=client_id, + "auth.authorize.2fa", client_id=client_id, ip=client_ip(request, tuple(config.server.trusted_proxies)), ) + return _authorize_approved(request, normalized) - redirect_uri = normalized["redirect_uri"] - code = code_store.issue( - client_id, - redirect_uri, - normalized["code_challenge"], - normalized["code_challenge_method"], + async def oauth_authorize_finalize(request: Request) -> Response: + """Turn an approved ticket into an authorization code and redirect.""" + form = await request.form() + ticket_raw = form.get("ticket", "") + ticket = ticket_raw.strip() if isinstance(ticket_raw, str) else "" + normalized = _consume_pending(ticket) if ticket else None + if normalized is None: + return JSONResponse( + { + "error": "invalid_request", + "error_description": ( + "This approval expired or was already used. " + "Start the authorization again." + ), + }, + status_code=400, + ) + audit.emit( + "auth.authorize.ok", client_id=normalized["client_id"], + ip=client_ip(request, tuple(config.server.trusted_proxies)), + ) + return _authorize_redirect(normalized) + + # --- passkeys on the authorize page ---------------------------------- + + def _authorize_passkey_guard(request: Request) -> Response | None: + """Rate-limit + availability gate shared by the passkey endpoints.""" + if passkey_service is None or not passkey_service.available: + return JSONResponse( + {"ok": False, "error": "Passkeys are not available here."}, + status_code=503, + ) + ip = client_ip(request, tuple(config.server.trusted_proxies)) + if not _login_limiter.check(ip): + retry = _login_limiter.retry_after(ip) + return JSONResponse( + { + "ok": False, + "error": f"Too many attempts. Retry in {retry}s.", + }, + status_code=429, + ) + return None + + async def _authorize_json_body(request: Request) -> dict: + try: + data = await request.json() + except Exception: # noqa: BLE001 + return {} + return data if isinstance(data, dict) else {} + + async def oauth_passkey_options(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + client_id = str(body.get("client_id") or "").strip() + if not client_id or not client_store.exists(client_id): + return JSONResponse( + {"ok": False, "error": "Unknown client."}, status_code=400, + ) + if totp_locked(client_id): + return JSONResponse( + {"ok": False, "error": "Too many attempts. Try again in 5 minutes."}, + status_code=429, + ) + try: + options, state = passkey_service.authentication_options( + request, client_id=_passkey_owner(client_id), + ) + except passkeys_mod.PasskeyError as exc: + return JSONResponse( + {"ok": False, "error": str(exc)}, status_code=400, + ) + return JSONResponse({"ok": True, "options": options, "state": state}) + + async def oauth_passkey_verify(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + raw_params = body.get("params") + if not isinstance(raw_params, dict): + return JSONResponse( + {"ok": False, "error": "invalid_request"}, status_code=400, + ) + params = {k: v for k, v in raw_params.items() if isinstance(v, str)} + normalized, err = _validate_authorize_params(params) + if err is not None: + return JSONResponse( + {"ok": False, "error": "Invalid authorization request."}, + status_code=400, + ) + client_id = normalized["client_id"] + if totp_locked(client_id): + return JSONResponse( + {"ok": False, "error": "Too many attempts. Try again in 5 minutes."}, + status_code=429, + ) + credential = body.get("credential") + state = str(body.get("state") or "") + if not isinstance(credential, dict) or not state: + return JSONResponse( + {"ok": False, "error": "Malformed passkey response."}, + status_code=400, + ) + try: + record = passkey_service.verify_authentication( + request, state=state, credential=credential, + ) + except passkeys_mod.PasskeyError as exc: + audit.emit( + "auth.authorize.fail", client_id=client_id, + reason=f"passkey:{exc}", + ip=client_ip(request, tuple(config.server.trusted_proxies)), + ) + return JSONResponse({"ok": False, "error": str(exc)}, status_code=401) + # The challenge was minted for the seed owner; make sure the + # credential that answered it really guards *this* client. + if record.client_id != _passkey_owner(client_id): + return JSONResponse( + {"ok": False, "error": "This passkey belongs to another client."}, + status_code=401, + ) + totp_record_success(client_id) + audit.emit( + "auth.authorize.2fa", client_id=client_id, via="passkey", + ip=client_ip(request, tuple(config.server.trusted_proxies)), + ) + return _authorize_approved(request, normalized, as_json=True) + + async def oauth_passkey_register_options(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + normalized = _peek_pending(str(body.get("ticket") or "")) + if normalized is None: + return JSONResponse( + {"ok": False, "error": "This approval expired. Start again."}, + status_code=400, + ) + owner = _passkey_owner(normalized["client_id"]) + try: + options, state = passkey_service.registration_options( + request, + client_id=owner, + client_name=client_store.get_name(owner) or owner, + ) + except passkeys_mod.PasskeyError as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) + return JSONResponse({"ok": True, "options": options, "state": state}) + + async def oauth_passkey_register_verify(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + normalized = _peek_pending(str(body.get("ticket") or "")) + if normalized is None: + return JSONResponse( + {"ok": False, "error": "This approval expired. Start again."}, + status_code=400, + ) + credential = body.get("credential") + state = str(body.get("state") or "") + if not isinstance(credential, dict) or not state: + return JSONResponse( + {"ok": False, "error": "Malformed passkey response."}, + status_code=400, + ) + try: + record = passkey_service.verify_registration( + request, state=state, credential=credential, + ) + except passkeys_mod.PasskeyError as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) + audit.emit( + "auth.passkey.register.ok", + client_id=record.client_id, label=record.label, + ) + return JSONResponse( + {"ok": True, "passkey": record.to_json()}, status_code=201, ) - query = {"code": code} - if normalized["state"]: - query["state"] = normalized["state"] - parsed = urlparse(redirect_uri) - sep = "&" if parsed.query else "?" - location = f"{redirect_uri}{sep}{urlencode(query)}" - return Response(status_code=302, headers={"Location": location}) async def oauth_token(request: Request) -> Response: ip = client_ip(request, tuple(config.server.trusted_proxies)) @@ -1085,6 +1921,7 @@ async def auth_middleware(request: Request, call_next): "/metrics", "/oauth/token", "/oauth/authorize", + "/oauth/authorize/finalize", "/oauth/register", "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", @@ -1101,6 +1938,14 @@ async def auth_middleware(request: Request, call_next): ): return await call_next(request) + # Passkey ceremonies for /oauth/authorize. They are part of signing + # in, so they cannot require a bearer -- there isn't one yet. Their + # own guards are the WebAuthn signature, the TOTP lockout and the + # per-IP login limiter; enrolment additionally demands an approval + # ticket, which is only handed out after a second factor passed. + if path.startswith("/oauth/passkey/"): + return await call_next(request) + # Dashboard routes have their own session-based auth. if path.startswith("/app/"): return await call_next(request) @@ -1149,10 +1994,8 @@ async def auth_middleware(request: Request, call_next): # OAuth Dynamic Client Registration plumbing. Only engaged when both # the feature flag is set AND the dashboard is enabled (the slug store - # lives in the dashboard's SQLite db). - from . import dashboard as _dashboard_mod + # lives in the dashboard's SQLite db, opened above). dyn_reg_store = None - shared_database = None if config.server.allow_dynamic_registration: if not _dashboard_mod.is_enabled(): print( @@ -1162,9 +2005,8 @@ async def auth_middleware(request: Request, call_next): file=sys.stderr, ) sys.exit(1) - from .dashboard.db import Database as _Database from .dashboard.dyn_reg import DynamicSlugStore as _DynamicSlugStore - shared_database = _Database() + assert shared_database is not None # required path: creation raised otherwise dyn_reg_store = _DynamicSlugStore(shared_database) async def dcr_protected_resource_metadata(request: Request) -> Response: @@ -1337,6 +2179,7 @@ async def lifespan(_app): dyn_reg=dyn_reg_store, shared_database=shared_database, login_limiter=_login_limiter, trusted_proxies=tuple(config.server.trusted_proxies), + passkey_service=passkey_service, ) app = Starlette( @@ -1348,6 +2191,20 @@ async def lifespan(_app): Route("/.well-known/oauth-protected-resource/mcp", protected_resource_metadata), Route("/oauth/authorize", oauth_authorize_get, methods=["GET"]), Route("/oauth/authorize", oauth_authorize_post, methods=["POST"]), + Route( + "/oauth/authorize/finalize", + oauth_authorize_finalize, methods=["POST"], + ), + Route("/oauth/passkey/options", oauth_passkey_options, methods=["POST"]), + Route("/oauth/passkey/verify", oauth_passkey_verify, methods=["POST"]), + Route( + "/oauth/passkey/register/options", + oauth_passkey_register_options, methods=["POST"], + ), + Route( + "/oauth/passkey/register/verify", + oauth_passkey_register_verify, methods=["POST"], + ), Route("/oauth/token", oauth_token, methods=["POST"]), Route("/oauth/register", oauth_register, methods=["POST"]), *dcr_routes, @@ -1373,6 +2230,13 @@ async def lifespan(_app): print(f"Dashboard: http://{host}:{port}/app/login (chat: {chat_status})") else: print("Dashboard: disabled (BEACONMCP_DASHBOARD_ENABLED=false)") + # The login pages hide their passkey buttons when this is off, with no + # visible explanation -- so say it here, where the operator can see it. + passkey_reason = passkey_service.unavailable_reason + if passkey_reason is None: + print("Passkeys: enabled (also needs HTTPS, or a loopback host)") + else: + print(f"Passkeys: disabled - {passkey_reason}") if n_clients == 0: print("\nNo clients registered. Create one with: beaconmcp auth create --name 'My Client'") uvicorn.run(app, host=host, port=port, log_level="info") @@ -1381,7 +2245,8 @@ async def lifespan(_app): def _build_dashboard_routes(client_store, token_store, totp_locked, totp_record_failure, totp_record_success, *, dyn_reg=None, shared_database=None, - login_limiter=None, trusted_proxies=()): + login_limiter=None, trusted_proxies=(), + passkey_service=None): """Build dashboard routes if enabled. Returns [] when disabled.""" from . import dashboard if not dashboard.is_enabled(): @@ -1454,6 +2319,7 @@ def _float_env(name: str, default: float) -> float: dyn_reg=dyn_reg, login_limiter=login_limiter, trusted_proxies=trusted_proxies, + passkeys=passkey_service, ) return build_dashboard_routes(deps) diff --git a/src/beaconmcp/dashboard/app.py b/src/beaconmcp/dashboard/app.py index 97ffec2..34b8f54 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -51,6 +51,7 @@ ) from .db import Database from .dyn_reg import DynamicSlugStore, SLUG_TTL_SECONDS +from .passkeys import PasskeyError, PasskeyService, is_secure_context from .session import SESSION_TTL_SECONDS, Session, SessionStore from .usage import UsageMeter, UsageStore @@ -98,6 +99,10 @@ class DashboardDeps: # tests/embedding paths can skip the limiter entirely. login_limiter: object | None = None trusted_proxies: tuple[str, ...] = () + # WebAuthn ceremonies for the passkey login/registration flow. When + # unset (or reporting ``available is False``), the login page hides + # every passkey affordance and the TOTP path is the only way in. + passkeys: PasskeyService | None = None # --------------------------------------------------------------------------- @@ -121,8 +126,17 @@ def _set_session_cookie(response: Response, session_id: str, secure: bool) -> No ) -def _set_csrf_cookie(response: Response, secure: bool) -> str: - token = csrf.issue_token() +def _set_csrf_cookie( + response: Response, secure: bool, token: str | None = None, +) -> str: + """Issue (or re-issue) the double-submit CSRF cookie. + + ``token`` lets a caller mint the value first so it can also hand it to + the client out-of-band -- the JSON login does that, because rotating + the cookie without telling the page would leave its in-DOM token stale + and every follow-up fetch would 403. + """ + token = token or csrf.issue_token() response.set_cookie( csrf.CSRF_COOKIE, token, @@ -243,6 +257,21 @@ def _totp_error(result: TotpResult, invalid_message: str) -> str: return TOTP_REPLAY_MESSAGE if result is TotpResult.REPLAY else invalid_message +def _wants_json(request: Request) -> bool: + """True when the caller is the login page's fetch() rather than a form POST. + + The login form still works with JavaScript disabled -- it posts and gets + a redirect, exactly as before. The enhanced flow (shimmer, then the + post-2FA screen offering to enrol a passkey) needs to stay on the page, + so it opts in explicitly with this header. + """ + return request.headers.get("x-beaconmcp-mode", "") == "json" + + +def _passkeys_ready(deps: DashboardDeps) -> bool: + return deps.passkeys is not None and deps.passkeys.available + + # --------------------------------------------------------------------------- # Route factories # --------------------------------------------------------------------------- @@ -278,8 +307,72 @@ async def login_get(request: Request) -> Response: next=request.query_params.get("next", ""), banner=None, locked=False, + passkeys_enabled=_passkeys_ready(deps), + secure_context=is_secure_context(request), ) + def _login_success_payload( + request: Request, session: Session, *, next_url: str, bearer_ttl: int, + ) -> dict[str, Any]: + """Data the post-2FA screen renders: expiry + passkey affordances.""" + return { + "ok": True, + "next": next_url, + "client_id": session.client_id, + "client_name": ( + deps.client_store.get_name(session.client_id) # type: ignore[attr-defined] + or session.client_id + ), + # Two different clocks, and users care about both: the MCP bearer + # is what actually stops working (a 2FA re-prompt at /app/refresh), + # while the signed-in cookie is what saves them the full login. + "bearer_expires_at": time.time() + bearer_ttl, + "session_expires_at": session.expires_at, + "passkeys_enabled": _passkeys_ready(deps), + "secure_context": is_secure_context(request), + "passkey_count": ( + deps.passkeys.store.count_for_client(session.client_id) + if _passkeys_ready(deps) else 0 + ), + } + + def _start_session( + request: Request, + *, + client_id: str, + client_secret: str, + next_url: str, + as_json: bool | None = None, + ) -> tuple[Session, int, Response]: + """Mint the MCP bearer + session row and attach the cookies. + + Shared by the TOTP login and the passkey login so the two paths + cannot drift on cookie flags or bearer lifetime. + """ + bearer, ttl = deps.token_store.issue(client_id) # type: ignore[attr-defined] + ua = request.headers.get("user-agent", "")[:200] + session = deps.session_store.create( + client_id=client_id, + client_secret=client_secret, + mcp_bearer=bearer, + bearer_ttl_seconds=ttl, + user_agent=ua, + ) + secure = _is_secure(request) + new_csrf = csrf.issue_token() + if as_json if as_json is not None else _wants_json(request): + payload = _login_success_payload( + request, session, next_url=next_url, bearer_ttl=ttl, + ) + payload["csrf_token"] = new_csrf + response: Response = JSONResponse(payload) + else: + response = RedirectResponse(next_url, status_code=303) + _set_session_cookie(response, session.session_id, secure) + _set_csrf_cookie(response, secure, new_csrf) + _apply_security_headers(response) + return session, ttl, response + async def login_post(request: Request) -> Response: if not await csrf.verify(request): return JSONResponse({"error": "csrf"}, status_code=403) @@ -292,14 +385,23 @@ async def login_post(request: Request) -> Response: ip = _client_ip(request, deps.trusted_proxies) if not limiter.check(ip): # type: ignore[attr-defined] retry = limiter.retry_after(ip) # type: ignore[attr-defined] + message = ( + f"Too many attempts from this address. Retry in {retry}s." + ) + if _wants_json(request): + return _json( + {"ok": False, "error": message, "locked": True}, status=429, + ) return _render( "login.html", request, client_id="", next="", - banner=f"Too many attempts from this address. Retry in {retry}s.", + banner=message, locked=True, status_code=429, + passkeys_enabled=_passkeys_ready(deps), + secure_context=is_secure_context(request), ) form = await request.form() @@ -316,6 +418,10 @@ def _v(name: str) -> str: next_url = _default_landing() def _fail(message: str, *, status: int = 400, locked: bool = False) -> Response: + if _wants_json(request): + return _json( + {"ok": False, "error": message, "locked": locked}, status=status, + ) return _render( "login.html", request, @@ -324,6 +430,8 @@ def _fail(message: str, *, status: int = 400, locked: bool = False) -> Response: banner=message, locked=locked, status_code=status, + passkeys_enabled=_passkeys_ready(deps), + secure_context=is_secure_context(request), ) if not client_id or not client_secret or not totp: @@ -356,21 +464,12 @@ def _fail(message: str, *, status: int = 400, locked: bool = False) -> Response: deps.totp_record_success(client_id) audit.emit("dashboard.login.ok", client_id=client_id) - bearer, ttl = deps.token_store.issue(client_id) # type: ignore[attr-defined] - ua = request.headers.get("user-agent", "")[:200] - session = deps.session_store.create( + _, _, response = _start_session( + request, client_id=client_id, client_secret=client_secret, - mcp_bearer=bearer, - bearer_ttl_seconds=ttl, - user_agent=ua, + next_url=next_url, ) - - secure = _is_secure(request) - response = RedirectResponse(next_url, status_code=303) - _set_session_cookie(response, session.session_id, secure) - _set_csrf_cookie(response, secure) - _apply_security_headers(response) return response async def refresh_get(request: Request) -> Response: @@ -772,6 +871,231 @@ async def tokens_revoke(request: Request) -> Response: ) return RedirectResponse("/app/tokens", status_code=303) + # --- Passkeys (WebAuthn) --------------------------------------------- + + def _passkey_service() -> PasskeyService | Response: + if not _passkeys_ready(deps): + return _json({"error": "passkeys_unavailable"}, status=503) + assert deps.passkeys is not None + return deps.passkeys + + async def api_passkeys_list(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + service = _passkey_service() + if isinstance(service, Response): + return service + return _json({ + "passkeys": [ + p.to_json() for p in service.store.list_for_client(session.client_id) + ], + }) + + async def api_passkeys_register_options(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + service = _passkey_service() + if isinstance(service, Response): + return service + try: + options, state = service.registration_options( + request, + client_id=session.client_id, + client_name=( + deps.client_store.get_name(session.client_id) # type: ignore[attr-defined] + or session.client_id + ), + session_id=session.session_id, + ) + except PasskeyError as exc: + return _json({"error": str(exc)}, status=400) + return _json({"options": options, "state": state}) + + async def api_passkeys_register_verify(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + credential = body.get("credential") + state = str(body.get("state") or "") + if not isinstance(credential, dict) or not state: + return _json({"error": "invalid_request"}, status=400) + label_raw = body.get("label") + try: + record = service.verify_registration( + request, + state=state, + credential=credential, + label=label_raw if isinstance(label_raw, str) else None, + session_id=session.session_id, + ) + except PasskeyError as exc: + audit.emit( + "dashboard.passkey.register.fail", + client_id=session.client_id, reason=str(exc), + ) + return _json({"error": str(exc)}, status=400) + audit.emit( + "dashboard.passkey.register.ok", + client_id=session.client_id, label=record.label, + ) + return _json({"ok": True, "passkey": record.to_json()}, status=201) + + async def api_passkeys_delete(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + credential_id = str(body.get("credential_id") or "").strip() + if not credential_id: + return _json({"error": "invalid_request"}, status=400) + if not service.store.delete(credential_id, session.client_id): + return _json({"error": "not_found"}, status=404) + audit.emit("dashboard.passkey.revoke", client_id=session.client_id) + return _json({"ok": True}) + + async def passkeys_remove(request: Request) -> Response: + """Form-POST twin of the JSON delete, for the tokens page. + + The tokens page is deliberately JS-free for destructive actions, so + removing a passkey posts a plain form and lands back on the page. + """ + session = _load_session(request, deps) + if not session: + return RedirectResponse("/app/login", status_code=302) + if not _bearer_live(deps, session): + return RedirectResponse("/app/refresh?next=/app/tokens", status_code=302) + if not await csrf.verify(request): + return JSONResponse({"error": "csrf"}, status_code=403) + if _passkeys_ready(deps): + form = await request.form() + raw = form.get("credential_id", "") + credential_id = (raw if isinstance(raw, str) else "").strip() + assert deps.passkeys is not None + if credential_id and deps.passkeys.store.delete( + credential_id, session.client_id, + ): + audit.emit("dashboard.passkey.revoke", client_id=session.client_id) + return RedirectResponse("/app/tokens", status_code=303) + + def _passkey_login_rate_limited(request: Request) -> Response | None: + """Apply the login limiter to the unauthenticated passkey endpoints. + + These take a client_id/client_secret pair, so they are just as much + a credential-stuffing surface as /app/login itself. + """ + limiter = deps.login_limiter + if limiter is None: + return None + from ..ratelimit import client_ip as _client_ip # local: avoid import cycle + ip = _client_ip(request, deps.trusted_proxies) + if limiter.check(ip): # type: ignore[attr-defined] + return None + retry = limiter.retry_after(ip) # type: ignore[attr-defined] + return _json( + { + "ok": False, + "error": f"Too many attempts from this address. Retry in {retry}s.", + }, + status=429, + ) + + async def api_passkeys_auth_options(request: Request) -> Response: + """Start a passkey sign-in. Requires the client credentials first. + + The passkey replaces the TOTP factor only: without a valid + client_id/client_secret we will not even disclose whether a client + has credentials registered. + """ + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + limited = _passkey_login_rate_limited(request) + if limited is not None: + return limited + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + client_id = str(body.get("client_id") or "").strip() + client_secret = str(body.get("client_secret") or "") + if not client_id or not client_secret: + return _json({"ok": False, "error": "Missing credentials."}, status=400) + if not deps.client_store.verify(client_id, client_secret): # type: ignore[attr-defined] + return _json({"ok": False, "error": "Invalid credentials."}, status=401) + try: + options, state = service.authentication_options( + request, client_id=client_id, + ) + except PasskeyError as exc: + return _json({"ok": False, "error": str(exc)}, status=400) + return _json({"ok": True, "options": options, "state": state}) + + async def api_passkeys_auth_verify(request: Request) -> Response: + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + limited = _passkey_login_rate_limited(request) + if limited is not None: + return limited + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + client_id = str(body.get("client_id") or "").strip() + client_secret = str(body.get("client_secret") or "") + state = str(body.get("state") or "") + credential = body.get("credential") + if not client_id or not client_secret or not state: + return _json({"ok": False, "error": "Missing credentials."}, status=400) + if not isinstance(credential, dict): + return _json({"ok": False, "error": "Malformed passkey response."}, status=400) + # Re-verify the secret: the options call proved it once, but the + # state token alone must never be enough to mint a session. + if not deps.client_store.verify(client_id, client_secret): # type: ignore[attr-defined] + return _json({"ok": False, "error": "Invalid credentials."}, status=401) + + try: + record = service.verify_authentication( + request, state=state, credential=credential, + ) + except PasskeyError as exc: + audit.emit( + "dashboard.login.fail", client_id=client_id, + reason=f"passkey:{exc}", + ) + return _json({"ok": False, "error": str(exc)}, status=401) + if record.client_id != client_id: + return _json({"ok": False, "error": "Invalid credentials."}, status=401) + + next_url = str(body.get("next") or "").strip() + if not next_url.startswith("/app/"): + next_url = _default_landing() + # A passkey sign-in is a clean second factor: clear any TOTP + # lockout the same way a correct code would. + deps.totp_record_success(client_id) + audit.emit("dashboard.login.ok", client_id=client_id, via="passkey") + _, _, response = _start_session( + request, + client_id=client_id, + client_secret=client_secret, + next_url=next_url, + as_json=True, + ) + return response + async def chat_get(request: Request) -> Response: session = _load_session(request, deps) if not session: @@ -1132,6 +1456,25 @@ async def _confirm(req: ToolConfirmRequired) -> bool: Route("/app/api/chat/stream", api_chat_stream, methods=["POST"]), Route("/app/api/chat/confirm", api_chat_confirm, methods=["POST"]), Route("/app/api/usage", api_usage, methods=["GET"]), + Route("/app/api/passkeys", api_passkeys_list, methods=["GET"]), + Route( + "/app/api/passkeys/register/options", + api_passkeys_register_options, methods=["POST"], + ), + Route( + "/app/api/passkeys/register/verify", + api_passkeys_register_verify, methods=["POST"], + ), + Route("/app/api/passkeys/delete", api_passkeys_delete, methods=["POST"]), + Route("/app/passkeys/remove", passkeys_remove, methods=["POST"]), + Route( + "/app/api/passkeys/auth/options", + api_passkeys_auth_options, methods=["POST"], + ), + Route( + "/app/api/passkeys/auth/verify", + api_passkeys_auth_verify, methods=["POST"], + ), Route("/", index, methods=["GET"]), Mount( "/app/static", @@ -1251,6 +1594,21 @@ def _expires_label(expires_at: float) -> str: locked=deps.totp_locked(session.client_id), chat_enabled=deps.engine is not None, dcr_enabled=deps.dyn_reg is not None, + passkeys_enabled=_passkeys_ready(deps), + passkeys=( + [ + { + "credential_id": p.credential_id, + "label": p.label, + "created_human": _human_time(p.created_at), + "last_used_human": ( + _human_time(p.last_used_at) if p.last_used_at else None + ), + } + for p in deps.passkeys.store.list_for_client(session.client_id) + ] + if _passkeys_ready(deps) else [] + ), ) diff --git a/src/beaconmcp/dashboard/db.py b/src/beaconmcp/dashboard/db.py index 7607758..dd80fd7 100644 --- a/src/beaconmcp/dashboard/db.py +++ b/src/beaconmcp/dashboard/db.py @@ -19,7 +19,7 @@ def db_path() -> Path: return Path(override) if override else DEFAULT_DB_PATH -_LATEST_VERSION = 4 +_LATEST_VERSION = 5 def _migrate(conn: sqlite3.Connection) -> None: @@ -147,6 +147,30 @@ def _migrate(conn: sqlite3.Connection) -> None: """ ) + if version < 5: + # WebAuthn credentials, one row per registered passkey. The public + # key and signature counter are all we need to verify assertions; + # nothing here is secret (a public key is public), but the table + # still lives in the 0600 dashboard database because knowing which + # credentials a client owns is useful to an attacker. + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS passkeys ( + credential_id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + transports TEXT, + label TEXT NOT NULL, + created_at REAL NOT NULL, + last_used_at REAL, + backed_up INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_passkeys_client + ON passkeys(client_id, created_at DESC); + """ + ) + conn.execute(f"PRAGMA user_version = {_LATEST_VERSION}") conn.commit() diff --git a/src/beaconmcp/dashboard/passkeys.py b/src/beaconmcp/dashboard/passkeys.py new file mode 100644 index 0000000..d80370f --- /dev/null +++ b/src/beaconmcp/dashboard/passkeys.py @@ -0,0 +1,598 @@ +"""Passkey (WebAuthn) support for the BeaconMCP login pages. + +A passkey stands in for the TOTP factor, never for the client secret. The +flow on both login pages stays two-factor: + +1. ``client_id`` + ``client_secret`` -- something you know / have stored. +2. Either a 6-digit TOTP code **or** a WebAuthn assertion -- something you + have on your device. + +That ordering is deliberate. The dashboard session record encrypts the +client secret so it can re-mint MCP bearers later, so a purely +"usernameless" passkey login could not build a usable session anyway. +Keeping the secret as the first factor also means a stolen passkey alone +is worthless. + +Credentials live in the dashboard SQLite database (``passkeys`` table); +challenges live in memory with a short TTL, since a challenge that +survives a restart is a replay window, not a feature. + +The ``webauthn`` package (py_webauthn) is an optional dependency: when it +is missing, :class:`PasskeyService` reports ``available is False`` and +every login page simply hides its passkey affordances instead of erroring. +""" + +from __future__ import annotations + +import base64 +import json +import secrets +import threading +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # pragma: no cover - typing only + from starlette.requests import Request + + from .db import Database + +try: + import webauthn as _webauthn + from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + AuthenticatorTransport, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + UserVerificationRequirement, + ) +except ImportError: # pragma: no cover - exercised only on slim installs + _webauthn = None # type: ignore[assignment] + AuthenticatorSelectionCriteria = None # type: ignore[assignment,misc] + AuthenticatorTransport = None # type: ignore[assignment,misc] + PublicKeyCredentialDescriptor = None # type: ignore[assignment,misc] + ResidentKeyRequirement = None # type: ignore[assignment,misc] + UserVerificationRequirement = None # type: ignore[assignment,misc] + + +#: How long a registration/authentication challenge stays usable. The spec +#: suggests a couple of minutes; the browser prompt itself times out at 60 s. +CHALLENGE_TTL_SECONDS = 180 + +#: Relying-party display name shown in the OS passkey prompt. +RP_NAME = "BeaconMCP" + +#: Cap on credentials per client. High enough for phone + laptop + a +#: hardware key or two, low enough that the allowCredentials list stays sane. +MAX_PASSKEYS_PER_CLIENT = 10 + + +class PasskeyError(Exception): + """User-facing passkey failure. The message is safe to render.""" + + +def webauthn_installed() -> bool: + """True when the optional ``webauthn`` dependency is importable.""" + return _webauthn is not None + + +def b64url_encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def b64url_decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(value + padding) + + +# --------------------------------------------------------------------------- +# Relying-party identity, derived from the live request +# --------------------------------------------------------------------------- + +def _forwarded_host(request: "Request") -> str: + host = request.headers.get("x-forwarded-host") or request.headers.get("host") + if not host: + host = request.url.netloc or "localhost" + # A proxy chain may append entries; the first one is the client-facing host. + return host.split(",")[0].strip() + + +def rp_id_for(request: "Request") -> str: + """Return the WebAuthn RP ID (effective domain, no scheme, no port). + + Derived from the request rather than configured, so a deployment that + moves behind a new hostname keeps working without a config edit. The + trade-off is that credentials are scoped to the hostname they were + registered under -- which is exactly the WebAuthn security model. + """ + host = _forwarded_host(request) + if host.startswith("["): # IPv6 literal: [::1]:8420 + closing = host.find("]") + if closing != -1: + return host[1:closing] + return host.rsplit(":", 1)[0] if ":" in host else host + + +def origin_for(request: "Request") -> str: + """Return the origin the browser will put in ``clientDataJSON``.""" + scheme = request.headers.get("x-forwarded-proto") or request.url.scheme + scheme = scheme.split(",")[0].strip() + return f"{scheme}://{_forwarded_host(request)}" + + +def is_secure_context(request: "Request") -> bool: + """True when the browser will expose ``navigator.credentials``. + + WebAuthn is gated on a secure context: HTTPS, or a loopback host. A + plain-HTTP LAN deployment silently has no passkey API at all, so the + UI needs to know before it offers the button. + """ + scheme = ( + request.headers.get("x-forwarded-proto") or request.url.scheme + ).split(",")[0].strip() + if scheme == "https": + return True + return rp_id_for(request) in ("localhost", "127.0.0.1", "::1") + + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +@dataclass +class PasskeyRecord: + credential_id: str # base64url + client_id: str + public_key: bytes + sign_count: int + transports: list[str] + label: str + created_at: float + last_used_at: float | None + backed_up: bool + + def to_json(self) -> dict[str, Any]: + return { + "credential_id": self.credential_id, + "label": self.label, + "created_at": self.created_at, + "last_used_at": self.last_used_at, + "backed_up": self.backed_up, + "transports": self.transports, + } + + +class PasskeyStore: + """Thin wrapper around the ``passkeys`` table.""" + + def __init__(self, database: "Database") -> None: + self._db = database + + @staticmethod + def _row_to_record(row: Any) -> PasskeyRecord: + try: + transports = json.loads(row["transports"] or "[]") + except (TypeError, ValueError): + transports = [] + return PasskeyRecord( + credential_id=row["credential_id"], + client_id=row["client_id"], + public_key=row["public_key"], + sign_count=row["sign_count"], + transports=[t for t in transports if isinstance(t, str)], + label=row["label"], + created_at=row["created_at"], + last_used_at=row["last_used_at"], + backed_up=bool(row["backed_up"]), + ) + + def add( + self, + *, + credential_id: str, + client_id: str, + public_key: bytes, + sign_count: int, + transports: list[str], + label: str, + backed_up: bool, + ) -> PasskeyRecord: + now = time.time() + self._db.conn().execute( + """ + INSERT INTO passkeys ( + credential_id, client_id, public_key, sign_count, + transports, label, created_at, last_used_at, backed_up + ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?) + """, + ( + credential_id, + client_id, + public_key, + sign_count, + json.dumps(transports), + label, + now, + 1 if backed_up else 0, + ), + ) + return PasskeyRecord( + credential_id=credential_id, + client_id=client_id, + public_key=public_key, + sign_count=sign_count, + transports=transports, + label=label, + created_at=now, + last_used_at=None, + backed_up=backed_up, + ) + + def list_for_client(self, client_id: str) -> list[PasskeyRecord]: + rows = self._db.conn().execute( + """ + SELECT credential_id, client_id, public_key, sign_count, transports, + label, created_at, last_used_at, backed_up + FROM passkeys WHERE client_id = ? + ORDER BY created_at DESC + """, + (client_id,), + ).fetchall() + return [self._row_to_record(r) for r in rows] + + def get(self, credential_id: str) -> PasskeyRecord | None: + row = self._db.conn().execute( + """ + SELECT credential_id, client_id, public_key, sign_count, transports, + label, created_at, last_used_at, backed_up + FROM passkeys WHERE credential_id = ? + """, + (credential_id,), + ).fetchone() + return self._row_to_record(row) if row is not None else None + + def count_for_client(self, client_id: str) -> int: + row = self._db.conn().execute( + "SELECT COUNT(*) AS n FROM passkeys WHERE client_id = ?", + (client_id,), + ).fetchone() + return int(row["n"]) if row is not None else 0 + + def touch(self, credential_id: str, sign_count: int) -> None: + self._db.conn().execute( + "UPDATE passkeys SET sign_count = ?, last_used_at = ? " + "WHERE credential_id = ?", + (sign_count, time.time(), credential_id), + ) + + def delete(self, credential_id: str, client_id: str) -> bool: + cur = self._db.conn().execute( + "DELETE FROM passkeys WHERE credential_id = ? AND client_id = ?", + (credential_id, client_id), + ) + return bool(cur.rowcount) + + def delete_all_for_client(self, client_id: str) -> int: + cur = self._db.conn().execute( + "DELETE FROM passkeys WHERE client_id = ?", (client_id,) + ) + return cur.rowcount or 0 + + +# --------------------------------------------------------------------------- +# Challenges +# --------------------------------------------------------------------------- + +@dataclass +class _Challenge: + purpose: str # "register" | "authenticate" + challenge: bytes + client_id: str + session_id: str | None + expires_at: float + + +class ChallengeStore: + """In-memory, single-use challenge store. + + Deliberately not persisted: a challenge that outlives the process is a + replay window. A restart mid-ceremony just makes the user click again. + """ + + def __init__(self, ttl_seconds: int = CHALLENGE_TTL_SECONDS) -> None: + self._ttl = ttl_seconds + self._items: dict[str, _Challenge] = {} + self._lock = threading.Lock() + + def issue( + self, + *, + purpose: str, + challenge: bytes, + client_id: str, + session_id: str | None = None, + ) -> str: + state = secrets.token_urlsafe(24) + with self._lock: + self._prune_locked() + self._items[state] = _Challenge( + purpose=purpose, + challenge=challenge, + client_id=client_id, + session_id=session_id, + expires_at=time.time() + self._ttl, + ) + return state + + def consume(self, state: str, purpose: str) -> _Challenge | None: + with self._lock: + self._prune_locked() + item = self._items.pop(state, None) + if item is None or item.purpose != purpose: + return None + if time.time() > item.expires_at: + return None + return item + + def _prune_locked(self) -> None: + now = time.time() + for key in [k for k, v in self._items.items() if now > v.expires_at]: + del self._items[key] + + +# --------------------------------------------------------------------------- +# Ceremony orchestration +# --------------------------------------------------------------------------- + +def default_label(user_agent: str) -> str: + """Best-effort human label for a freshly registered credential. + + Users never get asked to name their passkey during login -- one more + field in the middle of a sign-in is friction for no security gain -- so + we guess from the User-Agent and let them recognise it later. + """ + ua = (user_agent or "").lower() + if "iphone" in ua or "ipad" in ua or "ios" in ua: + return "iPhone / iPad" + if "android" in ua: + return "Android" + if "mac os" in ua or "macintosh" in ua: + return "Mac" + if "windows" in ua: + return "Windows" + if "linux" in ua: + return "Linux" + return "Passkey" + + +def _transport_descriptors(transports: list[str]) -> list[Any] | None: + """Map stored transport strings onto the library enum, dropping unknowns.""" + if not transports or AuthenticatorTransport is None: + return None + out = [] + for t in transports: + try: + out.append(AuthenticatorTransport(t)) + except ValueError: + continue + return out or None + + +class PasskeyService: + """Generates and verifies WebAuthn ceremonies against a :class:`PasskeyStore`.""" + + def __init__(self, store: PasskeyStore | None) -> None: + self._store = store + self._challenges = ChallengeStore() + + @property + def available(self) -> bool: + """True when passkeys can actually be used (library + storage present).""" + return _webauthn is not None and self._store is not None + + @property + def unavailable_reason(self) -> str | None: + """Why passkeys are off, or ``None`` when they work. + + The login pages hide their passkey affordances silently -- there is + nothing an anonymous visitor could do about it anyway -- so the + operator needs to be able to ask the question somewhere. This backs + the startup banner and ``beaconmcp doctor``. + """ + if _webauthn is None: + return ( + "the 'webauthn' Python package is missing " + "(pip install 'webauthn>=2,<4', or reinstall BeaconMCP)" + ) + if self._store is None: + return "no writable dashboard database to store credentials in" + return None + + @property + def store(self) -> PasskeyStore: + if self._store is None: + raise PasskeyError("Passkeys are not available on this deployment.") + return self._store + + def _require(self) -> None: + if _webauthn is None: + raise PasskeyError( + "Passkey support requires the 'webauthn' package. " + "Reinstall BeaconMCP to pull it in." + ) + if self._store is None: + raise PasskeyError("Passkeys are not available on this deployment.") + + # --- registration ---------------------------------------------------- + + def registration_options( + self, + request: "Request", + *, + client_id: str, + client_name: str, + session_id: str | None = None, + ) -> tuple[dict[str, Any], str]: + """Return ``(options_dict, state)`` for ``navigator.credentials.create``.""" + self._require() + existing = self.store.list_for_client(client_id) + if len(existing) >= MAX_PASSKEYS_PER_CLIENT: + raise PasskeyError( + f"This client already has {MAX_PASSKEYS_PER_CLIENT} passkeys. " + "Remove one before adding another." + ) + options = _webauthn.generate_registration_options( + rp_id=rp_id_for(request), + rp_name=RP_NAME, + user_id=client_id.encode("utf-8"), + user_name=client_id, + user_display_name=client_name or client_id, + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.PREFERRED, + ), + # Stops the authenticator offering to overwrite a passkey the + # client already registered on this device. + exclude_credentials=[ + PublicKeyCredentialDescriptor( + id=b64url_decode(p.credential_id), + transports=_transport_descriptors(p.transports), + ) + for p in existing + ], + ) + state = self._challenges.issue( + purpose="register", + challenge=options.challenge, + client_id=client_id, + session_id=session_id, + ) + return json.loads(_webauthn.options_to_json(options)), state + + def verify_registration( + self, + request: "Request", + *, + state: str, + credential: dict[str, Any], + label: str | None = None, + session_id: str | None = None, + ) -> PasskeyRecord: + """Verify a ``navigator.credentials.create`` result and persist it.""" + self._require() + pending = self._challenges.consume(state, "register") + if pending is None: + raise PasskeyError( + "This passkey request expired. Start the registration again." + ) + if pending.session_id is not None and pending.session_id != session_id: + raise PasskeyError("This passkey request belongs to another session.") + + try: + verified = _webauthn.verify_registration_response( + credential=credential, + expected_challenge=pending.challenge, + expected_rp_id=rp_id_for(request), + expected_origin=origin_for(request), + ) + except Exception as exc: # noqa: BLE001 - library raises many subtypes + raise PasskeyError(f"Passkey registration rejected: {exc}") from exc + + credential_id = b64url_encode(verified.credential_id) + if self.store.get(credential_id) is not None: + raise PasskeyError("This passkey is already registered.") + + transports = [] + raw_transports = (credential.get("response") or {}).get("transports") + if isinstance(raw_transports, list): + transports = [t for t in raw_transports if isinstance(t, str)] + + return self.store.add( + credential_id=credential_id, + client_id=pending.client_id, + public_key=verified.credential_public_key, + sign_count=verified.sign_count, + transports=transports, + label=(label or "").strip()[:60] + or default_label(request.headers.get("user-agent", "")), + backed_up=bool(getattr(verified, "credential_backed_up", False)), + ) + + # --- authentication -------------------------------------------------- + + def authentication_options( + self, request: "Request", *, client_id: str, + ) -> tuple[dict[str, Any], str]: + """Return ``(options_dict, state)`` for ``navigator.credentials.get``.""" + self._require() + credentials = self.store.list_for_client(client_id) + if not credentials: + raise PasskeyError( + "No passkey is registered for this client. Sign in with your " + "2FA code once, then add one." + ) + options = _webauthn.generate_authentication_options( + rp_id=rp_id_for(request), + allow_credentials=[ + PublicKeyCredentialDescriptor( + id=b64url_decode(p.credential_id), + transports=_transport_descriptors(p.transports), + ) + for p in credentials + ], + user_verification=UserVerificationRequirement.PREFERRED, + ) + state = self._challenges.issue( + purpose="authenticate", + challenge=options.challenge, + client_id=client_id, + ) + return json.loads(_webauthn.options_to_json(options)), state + + def verify_authentication( + self, request: "Request", *, state: str, credential: dict[str, Any], + ) -> PasskeyRecord: + """Verify a ``navigator.credentials.get`` result. + + Returns the credential that signed the challenge. The caller is + responsible for turning that into a session -- this module never + touches bearers or cookies. + """ + self._require() + pending = self._challenges.consume(state, "authenticate") + if pending is None: + raise PasskeyError( + "This passkey request expired. Try signing in again." + ) + + raw_id = credential.get("id") or credential.get("rawId") + if not isinstance(raw_id, str) or not raw_id: + raise PasskeyError("Malformed passkey response.") + record = self.store.get(raw_id) + if record is None: + raise PasskeyError("Unknown passkey.") + # The challenge was issued for one client; a credential belonging to + # another must never satisfy it, even if both are valid on their own. + if record.client_id != pending.client_id: + raise PasskeyError("This passkey belongs to a different client.") + + try: + verified = _webauthn.verify_authentication_response( + credential=credential, + expected_challenge=pending.challenge, + expected_rp_id=rp_id_for(request), + expected_origin=origin_for(request), + credential_public_key=record.public_key, + credential_current_sign_count=record.sign_count, + ) + except Exception as exc: # noqa: BLE001 - library raises many subtypes + # This also covers the clone signal: py_webauthn refuses any + # assertion whose signature counter did not move forward, as + # long as either side maintains one (platform authenticators + # commonly pin it at 0, and that stays legal). + raise PasskeyError(f"Passkey rejected: {exc}") from exc + + self.store.touch(record.credential_id, verified.new_sign_count) + record.sign_count = verified.new_sign_count + record.last_used_at = time.time() + return record diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index a9020c4..7488464 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -2168,3 +2168,150 @@ a.cta:hover { background: var(--accent-hover); } /* Shared icon sprite placement */ .icon-sprite { position: absolute; width: 0; height: 0; } + +/* =============================================================== + PASSKEYS + POST-2FA SCREEN (login.html) + =============================================================== */ + +/* Shimmer: a highlight sweeping across the button while a request is in + flight. Prefers a real animation over a spinner so the button keeps its + size and the label can carry the status text. */ +.btn-primary.is-loading, +.btn-ghost.is-loading { + position: relative; + overflow: hidden; + cursor: progress; + opacity: 1; +} +.btn-primary.is-loading::after, +.btn-ghost.is-loading::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 100deg, + transparent 20%, + rgba(255, 255, 255, 0.38) 50%, + transparent 80% + ); + transform: translateX(-100%); + animation: shimmer-sweep 1150ms var(--ease-out) infinite; + pointer-events: none; +} +.btn-ghost.is-loading::after { + background: linear-gradient( + 100deg, + transparent 20%, + var(--accent-soft) 50%, + transparent 80% + ); +} +@keyframes shimmer-sweep { + to { transform: translateX(100%); } +} +/* The trailing chevron/check is noise once the button says "Verifying…". */ +.btn-primary.is-loading .btn-icon { opacity: 0; } +@media (prefers-reduced-motion: reduce) { + .btn-primary.is-loading::after, + .btn-ghost.is-loading::after { animation: none; opacity: 0.25; } +} + +.btn-ghost { + width: 100%; + padding: 11px 16px; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: var(--bg-soft); + color: var(--fg); + font-family: var(--font); font-weight: 550; font-size: 14px; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + transition: border-color 160ms var(--ease-out), background 160ms var(--ease-out), + color 160ms var(--ease-out); +} +.btn-ghost:hover:not(:disabled) { + border-color: var(--accent-border); + background: var(--accent-softer); + color: var(--fg); +} +.btn-ghost:disabled { opacity: 0.55; cursor: not-allowed; } +.btn-ghost svg { flex-shrink: 0; } + +.alt-divider { + display: flex; align-items: center; gap: 12px; + margin: 18px 0 14px; + color: var(--fg-faint); + font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; +} +.alt-divider::before, +.alt-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} + +.auth-card .hint { + margin: 10px 0 0; + font-size: 12.5px; + line-height: 1.5; + color: var(--fg-muted); + text-align: center; +} + +.success-mark { + width: 42px; height: 42px; + border-radius: 50%; + display: grid; place-items: center; + margin-bottom: 16px; + color: var(--success); + background: var(--success-soft); + border: 1px solid color-mix(in oklab, var(--success) 35%, var(--border)); + animation: pop-in 320ms var(--ease-out) both; +} +@keyframes pop-in { + from { opacity: 0; transform: scale(0.82); } + to { opacity: 1; transform: scale(1); } +} + +.expiry-card { + margin: 4px 0 0; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-soft); +} +.expiry-row { + display: flex; align-items: baseline; justify-content: space-between; + gap: 12px; + padding: 5px 0; +} +.expiry-row + .expiry-row { border-top: 1px solid var(--border-subtle); } +.expiry-row dt { + font-size: 12.5px; + color: var(--fg-muted); +} +.expiry-row dd { + margin: 0; + font-size: 13px; font-weight: 550; + color: var(--fg); + text-align: right; +} + +#passkey-add-block { margin-top: 18px; } +#step-3 .btn-primary { margin-top: 20px; } +#step-3 .banner { margin-bottom: 14px; } + +/* Registered passkeys list (dashboard "API tokens" page) */ +.passkey-row { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; + padding: 10px 0; +} +.passkey-row + .passkey-row { border-top: 1px solid var(--border-subtle); } +.passkey-row .passkey-meta { min-width: 0; } +.passkey-row .passkey-name { + font-size: 13.5px; font-weight: 550; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.passkey-row .passkey-sub { font-size: 12px; color: var(--fg-muted); } diff --git a/src/beaconmcp/dashboard/static/login.js b/src/beaconmcp/dashboard/static/login.js index b1f2fce..5292230 100644 --- a/src/beaconmcp/dashboard/static/login.js +++ b/src/beaconmcp/dashboard/static/login.js @@ -1,10 +1,22 @@ -// Two-step login: Client ID / Secret → TOTP. -// Submits all three fields together once the 6-digit code is entered. +// Three-step login. +// +// 1. Client ID / Secret +// 2. TOTP code, or a passkey instead +// 3. Signed in: session lifetime, optional passkey enrolment, finish +// +// Step 3 only exists when JavaScript is on: the form still posts normally +// and gets a 303 to the landing page when it isn't, so the whole passkey +// layer is progressive enhancement over a flow that already worked. (function() { + "use strict"; + var form = document.getElementById("login-form"); if (!form) return; + + var card = document.querySelector(".auth-card"); var s1 = document.getElementById("step-1"); var s2 = document.getElementById("step-2"); + var s3 = document.getElementById("step-3"); var toggle = document.getElementById("toggle-pw"); var pw = document.getElementById("client_secret"); var cid = document.getElementById("client_id"); @@ -14,6 +26,112 @@ var verifyBtn = document.getElementById("verify-btn"); var inputs = document.querySelectorAll("#totp-inputs input"); var verifiedLabel = document.getElementById("client-verified"); + var csrfInput = document.getElementById("csrf-token"); + var step2Error = document.getElementById("step-2-error"); + var step3Error = document.getElementById("step-3-error"); + var step3Ok = document.getElementById("step-3-ok"); + var passkeyLoginBlock = document.getElementById("passkey-login-block"); + var passkeyLoginBtn = document.getElementById("passkey-login-btn"); + var passkeyAddBlock = document.getElementById("passkey-add-block"); + var addPasskeyBtn = document.getElementById("add-passkey-btn"); + var finishBtn = document.getElementById("finish-btn"); + + var passkeysEnabled = card && card.dataset.passkeysEnabled === "true"; + var secureContext = card && card.dataset.secureContext === "true"; + var passkeysUsable = passkeysEnabled && secureContext && + window.BeaconPasskeys && window.BeaconPasskeys.supported(); + + var session = null; // payload returned by the successful login + var busy = false; + + if (passkeyLoginBlock && !passkeysUsable) passkeyLoginBlock.hidden = true; + // Server offers passkeys but the browser won't expose the API (insecure + // origin): say so, rather than silently dropping the button. + var passkeyUnsupported = document.getElementById("passkey-unsupported"); + if (passkeyUnsupported) { + passkeyUnsupported.hidden = passkeysUsable || !passkeysEnabled; + } + + // --- small helpers ---------------------------------------------------- + + function csrf() { + return csrfInput ? csrfInput.value : ""; + } + + function postJson(url, body) { + return fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrf(), + "X-BeaconMCP-Mode": "json", + }, + body: JSON.stringify(body || {}), + }).then(function(res) { + return res.json().catch(function() { return {}; }).then(function(data) { + return { ok: res.ok, status: res.status, data: data }; + }); + }); + } + + function showError(el, message) { + if (!el) return; + el.textContent = message; + el.hidden = !message; + } + + function showOk(el, message) { + if (!el) return; + el.textContent = message; + el.hidden = !message; + } + + // Shimmer: the button keeps its width, gains a sweeping highlight and + // swaps its label. Kept as a class so the CSS owns the animation. + function setLoading(btn, loading, label) { + if (!btn) return; + var labelEl = btn.querySelector(".btn-label"); + if (loading) { + if (labelEl && label) { + if (!btn.dataset.idleLabel) btn.dataset.idleLabel = labelEl.textContent; + labelEl.textContent = label; + } + btn.classList.add("is-loading"); + btn.disabled = true; + } else { + if (labelEl && btn.dataset.idleLabel) { + labelEl.textContent = btn.dataset.idleLabel; + delete btn.dataset.idleLabel; + } + btn.classList.remove("is-loading"); + } + } + + function formatDateTime(epochSeconds) { + if (!epochSeconds || !isFinite(epochSeconds)) return "—"; + var d = new Date(epochSeconds * 1000); + var today = new Date(); + var sameDay = d.toDateString() === today.toDateString(); + var time = d.toLocaleTimeString(undefined, { + hour: "2-digit", minute: "2-digit", + }); + if (sameDay) return "today at " + time; + return d.toLocaleDateString(undefined, { + day: "numeric", month: "short", year: "numeric", + }) + " at " + time; + } + + function formatRelative(epochSeconds) { + var secs = epochSeconds - (Date.now() / 1000); + if (secs <= 0) return "expired"; + var hours = secs / 3600; + if (hours < 1) return "in " + Math.max(1, Math.round(secs / 60)) + " min"; + if (hours < 48) return "in " + Math.round(hours) + " h"; + return "in " + Math.round(hours / 24) + " days"; + } + + // --- step 1 -> step 2 ------------------------------------------------- if (toggle) { toggle.addEventListener("click", function() { @@ -24,13 +142,14 @@ function goStep2() { if (!cid.value.trim() || !pw.value) return; if (verifiedLabel) verifiedLabel.textContent = cid.value.trim(); + showError(step2Error, ""); s1.hidden = true; s2.hidden = false; setTimeout(function() { if (inputs[0]) inputs[0].focus(); }, 40); } if (toStep2) toStep2.addEventListener("click", goStep2); - // Allow Enter in step-1 inputs to advance rather than submit. + // Enter in step-1 advances rather than submitting a half-filled form. [cid, pw].forEach(function(el) { if (!el) return; el.addEventListener("keydown", function(e) { @@ -46,15 +165,18 @@ s1.hidden = false; }); + // --- TOTP boxes ------------------------------------------------------- + function collectTotp() { var s = ""; inputs.forEach(function(i) { s += (i.value || "").replace(/\D/g, ""); }); return s; } + function refresh() { var v = collectTotp(); totpHidden.value = v; - verifyBtn.disabled = v.length !== 6; + verifyBtn.disabled = busy || v.length !== 6; } inputs.forEach(function(inp, i) { @@ -70,6 +192,14 @@ refresh(); }); inp.addEventListener("keydown", function(e) { + // Enter validates as soon as the six digits are in — no reaching + // for the mouse after typing the last one. + if (e.key === "Enter") { + e.preventDefault(); + refresh(); + if (!busy && collectTotp().length === 6) submitTotp(); + return; + } if (e.key === "Backspace" && !e.target.value && inputs[i - 1]) { inputs[i - 1].focus(); inputs[i - 1].value = ""; @@ -89,10 +219,201 @@ }); }); + function clearTotp() { + inputs.forEach(function(i) { i.value = ""; i.classList.remove("filled"); }); + totpHidden.value = ""; + refresh(); + if (inputs[0]) inputs[0].focus(); + } + + // --- step 3 ----------------------------------------------------------- + + function showStep3(payload) { + session = payload; + // Signing in rotates the CSRF cookie; without picking the new value up + // every follow-up fetch (passkey enrolment) would 403. + if (payload.csrf_token && csrfInput) csrfInput.value = payload.csrf_token; + s1.hidden = true; + s2.hidden = true; + s3.hidden = false; + + var who = document.getElementById("success-client"); + if (who) who.textContent = payload.client_name || payload.client_id || "—"; + + var bearerEl = document.getElementById("bearer-expiry"); + if (bearerEl) { + bearerEl.textContent = formatDateTime(payload.bearer_expires_at) + + " (" + formatRelative(payload.bearer_expires_at) + ")"; + } + var sessionEl = document.getElementById("session-expiry"); + if (sessionEl) sessionEl.textContent = formatDateTime(payload.session_expires_at); + + if (passkeyAddBlock) { + var canAdd = !!(payload.passkeys_enabled && payload.secure_context && + window.BeaconPasskeys && window.BeaconPasskeys.supported()); + passkeyAddBlock.hidden = !canAdd; + var addNote = document.getElementById("passkey-add-unsupported"); + if (addNote) addNote.hidden = canAdd || !payload.passkeys_enabled; + var hint = document.getElementById("passkey-add-hint"); + if (canAdd && hint && payload.passkey_count > 0) { + hint.textContent = payload.passkey_count === 1 + ? "1 passkey already registered. Add another for a second device." + : payload.passkey_count + " passkeys already registered. " + + "Add another for a second device."; + } + } + if (finishBtn) finishBtn.focus(); + } + + function finish() { + var next = (session && session.next) || "/app/tokens"; + setLoading(finishBtn, true, "Opening…"); + window.location.href = next; + } + + if (finishBtn) finishBtn.addEventListener("click", finish); + + // --- TOTP submit ------------------------------------------------------ + + function submitTotp() { + if (busy) return; + var code = collectTotp(); + if (code.length !== 6) return; + busy = true; + showError(step2Error, ""); + setLoading(verifyBtn, true, "Verifying…"); + + var body = new URLSearchParams(); + body.set("csrf_token", csrf()); + body.set("client_id", cid.value.trim()); + body.set("client_secret", pw.value); + body.set("totp", code); + var nextField = form.querySelector('input[name="next"]'); + if (nextField) body.set("next", nextField.value); + + fetch("/app/login", { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-CSRF-Token": csrf(), + "X-BeaconMCP-Mode": "json", + }, + body: body.toString(), + }).then(function(res) { + return res.json().catch(function() { return {}; }).then(function(data) { + return { ok: res.ok, data: data }; + }); + }).then(function(res) { + busy = false; + setLoading(verifyBtn, false); + if (res.ok && res.data && res.data.ok) { + showStep3(res.data); + return; + } + showError(step2Error, (res.data && res.data.error) || "Sign-in failed."); + clearTotp(); + }).catch(function() { + busy = false; + setLoading(verifyBtn, false); + showError(step2Error, "Network error. Try again."); + refresh(); + }); + } + form.addEventListener("submit", function(e) { + e.preventDefault(); if (totpHidden.value.length !== 6) { - e.preventDefault(); goStep2(); + return; } + submitTotp(); }); + + // --- passkey sign-in (step 2) ---------------------------------------- + + if (passkeyLoginBtn) { + passkeyLoginBtn.addEventListener("click", function() { + if (busy) return; + var clientId = cid.value.trim(); + var secret = pw.value; + if (!clientId || !secret) { + showError(step2Error, "Enter your client credentials first."); + return; + } + busy = true; + showError(step2Error, ""); + setLoading(passkeyLoginBtn, true, "Waiting for your passkey…"); + verifyBtn.disabled = true; + + var nextField = form.querySelector('input[name="next"]'); + postJson("/app/api/passkeys/auth/options", { + client_id: clientId, + client_secret: secret, + }).then(function(res) { + if (!res.ok || !res.data.ok) { + throw new Error(res.data.error || "Passkey sign-in unavailable."); + } + return window.BeaconPasskeys.authenticate(res.data.options) + .then(function(assertion) { + return postJson("/app/api/passkeys/auth/verify", { + client_id: clientId, + client_secret: secret, + state: res.data.state, + credential: assertion, + next: nextField ? nextField.value : "", + }); + }); + }).then(function(res) { + busy = false; + setLoading(passkeyLoginBtn, false); + refresh(); + if (res.ok && res.data && res.data.ok) { + showStep3(res.data); + return; + } + showError(step2Error, (res.data && res.data.error) || "Passkey rejected."); + }).catch(function(err) { + busy = false; + setLoading(passkeyLoginBtn, false); + refresh(); + showError(step2Error, window.BeaconPasskeys.describeError(err)); + }); + }); + } + + // --- passkey enrolment (step 3) -------------------------------------- + + if (addPasskeyBtn) { + addPasskeyBtn.addEventListener("click", function() { + showError(step3Error, ""); + showOk(step3Ok, ""); + setLoading(addPasskeyBtn, true, "Follow your device prompt…"); + + postJson("/app/api/passkeys/register/options", {}).then(function(res) { + if (!res.ok) throw new Error(res.data.error || "Could not start registration."); + return window.BeaconPasskeys.register(res.data.options) + .then(function(attestation) { + return postJson("/app/api/passkeys/register/verify", { + state: res.data.state, + credential: attestation, + }); + }); + }).then(function(res) { + setLoading(addPasskeyBtn, false); + if (res.ok && res.data && res.data.ok) { + showOk(step3Ok, "Passkey “" + res.data.passkey.label + + "” registered. Next time you can skip the 2FA code."); + addPasskeyBtn.disabled = true; + var hint = document.getElementById("passkey-add-hint"); + if (hint) hint.hidden = true; + return; + } + showError(step3Error, (res.data && res.data.error) || "Registration failed."); + }).catch(function(err) { + setLoading(addPasskeyBtn, false); + showError(step3Error, window.BeaconPasskeys.describeError(err)); + }); + }); + } })(); diff --git a/src/beaconmcp/dashboard/static/webauthn.js b/src/beaconmcp/dashboard/static/webauthn.js new file mode 100644 index 0000000..1a079ba --- /dev/null +++ b/src/beaconmcp/dashboard/static/webauthn.js @@ -0,0 +1,119 @@ +// Minimal WebAuthn plumbing shared by the dashboard auth pages. +// +// The server speaks base64url (that's what py_webauthn's options_to_json +// emits and what its verifiers parse); the browser API speaks ArrayBuffer. +// Everything here is that translation, plus the two ceremony wrappers. +window.BeaconPasskeys = (function() { + "use strict"; + + function supported() { + return !!(window.PublicKeyCredential && navigator.credentials && + navigator.credentials.create && navigator.credentials.get); + } + + function b64urlToBuf(value) { + var s = String(value).replace(/-/g, "+").replace(/_/g, "/"); + while (s.length % 4) s += "="; + var bin = window.atob(s); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; + } + + function bufToB64url(buf) { + var bytes = new Uint8Array(buf); + var bin = ""; + for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return window.btoa(bin) + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function decodeDescriptors(list) { + return (list || []).map(function(d) { + var out = { type: d.type || "public-key", id: b64urlToBuf(d.id) }; + if (d.transports && d.transports.length) out.transports = d.transports; + return out; + }); + } + + // navigator.credentials.create() — returns a JSON-safe attestation. + function register(options) { + var publicKey = Object.assign({}, options); + publicKey.challenge = b64urlToBuf(options.challenge); + publicKey.user = Object.assign({}, options.user, { + id: b64urlToBuf(options.user.id), + }); + publicKey.excludeCredentials = decodeDescriptors(options.excludeCredentials); + return navigator.credentials.create({ publicKey: publicKey }).then(function(cred) { + if (!cred) throw new Error("No credential was created."); + var response = cred.response; + var out = { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults + ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64url(response.clientDataJSON), + attestationObject: bufToB64url(response.attestationObject), + }, + }; + if (cred.authenticatorAttachment) { + out.authenticatorAttachment = cred.authenticatorAttachment; + } + if (response.getTransports) { + try { out.response.transports = response.getTransports(); } catch (e) {} + } + return out; + }); + } + + // navigator.credentials.get() — returns a JSON-safe assertion. + function authenticate(options) { + var publicKey = Object.assign({}, options); + publicKey.challenge = b64urlToBuf(options.challenge); + publicKey.allowCredentials = decodeDescriptors(options.allowCredentials); + return navigator.credentials.get({ publicKey: publicKey }).then(function(cred) { + if (!cred) throw new Error("No passkey was selected."); + var response = cred.response; + return { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults + ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64url(response.clientDataJSON), + authenticatorData: bufToB64url(response.authenticatorData), + signature: bufToB64url(response.signature), + userHandle: response.userHandle ? bufToB64url(response.userHandle) : null, + }, + }; + }); + } + + // The browser throws opaque DOMExceptions; turn the ones users actually + // hit into something actionable and let the rest through as-is. + function describeError(err) { + if (!err) return "Passkey request failed."; + if (err.name === "NotAllowedError") { + return "Passkey prompt cancelled or timed out."; + } + if (err.name === "InvalidStateError") { + return "This device already has a passkey registered for this client."; + } + if (err.name === "SecurityError") { + return "Passkeys need a secure origin (HTTPS or localhost)."; + } + return err.message || String(err); + } + + return { + supported: supported, + register: register, + authenticate: authenticate, + describeError: describeError, + b64urlToBuf: b64urlToBuf, + bufToB64url: bufToB64url, + }; +})(); diff --git a/src/beaconmcp/dashboard/templates/login.html b/src/beaconmcp/dashboard/templates/login.html index 0aa7c94..41684f2 100644 --- a/src/beaconmcp/dashboard/templates/login.html +++ b/src/beaconmcp/dashboard/templates/login.html @@ -2,13 +2,15 @@ {% block title %}Sign in · BeaconMCP{% endblock %} {% block body_class %}auth-page{% endblock %} {% block body %} -
    +
    BeaconMCP
    - + {% if next %}{% endif %} {# Step 1 — Client ID + Client Secret #} @@ -58,7 +60,7 @@

    Sign in

    - {# Step 2 — TOTP #} + {# Step 2 — TOTP, or a passkey instead #}
    + +
    @@ -84,12 +88,70 @@

    Two-factor

    + + {% if passkeys_enabled %} +
    +
    or
    + +

    Skip the 6-digit code with a passkey registered on this device.

    +
    + + {% endif %} + + {# Step 3 — signed in: session lifetime, passkey enrolment, finish #} +
    + {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index 121fe70..032a97a 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -259,6 +259,39 @@

    Static bearer tokens

    + {% if passkeys_enabled %} +
    +
    Passkeys
    + {% if passkeys %} +
    + {% for p in passkeys %} +
    +
    +
    {{ p.label }}
    +
    + Added {{ p.created_human }}{% if p.last_used_human %} · last used {{ p.last_used_human }}{% else %} · never used{% endif %} +
    +
    +
    + + + +
    +
    + {% endfor %} +
    + {% else %} +

    + No passkey registered. Add one right after your next sign-in to skip + the 2FA code on this device. +

    + {% endif %} +
    + {% endif %} +

    Concrete setup per client is in the By platform tab.

    diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index 65091af..5acd498 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -704,8 +704,10 @@ def test_migration_v1_to_v2_renames_gemini_models(tmp_path): assert msg["model"] == "gemini-3-flash-preview" # user_version reflects the migration (latest schema version). + from beaconmcp.dashboard.db import _LATEST_VERSION + ver = db.conn().execute("PRAGMA user_version").fetchone()[0] - assert ver == 4 + assert ver == _LATEST_VERSION def test_short_ciphertext_decryption_returns_none(store): diff --git a/tests/test_passkeys.py b/tests/test_passkeys.py new file mode 100644 index 0000000..06279ac --- /dev/null +++ b/tests/test_passkeys.py @@ -0,0 +1,808 @@ +"""Passkey (WebAuthn) tests. + +Drives the real ceremonies end to end against a software authenticator +built here from ``cryptography`` primitives: a fake that only returned +canned dicts would prove nothing, since every interesting failure mode +(wrong origin, replayed challenge, foreign credential) lives inside the +signature verification. + +Run with:: + + pytest tests/test_passkeys.py -v +""" + +from __future__ import annotations + +import hashlib +import json +import os +import struct +import sys +import time +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +pytest.importorskip("webauthn", reason="passkey support is an optional extra") +cbor2 = pytest.importorskip("cbor2") + +from cryptography.hazmat.primitives import hashes # noqa: E402 +from cryptography.hazmat.primitives.asymmetric import ec # noqa: E402 +from cryptography.hazmat.primitives.asymmetric.utils import ( # noqa: E402 + decode_dss_signature, + encode_dss_signature, +) + +from beaconmcp.auth import TotpResult # noqa: E402 +from beaconmcp.dashboard.app import ( # noqa: E402 + DashboardDeps, + SESSION_COOKIE, + build_dashboard_routes, +) +from beaconmcp.dashboard.csrf import CSRF_COOKIE # noqa: E402 +from beaconmcp.dashboard.db import Database # noqa: E402 +from beaconmcp.dashboard.passkeys import ( # noqa: E402 + ChallengeStore, + PasskeyError, + PasskeyService, + PasskeyStore, + b64url_decode, + b64url_encode, + default_label, +) +from beaconmcp.dashboard.session import SessionStore # noqa: E402 + + +ORIGIN = "https://beacon.example" +RP_ID = "beacon.example" + + +# --------------------------------------------------------------------------- +# Software authenticator +# --------------------------------------------------------------------------- + +class SoftwareAuthenticator: + """A minimal ES256 authenticator: enough to produce valid ceremonies.""" + + AAGUID = b"\x00" * 16 + + def __init__(self) -> None: + self.key = ec.generate_private_key(ec.SECP256R1()) + self.credential_id = os.urandom(32) + self.sign_count = 0 + + # --- helpers --------------------------------------------------------- + + def _cose_key(self) -> bytes: + numbers = self.key.public_key().public_numbers() + return cbor2.dumps({ + 1: 2, # kty: EC2 + 3: -7, # alg: ES256 + -1: 1, # crv: P-256 + -2: numbers.x.to_bytes(32, "big"), + -3: numbers.y.to_bytes(32, "big"), + }) + + @staticmethod + def _client_data(kind: str, challenge: str, origin: str) -> bytes: + return json.dumps( + { + "type": kind, + "challenge": challenge, + "origin": origin, + "crossOrigin": False, + }, + separators=(",", ":"), + ).encode("utf-8") + + def _auth_data(self, rp_id: str, flags: int, attested: bool) -> bytes: + data = hashlib.sha256(rp_id.encode("utf-8")).digest() + data += bytes([flags]) + data += struct.pack(">I", self.sign_count) + if attested: + key = self._cose_key() + data += self.AAGUID + data += struct.pack(">H", len(self.credential_id)) + data += self.credential_id + data += key + return data + + # --- ceremonies ------------------------------------------------------ + + def create(self, options: dict, *, origin: str = ORIGIN, rp_id: str = RP_ID) -> dict: + client_data = self._client_data( + "webauthn.create", options["challenge"], origin, + ) + # UP | UV | AT + auth_data = self._auth_data(rp_id, 0x01 | 0x04 | 0x40, attested=True) + attestation = cbor2.dumps({ + "fmt": "none", "attStmt": {}, "authData": auth_data, + }) + return { + "id": b64url_encode(self.credential_id), + "rawId": b64url_encode(self.credential_id), + "type": "public-key", + "clientExtensionResults": {}, + "response": { + "clientDataJSON": b64url_encode(client_data), + "attestationObject": b64url_encode(attestation), + "transports": ["internal"], + }, + } + + def get(self, options: dict, *, origin: str = ORIGIN, rp_id: str = RP_ID) -> dict: + self.sign_count += 1 + client_data = self._client_data("webauthn.get", options["challenge"], origin) + auth_data = self._auth_data(rp_id, 0x01 | 0x04, attested=False) + payload = auth_data + hashlib.sha256(client_data).digest() + raw = self.key.sign(payload, ec.ECDSA(hashes.SHA256())) + # Re-encode so the DER is canonical whatever the backend produced. + r, s = decode_dss_signature(raw) + signature = encode_dss_signature(r, s) + return { + "id": b64url_encode(self.credential_id), + "rawId": b64url_encode(self.credential_id), + "type": "public-key", + "clientExtensionResults": {}, + "response": { + "clientDataJSON": b64url_encode(client_data), + "authenticatorData": b64url_encode(auth_data), + "signature": b64url_encode(signature), + "userHandle": None, + }, + } + + +class FakeRequest: + """Just the surface :mod:`beaconmcp.dashboard.passkeys` reads.""" + + def __init__(self, host: str = RP_ID, scheme: str = "https") -> None: + self.headers = {"host": host, "x-forwarded-proto": scheme} + + class _URL: + netloc = host + + self.url = _URL() + self.url.scheme = scheme # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def store(tmp_path) -> PasskeyStore: + return PasskeyStore(Database(tmp_path / "dashboard.db")) + + +@pytest.fixture() +def service(store) -> PasskeyService: + return PasskeyService(store) + + +@pytest.fixture() +def enrolled(service): + """A client with one registered passkey, plus its authenticator.""" + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test Client", + ) + record = service.verify_registration( + request, state=state, credential=auth.create(options), + ) + return auth, record + + +# --------------------------------------------------------------------------- +# RP identity +# --------------------------------------------------------------------------- + +def test_rp_id_strips_port_and_scheme(): + from beaconmcp.dashboard.passkeys import origin_for, rp_id_for + + req = FakeRequest(host="beacon.example:8420") + assert rp_id_for(req) == "beacon.example" + assert origin_for(req) == "https://beacon.example:8420" + + +def test_rp_id_handles_ipv6_literal(): + from beaconmcp.dashboard.passkeys import rp_id_for + + assert rp_id_for(FakeRequest(host="[::1]:8420")) == "::1" + + +def test_secure_context_requires_https_or_loopback(): + from beaconmcp.dashboard.passkeys import is_secure_context + + assert is_secure_context(FakeRequest(host="beacon.example", scheme="https")) + assert is_secure_context(FakeRequest(host="localhost:8420", scheme="http")) + assert not is_secure_context(FakeRequest(host="192.168.1.5:8420", scheme="http")) + + +def test_forwarded_host_wins_over_host(): + from beaconmcp.dashboard.passkeys import rp_id_for + + req = FakeRequest(host="internal:8420") + req.headers["x-forwarded-host"] = "public.example" + assert rp_id_for(req) == "public.example" + + +def test_default_label_from_user_agent(): + assert default_label("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)") == "iPhone / iPad" + assert default_label("Mozilla/5.0 (Windows NT 10.0)") == "Windows" + assert default_label("") == "Passkey" + + +# --------------------------------------------------------------------------- +# Challenge store +# --------------------------------------------------------------------------- + +def test_challenge_is_single_use(): + challenges = ChallengeStore() + state = challenges.issue( + purpose="authenticate", challenge=b"abc", client_id="c1", + ) + assert challenges.consume(state, "authenticate") is not None + assert challenges.consume(state, "authenticate") is None + + +def test_challenge_purpose_must_match(): + challenges = ChallengeStore() + state = challenges.issue(purpose="register", challenge=b"abc", client_id="c1") + assert challenges.consume(state, "authenticate") is None + + +def test_challenge_expires(): + challenges = ChallengeStore(ttl_seconds=-1) + state = challenges.issue(purpose="register", challenge=b"abc", client_id="c1") + assert challenges.consume(state, "register") is None + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +def test_registration_round_trip(service, store): + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test Client", + ) + assert options["rp"]["id"] == RP_ID + assert b64url_decode(options["user"]["id"]) == b"beaconmcp_test" + + record = service.verify_registration( + request, state=state, credential=auth.create(options), label="My laptop", + ) + assert record.client_id == "beaconmcp_test" + assert record.label == "My laptop" + assert record.transports == ["internal"] + assert store.count_for_client("beaconmcp_test") == 1 + + +def test_registration_rejects_wrong_origin(service): + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test Client", + ) + with pytest.raises(PasskeyError): + service.verify_registration( + request, + state=state, + credential=auth.create(options, origin="https://evil.example"), + ) + + +def test_registration_state_bound_to_session(service): + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test", + session_id="session-a", + ) + with pytest.raises(PasskeyError, match="another session"): + service.verify_registration( + request, state=state, credential=auth.create(options), + session_id="session-b", + ) + + +def test_registration_excludes_known_credentials(service, enrolled): + _, record = enrolled + options, _ = service.registration_options( + FakeRequest(), client_id="beaconmcp_test", client_name="Test", + ) + assert [c["id"] for c in options["excludeCredentials"]] == [record.credential_id] + + +def test_registration_label_defaults_from_user_agent(service): + request = FakeRequest() + request.headers["user-agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)" + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test", + ) + record = service.verify_registration( + request, state=state, credential=auth.create(options), + ) + assert record.label == "Mac" + + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- + +def test_authentication_round_trip(service, enrolled, store): + auth, record = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + assert [c["id"] for c in options["allowCredentials"]] == [record.credential_id] + + verified = service.verify_authentication( + request, state=state, credential=auth.get(options), + ) + assert verified.client_id == "beaconmcp_test" + assert store.get(record.credential_id).last_used_at is not None + + +def test_authentication_without_credentials_is_refused(service): + with pytest.raises(PasskeyError, match="No passkey"): + service.authentication_options(FakeRequest(), client_id="nobody") + + +def test_authentication_rejects_replayed_challenge(service, enrolled): + auth, _ = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + assertion = auth.get(options) + service.verify_authentication(request, state=state, credential=assertion) + with pytest.raises(PasskeyError, match="expired"): + service.verify_authentication(request, state=state, credential=assertion) + + +def test_authentication_rejects_foreign_credential(service, enrolled): + """A challenge minted for client A must not accept client B's passkey.""" + auth_a, _ = enrolled + request = FakeRequest() + + other = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_other", client_name="Other", + ) + service.verify_registration( + request, state=state, credential=other.create(options), + ) + + options, state = service.authentication_options( + request, client_id="beaconmcp_other", + ) + # Sign the *other* client's challenge with the first client's key. + with pytest.raises(PasskeyError, match="different client"): + service.verify_authentication( + request, state=state, credential=auth_a.get(options), + ) + + +def test_authentication_rejects_wrong_rp_id(service, enrolled): + auth, _ = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + with pytest.raises(PasskeyError): + service.verify_authentication( + request, state=state, + credential=auth.get(options, rp_id="evil.example"), + ) + + +def test_sign_counter_regression_is_rejected(service, enrolled, store): + auth, record = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + service.verify_authentication(request, state=state, credential=auth.get(options)) + + # Roll the authenticator's counter back, as a cloned key would. + auth.sign_count = 0 + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + with pytest.raises(PasskeyError, match="sign count"): + service.verify_authentication( + request, state=state, credential=auth.get(options), + ) + + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +def test_delete_is_scoped_to_owner(store, service, enrolled): + _, record = enrolled + assert store.delete(record.credential_id, "someone_else") is False + assert store.delete(record.credential_id, "beaconmcp_test") is True + assert store.count_for_client("beaconmcp_test") == 0 + + +def test_service_without_store_reports_unavailable(): + service = PasskeyService(None) + assert service.available is False + assert "database" in service.unavailable_reason + with pytest.raises(PasskeyError): + service.authentication_options(FakeRequest(), client_id="x") + + +def test_working_service_has_no_unavailable_reason(service): + assert service.available is True + assert service.unavailable_reason is None + + +def test_missing_library_is_reported_with_a_fix(monkeypatch, store): + """The banner/doctor message must name the package and the command.""" + import beaconmcp.dashboard.passkeys as mod + + monkeypatch.setattr(mod, "_webauthn", None) + assert mod.webauthn_installed() is False + reason = PasskeyService(store).unavailable_reason + assert "webauthn" in reason and "pip install" in reason + + +# --------------------------------------------------------------------------- +# Dashboard routes +# --------------------------------------------------------------------------- + +class FakeClientStore: + def __init__(self): + self.clients = { + "beaconmcp_test": { + "secret": "sk_test", "name": "Test Client", "totp": "123456", + } + } + + def verify(self, client_id, secret): + c = self.clients.get(client_id) + return bool(c and c["secret"] == secret) + + def check_totp(self, client_id, code): + c = self.clients.get(client_id) + return TotpResult.OK if (c and c["totp"] == code) else TotpResult.INVALID + + def get_name(self, client_id): + c = self.clients.get(client_id) + return c["name"] if c else None + + +class FakeTokenStore: + def __init__(self): + self._tokens: dict[str, str] = {} + self._n = 0 + + def issue(self, client_id, name=None): + self._n += 1 + token = f"bearer_{self._n}" + self._tokens[token] = client_id + return token, 86400 + + def validate(self, token): + return self._tokens.get(token) + + def revoke(self, token): + self._tokens.pop(token, None) + return True + + def list_named(self, client_id): + return [] + + +@pytest.fixture() +def web(tmp_path, monkeypatch): + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "dashboard.db")) + db = Database(tmp_path / "dashboard.db") + passkey_store = PasskeyStore(db) + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + passkeys=PasskeyService(passkey_store), + ) + app = Starlette(routes=build_dashboard_routes(deps)) + client = TestClient( + app, follow_redirects=False, base_url=ORIGIN, + headers={"x-forwarded-proto": "https"}, + ) + return client, deps, passkey_store + + +def _csrf(client) -> str: + client.get("/app/login") + return client.cookies.get(CSRF_COOKIE) + + +def _json_headers(token: str) -> dict[str, str]: + return {"X-CSRF-Token": token, "X-BeaconMCP-Mode": "json"} + + +def _login(client) -> str: + """Sign in with TOTP and return the *rotated* CSRF token.""" + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "123456", + }, + headers=_json_headers(token), + ) + assert res.status_code == 200, res.text + return res.json()["csrf_token"] + + +def test_login_json_mode_returns_expiry_and_passkey_state(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "123456", + }, + headers=_json_headers(token), + ) + assert res.status_code == 200 + body = res.json() + assert body["ok"] is True + assert body["client_name"] == "Test Client" + assert body["bearer_expires_at"] > time.time() + assert body["session_expires_at"] > body["bearer_expires_at"] + assert body["passkeys_enabled"] is True + assert body["passkey_count"] == 0 + assert client.cookies.get(SESSION_COOKIE) + + +def test_login_form_mode_still_redirects(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "123456", + }, + ) + assert res.status_code == 303 + assert res.headers["location"] == "/app/tokens" + + +def test_login_json_mode_reports_errors_as_json(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "000000", + }, + headers=_json_headers(token), + ) + assert res.status_code == 401 + assert res.json()["ok"] is False + assert "2FA" in res.json()["error"] + + +def _register_passkey(client, token) -> SoftwareAuthenticator: + auth = SoftwareAuthenticator() + res = client.post( + "/app/api/passkeys/register/options", json={}, headers=_json_headers(token), + ) + assert res.status_code == 200, res.text + body = res.json() + res = client.post( + "/app/api/passkeys/register/verify", + json={"state": body["state"], "credential": auth.create(body["options"])}, + headers=_json_headers(token), + ) + assert res.status_code == 201, res.text + return auth + + +def test_register_then_sign_in_with_passkey(web): + client, _, passkey_store = web + token = _login(client) + auth = _register_passkey(client, token) + assert passkey_store.count_for_client("beaconmcp_test") == 1 + + listed = client.get("/app/api/passkeys", headers=_json_headers(token)).json() + assert len(listed["passkeys"]) == 1 + + # Fresh browser: credentials + passkey, no TOTP anywhere. + fresh = TestClient( + client.app, follow_redirects=False, base_url=ORIGIN, + headers={"x-forwarded-proto": "https"}, + ) + token2 = _csrf(fresh) + res = fresh.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + headers=_json_headers(token2), + ) + assert res.status_code == 200, res.text + body = res.json() + res = fresh.post( + "/app/api/passkeys/auth/verify", + json={ + "client_id": "beaconmcp_test", + "client_secret": "sk_test", + "state": body["state"], + "credential": auth.get(body["options"]), + }, + headers=_json_headers(token2), + ) + assert res.status_code == 200, res.text + assert res.json()["ok"] is True + assert res.json()["passkey_count"] == 1 + assert fresh.cookies.get(SESSION_COOKIE) + + +def test_passkey_auth_requires_valid_client_secret(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "wrong"}, + headers=_json_headers(token), + ) + assert res.status_code == 401 + assert res.json()["error"] == "Invalid credentials." + + +def test_passkey_auth_verify_rechecks_the_secret(web): + """The state token alone must never be enough to mint a session.""" + client, _, _ = web + token = _login(client) + auth = _register_passkey(client, token) + body = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + headers=_json_headers(token), + ).json() + res = client.post( + "/app/api/passkeys/auth/verify", + json={ + "client_id": "beaconmcp_test", + "client_secret": "wrong", + "state": body["state"], + "credential": auth.get(body["options"]), + }, + headers=_json_headers(token), + ) + assert res.status_code == 401 + + +def test_passkey_endpoints_require_csrf(web): + client, _, _ = web + _csrf(client) + res = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + ) + assert res.status_code == 403 + + +def test_passkey_registration_requires_a_session(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/api/passkeys/register/options", json={}, headers=_json_headers(token), + ) + assert res.status_code == 401 + + +def test_passkey_delete_scoped_to_session_client(web): + client, _, passkey_store = web + token = _login(client) + _register_passkey(client, token) + credential_id = passkey_store.list_for_client("beaconmcp_test")[0].credential_id + res = client.post( + "/app/api/passkeys/delete", + json={"credential_id": credential_id}, + headers=_json_headers(token), + ) + assert res.status_code == 200 + assert passkey_store.count_for_client("beaconmcp_test") == 0 + + +def test_tokens_page_lists_and_removes_passkeys(web): + client, _, passkey_store = web + token = _login(client) + _register_passkey(client, token) + + page = client.get("/app/tokens").text + assert "Passkeys" in page + credential_id = passkey_store.list_for_client("beaconmcp_test")[0].credential_id + assert credential_id in page + assert "never used" in page + + res = client.post( + "/app/passkeys/remove", + data={"csrf_token": token, "credential_id": credential_id}, + ) + assert res.status_code == 303 + assert res.headers["location"] == "/app/tokens" + assert passkey_store.count_for_client("beaconmcp_test") == 0 + + +def test_passkeys_remove_requires_csrf(web): + client, _, passkey_store = web + token = _login(client) + _register_passkey(client, token) + credential_id = passkey_store.list_for_client("beaconmcp_test")[0].credential_id + res = client.post( + "/app/passkeys/remove", + data={"csrf_token": "wrong", "credential_id": credential_id}, + ) + assert res.status_code == 403 + assert passkey_store.count_for_client("beaconmcp_test") == 1 + + +def test_routes_report_unavailable_without_a_service(tmp_path, monkeypatch): + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "d.db")) + db = Database(tmp_path / "d.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + passkeys=None, + ) + client = TestClient( + Starlette(routes=build_dashboard_routes(deps)), + follow_redirects=False, base_url=ORIGIN, + ) + client.get("/app/login") + token = client.cookies.get(CSRF_COOKIE) + res = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + headers=_json_headers(token), + ) + assert res.status_code == 503 + + +def test_login_page_hides_passkeys_when_unavailable(tmp_path, monkeypatch): + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "d2.db")) + db = Database(tmp_path / "d2.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + passkeys=None, + ) + client = TestClient( + Starlette(routes=build_dashboard_routes(deps)), base_url=ORIGIN, + ) + body = client.get("/app/login").text + assert 'data-passkeys-enabled="false"' in body + assert "passkey-login-btn" not in body From 0c24b2230d7c174a495d9a4ef0322eab070ca7cf Mon Sep 17 00:00:00 2001 From: Lony <66854264+Showdown76py@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:44:34 +0200 Subject: [PATCH 151/155] feat(updates): update notice for signed-in operators + self-update MCP tools (#37) * feat(updates): tell signed-in operators about updates, and offer to apply them BeaconMCP cuts no releases and ships no PyPI package: the canonical install is a git clone with a venv and a systemd unit. So "is there an update?" means "is this checkout behind the upstream default branch?", and nothing in the server was answering that question. Operators found out by happening to read the repo. Adds three things. **A notice, for signed-in operators only.** A card on any /app/* page when the checkout is behind: how far, the recent commit subjects, a link to the diff, and the commands to update. GET /app/api/update requires a live session and 401s otherwise -- the exact revision a server runs is free reconnaissance for anyone who hasn't authenticated, and the card is only ever rendered to someone signed in. Dismissing it hides that revision until a newer one lands. **Instructions that match the install**, rather than assuming everyone ran deploy/install.sh. A git checkout gets its own root and its real venv pip path, plus a systemctl line only when a unit file actually exists; a container gets docker compose; a pip distribution gets the git+https URL. **Two MCP tools.** beaconmcp_check_update is read-only. beaconmcp_self_update applies: pull --ff-only, reinstall dependencies, validate the config, then restart. It requires confirm=True, refuses a dirty checkout so local edits are never discarded, and refuses a non-git install. The config validation is a hard gate, not a warning, and it is what makes this safe to run unattended: it shells out to `beaconmcp validate-config` so the *new* code parses the operator's *actual* config. If a setting was renamed or a new one is now required, the checkout is reset to where it started, dependencies are restored, and nothing is restarted -- an update that bricks the server is worse than no update. The check also diffs the incoming .env.example / beaconmcp.yaml.example against the operator's real files (not the local examples, and honouring variables already exported), so the notice can say "this update wants a variable you haven't set" *before* it is applied. The dashboard's "Update now" re-prompts for 2FA: pulling code and restarting is the most privileged thing the panel can do, so a session alone is not the right bar -- same gate as minting a token. Both are switchable: features.updates.enabled is the air-gap switch (no egress, no tools, no notice) and allow_self_update keeps the notice while forbidding the apply, for deployments where updates go through a pipeline. Also fixes __version__, which had been pinned at "0.1.0" while pyproject said 2.0.0 -- it now reads package metadata, with the real number as the source-tree fallback. Tests drive git for real against throwaway repositories: a mocked subprocess would only prove the mock agrees with itself. pip and the validation subprocess are the two steps stubbed, so the pull/validate/ roll-back orchestration is exercised without touching the interpreter running the suite. * fix(updates): mention updates on the post-2FA screen, and stop caches pinning old assets Two gaps found by actually looking at the rendered pages. **The "You're signed in" screen said nothing.** The toast fetches its status once at page load, which on /app/login happens before the session exists -- so it 401'd and stayed empty, and signing in never re-checks because it does not reload the page. The one moment the operator is guaranteed to pass through said nothing about a pending update. login.js now re-asks once the session is created and renders a one-line mention above "Finish signing in". Deliberately not the full card: that screen has a single primary action, and on a narrow viewport a bottom-anchored card this tall would sit on top of it. The card now opts out of the auth pages entirely and shows on the landing page instead. **Browsers could keep running the previous release's JavaScript.** Starlette serves static files with ETag/Last-Modified but no Cache-Control, which leaves browsers on heuristic freshness -- a file untouched for weeks is reused for a long time without ever revalidating. That was survivable when upgrading meant running commands by hand; it is not once the server can update itself and the next page load is expected to match the new backend. This was not theoretical: it bit the browser used to verify the change, which kept executing a stale bundle across several restarts. Asset URLs now carry a fingerprint of the bundle, recomputed at start from the newest mtime in the static directory (which a git pull bumps). New bytes mean a new URL, so no cache can serve it from an old entry -- which also lets the files be cached hard instead of revalidated: ?v= present -> public, max-age=31536000, immutable ?v= absent -> no-cache (a legacy or hand-typed URL can't pin old code) /app/* pages -> no-store (per-session, and they carry the fingerprint) * fix(updates): serialize update work, and keep the restart off the shell Self-review findings on the update flow. **Two updates could run at once.** The dashboard button and the MCP tool reach `apply_update` independently, so nothing stopped a second one starting mid-pull: two `git pull` / `pip install -e .` runs in one checkout fight over index.lock and can leave a half-applied tree, and one caller's rollback could discard the other's successful update. A second caller is now told an update is already running rather than queued behind a pip that may take minutes -- it never touches git. **Cold-cache checks stampeded.** Every dashboard tab opening at once fired its own `git fetch`, piling up 60 s subprocesses for one answer. The uncached path is now single-flighted; waiters get the result the winner cached. **The deferred restart built a shell string.** `service` is the literal "beaconmcp" today, so this was not exploitable, but interpolating it into `sh -c` means a future change that made the unit name configurable would silently become a shell injection. Values now go through argv. All three are covered by tests, and both locks were mutation-checked: removing either makes its test fail (4 concurrent checks instead of 1; "release unlocked lock" when the second updater proceeds). --- README.md | 3 +- beaconmcp.yaml.example | 14 + docs/configuration.md | 9 + docs/tools.md | 18 +- docs/updates.md | 121 +++ src/beaconmcp/__init__.py | 12 +- src/beaconmcp/__main__.py | 10 +- src/beaconmcp/config.py | 34 +- src/beaconmcp/dashboard/app.py | 154 ++- src/beaconmcp/dashboard/static/app.css | 215 +++++ src/beaconmcp/dashboard/static/login.js | 20 + .../dashboard/static/update_banner.js | 237 +++++ src/beaconmcp/dashboard/templates/base.html | 11 +- src/beaconmcp/dashboard/templates/chat.html | 2 +- .../dashboard/templates/connectors.html | 2 +- src/beaconmcp/dashboard/templates/login.html | 7 +- src/beaconmcp/dashboard/templates/tokens.html | 2 +- .../dashboard/templates/totp_refresh.html | 2 +- src/beaconmcp/maintenance/__init__.py | 5 + src/beaconmcp/maintenance/tools.py | 117 +++ src/beaconmcp/server.py | 6 + src/beaconmcp/updates.py | 821 ++++++++++++++++ tests/test_updates.py | 907 ++++++++++++++++++ 23 files changed, 2713 insertions(+), 16 deletions(-) create mode 100644 docs/updates.md create mode 100644 src/beaconmcp/dashboard/static/update_banner.js create mode 100644 src/beaconmcp/maintenance/__init__.py create mode 100644 src/beaconmcp/maintenance/tools.py create mode 100644 src/beaconmcp/updates.py create mode 100644 tests/test_updates.py diff --git a/README.md b/README.md index f002531..638c01b 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ install. Both are covered in [Installation](docs/installation.md). |-------|--------------| | [Installation](docs/installation.md) | Requirements, Docker, systemd install, config wizard, reverse proxy, updates | | [Configuration](docs/configuration.md) | The two config files, every YAML key that matters, where to run the server | -| [Tools](docs/tools.md) | The 44 MCP tools, grouped by module | +| [Tools](docs/tools.md) | The 46 MCP tools, grouped by module | +| [Updates](docs/updates.md) | The update notice, the self-update tools, and how to turn both off | | [Client setup](docs/clients.md) | Assistant, ChatGPT, Gemini, Mistral, VS Code, Cursor, OpenCode | | [Security](docs/security.md) | What to review before approving a tool call, token handling, TOTP hygiene | | [Dashboard](docs/dashboard.md) | The optional `/app/*` web panel: login, API tokens, Gemini chat | diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index db590ed..3ceda3b 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -230,6 +230,20 @@ features: public_url: https://mcp.example.com # used to generate MCP URLs in the UI mcp_mode: local # "local" (default) or "remote" + # Update notifications. When enabled, the server periodically compares + # this checkout against the upstream default branch and shows a notice + # in the dashboard (signed-in operators only), with instructions matched + # to how BeaconMCP was installed here. Also exposes the + # beaconmcp_check_update / beaconmcp_self_update MCP tools. + updates: + # Set to false on an air-gapped or change-controlled deployment: the + # server then never contacts the git remote at all. + enabled: true + # Set to false to keep the notification but forbid applying it from + # the dashboard or over MCP -- appropriate when updates go through a + # deployment pipeline. Manual instructions are still shown. + allow_self_update: true + # Free-form infrastructure context exposed as an MCP resource. Edit freely: # the LLM reads this to understand your topology, naming conventions, and # operational notes. diff --git a/docs/configuration.md b/docs/configuration.md index ab2e1b3..9f1b14b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,3 +70,12 @@ Tailscale IP, a VPN address, a bastion. Everything else about the panel — enabling it, the tokens page, cost tracking, the confirmation modal — is in [dashboard.md](dashboard.md). + +## Updates + +| Key | Notes | +|-----|-------| +| `features.updates.enabled` | Default `true`. Compares this checkout against the upstream default branch and shows a notice to signed-in operators. Set to `false` on an air-gapped or change-controlled box: the server then never contacts the git remote, and the `beaconmcp_*_update` MCP tools are not registered. | +| `features.updates.allow_self_update` | Default `true`. Set to `false` to keep the notification but forbid applying it from the dashboard or over MCP — the right setting when deploys go through a pipeline. Manual instructions are still shown. | + +See [updates.md](updates.md) for the notice, the MCP tools, and what the self-update does. diff --git a/docs/tools.md b/docs/tools.md index 87a3bd5..9eb368d 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,8 +1,9 @@ # MCP tools -44 tools across six modules. The infrastructure modules are only registered when the matching +46 tools across seven modules. The infrastructure modules are only registered when the matching capability is configured, so an SSH-only deployment exposes the 2 SSH tools and nothing else from -Proxmox or BMC. `security_end_session` is always registered, whatever the topology. +Proxmox or BMC. `security_end_session` and the two maintenance tools are always registered, +whatever the topology. Long-running commands (`proxmox_run`, `ssh_run`) are synchronous by default. Pass `wait=False` to start one in the background and get an `exec_id` back, then call the same tool with `exec_id=` to @@ -92,3 +93,16 @@ to that device. | Tool | Description | |------|-------------| | `security_end_session` | Revoke the bearer token used for the current request, ~8 s after responding. Call it as the last step of a task to shrink the window in which a stolen token can be replayed — never mid-task, or the next call gets a 401. | + +## Maintenance (2) + +Registered on every deployment shape, unless `features.updates.enabled` is `false`. + +| Tool | Description | +|------|-------------| +| `beaconmcp_check_update` | Read-only. Reports the running version, how many commits this install is behind upstream, the changelog, any `.env` / `beaconmcp.yaml` settings the new revision knows about that this install has not set, and the exact commands that would update *this* install (git checkout, pip install and container each get different ones). Cached for a few hours. | +| `beaconmcp_self_update` | Applies the update: `git pull --ff-only` → reinstall the package and its dependencies → **validate the config against the new code** → schedule a restart. Requires `confirm=True`. Refuses on a dirty checkout or a non-git install. If the new revision cannot load the current config, everything is rolled back and nothing restarts. Hidden when `features.updates.allow_self_update` is `false`. | + +The restart is deferred a few seconds so the tool result reaches the caller before the process dies. +Show the user `beaconmcp_check_update` output — especially any new configuration — and get an +explicit go-ahead before calling `beaconmcp_self_update`. diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 0000000..47d213c --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,121 @@ +# Updates + +BeaconMCP publishes no releases and no PyPI package: the canonical install is a `git clone` at +`/opt/beaconmcp` with a venv and a systemd unit. So "is there an update?" means **is this checkout +behind the upstream default branch?** + +The server answers that question itself, tells signed-in operators, and can apply the update. + +## The notice + +Signed in to the dashboard, a card appears bottom-right on any `/app/*` page when the checkout is +behind: + +- how far behind, and the revision range (`9f496cb → abc1234`); +- the last few commit subjects, and a link to the full diff on GitHub; +- **any configuration the new revision knows about that you have not set** — new `.env` variables, + new `beaconmcp.yaml` settings; +- the exact commands to update *this* install; +- an **Update now** button, when an automatic update is possible. + +Dismissing it hides that specific revision; the card returns when a newer one lands. + +On the **"You're signed in"** screen — the moment the session is created, one click before the panel +— you get a one-line mention instead of the full card. That screen has a single primary action, and +on a narrow viewport a bottom-anchored card this tall would sit right on top of it. The card itself +opts out of the auth pages entirely and shows on the landing page. + +The endpoint behind it (`GET /app/api/update`) requires a live session and returns `401` otherwise. +That is deliberate: the exact revision a server runs is free reconnaissance for anyone who has not +authenticated, and the card is only ever rendered to someone signed in. + +## Instructions match your install + +Detection is not a guess about how you *should* have installed it: + +| Detected | What you are told | +|----------|-------------------| +| git checkout | `cd ` → `git pull --ff-only` → `/bin/pip install -e .` (the real venv path, when there is one) → `systemctl restart beaconmcp` if a unit file exists | +| container | `docker compose pull` → `docker compose up -d` | +| pip distribution | `pip install --upgrade 'beaconmcp @ git+https://github.com/Showdown76py/BeaconMCP.git'` | +| unknown | Re-run `deploy/install.sh` from a checkout | + +## MCP tools + +Two tools, registered on every deployment shape: + +- **`beaconmcp_check_update`** — read-only. Version, commits behind, changelog, new configuration, + and the commands for this install. Cached for a few hours. +- **`beaconmcp_self_update`** — applies it. Requires `confirm=True`. + +Ask your assistant to "check whether BeaconMCP has an update" and it will read the changelog and any +new settings back to you before touching anything. + +## What the self-update actually does + +In order, stopping at the first failure: + +1. **Preflight** — refuses on a non-git install, and refuses when the checkout has uncommitted + changes. Local edits are never discarded. +2. **`git pull --ff-only`** — a fast-forward or nothing. No merges, no rebases. +3. **Reinstall** — `pip install -e .` in the detected venv, so new or bumped dependencies land. +4. **Validate the config** — runs `beaconmcp validate-config` in a subprocess, so the *new* code + parses your *actual* configuration. +5. **Restart** — `systemctl restart`, deferred a few seconds so the response reaches you first. + +Step 4 is a hard gate, and it is the reason this is safe to run unattended. If the new revision +cannot load your config — a setting was renamed, a new one is now required — the checkout is reset +to exactly where it started, dependencies are restored, **nothing is restarted**, and the error from +the validator is handed back to you. An update that bricks the server is worse than no update. + +Only one update runs at a time. The dashboard button and the MCP tool reach the same code, and two +`git pull` / `pip install` runs in one checkout would fight over `index.lock`. A second caller is +told one is already in progress rather than queued behind a pip that may take minutes. + +### From the dashboard + +The **Update now** button asks for a fresh 2FA code before it runs. Pulling code and restarting the +process is the most privileged thing the panel can do, so a session alone is not enough — same bar +as minting an API token. + +## Turning it off + +```yaml +features: + updates: + enabled: true # false: never contact the remote, no tools, no notice + allow_self_update: true # false: keep the notice, forbid applying it +``` + +`enabled: false` is the air-gap switch. `allow_self_update: false` is for deployments where updates +go through a pipeline: operators still see that one is available, with instructions, but neither the +dashboard button nor the MCP tool exists. + +## Stale assets after an update + +A server that can update itself must not leave browsers running the previous release's JavaScript. +Starlette serves static files with `ETag`/`Last-Modified` but no `Cache-Control`, which puts +browsers on *heuristic* freshness: a file untouched for weeks is reused for a long time without ever +revalidating. + +Asset URLs therefore carry a fingerprint of the bundle (`app.css?v=6a69fedf`), recomputed at each +start from the newest mtime in the static directory — which a `git pull` bumps. New bytes mean a new +URL, so no cache can satisfy the request from an old entry: + +| Response | `Cache-Control` | +|----------|-----------------| +| Asset with `?v=` | `public, max-age=31536000, immutable` | +| Asset without | `no-cache` (revalidate every time — a legacy or hand-typed URL can never pin stale code) | +| Any `/app/*` page or API reply | `no-store` (per-session, and it carries the fingerprint) | + +## Audit trail + +Every attempt is logged (see [security.md](security.md#audit-trail)): + +| Event | When | +|-------|------| +| `dashboard.update.start` / `dashboard.update.finish` | Update applied from the panel | +| `maintenance.self_update.start` / `maintenance.self_update.finish` | Update applied over MCP | + +The `finish` events carry `ok`, `from_ref`, `to_ref` and `rolled_back`, so a rollback is visible in +the log without reading the tool output. diff --git a/src/beaconmcp/__init__.py b/src/beaconmcp/__init__.py index 3dc1f76..6364084 100644 --- a/src/beaconmcp/__init__.py +++ b/src/beaconmcp/__init__.py @@ -1 +1,11 @@ -__version__ = "0.1.0" +"""BeaconMCP -- remote MCP server for Proxmox VE and BMC infrastructure.""" + +try: # pragma: no cover - trivial + from importlib.metadata import version as _version + + __version__ = _version("beaconmcp") +except Exception: # noqa: BLE001 - running straight from an uninstalled tree + # Fallback when the package was never pip-installed. Keep in sync with + # [project].version in pyproject.toml. (The old hard-coded "0.1.0" had + # drifted three majors behind it.) + __version__ = "2.0.0" diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 443f366..f711726 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -2180,6 +2180,8 @@ async def lifespan(_app): login_limiter=_login_limiter, trusted_proxies=tuple(config.server.trusted_proxies), passkey_service=passkey_service, + updates=config.features.updates, + config_path=config.source_path, ) app = Starlette( @@ -2246,7 +2248,8 @@ def _build_dashboard_routes(client_store, token_store, totp_locked, totp_record_failure, totp_record_success, *, dyn_reg=None, shared_database=None, login_limiter=None, trusted_proxies=(), - passkey_service=None): + passkey_service=None, updates=None, + config_path=None): """Build dashboard routes if enabled. Returns [] when disabled.""" from . import dashboard if not dashboard.is_enabled(): @@ -2320,6 +2323,11 @@ def _float_env(name: str, default: float) -> float: login_limiter=login_limiter, trusted_proxies=trusted_proxies, passkeys=passkey_service, + updates_enabled=updates.enabled if updates is not None else True, + allow_self_update=( + updates.allow_self_update if updates is not None else True + ), + config_path=config_path, ) return build_dashboard_routes(deps) diff --git a/src/beaconmcp/config.py b/src/beaconmcp/config.py index 14f1272..c7f89fd 100644 --- a/src/beaconmcp/config.py +++ b/src/beaconmcp/config.py @@ -168,10 +168,26 @@ class DashboardConfig: mcp_mode: str = "local" # "local" | "remote" +@dataclass +class UpdatesConfig: + """Update checking and self-update. + + ``enabled`` is the network-egress switch: turning it off means the + server never contacts the git remote, which is what an air-gapped or + change-controlled deployment wants. ``allow_self_update`` keeps the + check but removes the ability to apply one from the dashboard or over + MCP -- appropriate when updates go through a deployment pipeline. + """ + + enabled: bool = True + allow_self_update: bool = True + + @dataclass class FeaturesConfig: dashboard: DashboardConfig = field(default_factory=DashboardConfig) ssh_enabled: bool = True + updates: UpdatesConfig = field(default_factory=UpdatesConfig) @dataclass @@ -183,6 +199,10 @@ class Config: features: FeaturesConfig verify_ssl: bool infrastructure: dict + #: YAML file this config was loaded from, or ``None`` on the legacy + #: env-var path. The self-update flow needs it to re-validate the + #: operator's *actual* config against newly pulled code. + source_path: Path | None = None # --- Loading ---------------------------------------------------------- @@ -255,7 +275,7 @@ def _from_yaml(cls, path: Path) -> Config: if not isinstance(raw, dict): raise ConfigError(f"{path}: top-level YAML must be a mapping.") resolved = _resolve_env_refs(raw, path=path) - return cls._build(resolved) + return cls._build(resolved, source_path=path) @classmethod def _from_legacy_env(cls) -> Config: @@ -328,7 +348,7 @@ def _from_legacy_env(cls) -> Config: return cls._build(raw) @classmethod - def _build(cls, raw: dict) -> Config: + def _build(cls, raw: dict, *, source_path: Path | None = None) -> Config: proxmox_raw = raw.get("proxmox") or {} nodes_raw = proxmox_raw.get("nodes") or [] @@ -554,9 +574,14 @@ def _build(cls, raw: dict) -> Config: public_url=dash_raw.get("public_url"), mcp_mode=(dash_raw.get("mcp_mode") or "local").strip().lower(), ) + updates_raw = feat_raw.get("updates") or {} features = FeaturesConfig( dashboard=dashboard, ssh_enabled=_bool((feat_raw.get("ssh") or {}).get("enabled", True)), + updates=UpdatesConfig( + enabled=_bool(updates_raw.get("enabled", True)), + allow_self_update=_bool(updates_raw.get("allow_self_update", True)), + ), ) # Cross-capability validation ---------------------------------------- @@ -597,6 +622,7 @@ def _build(cls, raw: dict) -> Config: features=features, verify_ssl=_bool(proxmox_raw.get("verify_ssl", False)), infrastructure=raw.get("infrastructure") or {}, + source_path=source_path, ) # --- Accessors -------------------------------------------------------- @@ -748,6 +774,10 @@ def mask(value: str) -> str: "mcp_mode": self.features.dashboard.mcp_mode, }, "ssh_enabled": self.features.ssh_enabled, + "updates": { + "enabled": self.features.updates.enabled, + "allow_self_update": self.features.updates.allow_self_update, + }, }, "infrastructure": self.infrastructure, } diff --git a/src/beaconmcp/dashboard/app.py b/src/beaconmcp/dashboard/app.py index 34b8f54..ab7e34a 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -103,6 +103,13 @@ class DashboardDeps: # unset (or reporting ``available is False``), the login page hides # every passkey affordance and the TOTP path is the only way in. passkeys: PasskeyService | None = None + # Update notifications. ``updates_enabled`` gates the check (and with + # it any network egress); ``allow_self_update`` gates the "Update now" + # button. ``config_path`` is the YAML the update flow re-validates + # against freshly pulled code. + updates_enabled: bool = True + allow_self_update: bool = True + config_path: Path | None = None # --------------------------------------------------------------------------- @@ -170,6 +177,7 @@ def _render( if not token: token = csrf.issue_token() context["csrf_token"] = token + context["asset_v"] = ASSET_VERSION response = _TEMPLATES.TemplateResponse( request, template, context, status_code=status_code ) @@ -191,6 +199,11 @@ def _render( def _apply_security_headers(response: Response) -> None: response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("X-Content-Type-Options", "nosniff") + # Panel pages and API replies are per-session and must not be reused -- + # by a shared cache, or by the back button after a sign-out. It also + # keeps the asset fingerprints these pages embed from going stale. + # setdefault, so the SSE stream keeps its own directives. + response.headers.setdefault("Cache-Control", "no-store") response.headers.setdefault( "Referrer-Policy", "strict-origin-when-cross-origin" ) @@ -257,6 +270,60 @@ def _totp_error(result: TotpResult, invalid_message: str) -> str: return TOTP_REPLAY_MESSAGE if result is TotpResult.REPLAY else invalid_message +def _compute_asset_version() -> str: + """Fingerprint the static bundle, for cache-busting query strings. + + Starlette serves static files with ``ETag``/``Last-Modified`` but no + ``Cache-Control``, which leaves browsers on *heuristic* freshness: an + asset untouched for weeks is reused for a long time without ever + revalidating. That was survivable when upgrading meant an operator + running commands by hand; now that the server can update itself, the + next page load would happily keep executing the previous release's + JavaScript against a new backend. + + Stamping the URLs with a fingerprint fixes it deterministically -- new + bytes mean a new URL, which no cache can satisfy from an old entry -- + and lets the files themselves be cached hard (see + :class:`_ImmutableStaticFiles`). Newest mtime in the directory is + enough: a ``git pull`` rewrites the files it changes. + """ + try: + newest = max( + p.stat().st_mtime + for p in (_DASHBOARD_DIR / "static").iterdir() + if p.is_file() + ) + except (OSError, ValueError): + return "0" + return format(int(newest), "x") + + +#: Computed once per process: a restart is exactly when the bundle can change. +ASSET_VERSION = _compute_asset_version() + + +class _ImmutableStaticFiles(StaticFiles): + """StaticFiles for URLs that carry a content fingerprint. + + Safe to cache hard *because* the query string changes whenever the + bytes do. Requests without a version (a hand-typed URL, an old cached + page) fall back to revalidate-every-time so they can never pin stale + code. + """ + + def file_response(self, full_path, stat_result, scope, *args, **kwargs) -> Response: + response = super().file_response( + full_path, stat_result, scope, *args, **kwargs + ) + query = scope.get("query_string") or b"" + versioned = b"v=" in query + response.headers.setdefault( + "Cache-Control", + "public, max-age=31536000, immutable" if versioned else "no-cache", + ) + return response + + def _wants_json(request: Request) -> bool: """True when the caller is the login page's fetch() rather than a form POST. @@ -1096,6 +1163,89 @@ async def api_passkeys_auth_verify(request: Request) -> Response: ) return response + # --- Update notifications -------------------------------------------- + + async def api_update_status(request: Request) -> Response: + """Update status for the signed-in operator. + + Deliberately session-gated: an anonymous visitor learning the exact + revision a server runs is free reconnaissance, and the banner is + only ever rendered to someone already signed in. + """ + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not deps.updates_enabled: + return _json({"enabled": False, "available": False}) + + from .. import updates as updates_mod + + force = request.query_params.get("force") == "1" + # The check shells out to git (network); keep it off the event loop. + info = await asyncio.to_thread( + updates_mod.check_for_update, force=force, config_path=deps.config_path, + ) + payload = info.to_json() + payload["enabled"] = True + payload["self_update_allowed"] = deps.allow_self_update + if not deps.allow_self_update: + payload["can_self_update"] = False + return _json(payload) + + async def api_update_apply(request: Request) -> Response: + """Apply an update from the dashboard, behind a fresh 2FA code. + + Pulling code and restarting the process is the most privileged + thing this panel can do, so it is gated exactly like minting a + token: a session is not enough, the operator re-proves the second + factor at the moment of the action. + """ + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + if not (deps.updates_enabled and deps.allow_self_update): + return _json( + { + "ok": False, + "error": "Self-update is disabled on this server " + "(features.updates.allow_self_update).", + }, + status=403, + ) + + body = await _read_json(request) + totp = str(body.get("totp") or "").strip() + if not totp: + return _json({"ok": False, "error": "2FA code is required."}, status=400) + if deps.totp_locked(session.client_id): + return _json( + {"ok": False, "error": "Too many 2FA attempts; try again in 5 minutes."}, + status=429, + ) + totp_result = _check_totp(deps, session.client_id, totp) + if totp_result is not TotpResult.OK: + return _json( + {"ok": False, "error": _totp_error(totp_result, "Invalid 2FA code.")}, + status=401, + ) + deps.totp_record_success(session.client_id) + + from .. import updates as updates_mod + + audit.emit("dashboard.update.start", client_id=session.client_id) + result = await asyncio.to_thread( + updates_mod.apply_update, config_path=deps.config_path, + ) + audit.emit( + "dashboard.update.finish", client_id=session.client_id, + ok=result.ok, from_ref=result.from_ref, to_ref=result.to_ref, + rolled_back=result.rolled_back, + ) + updates_mod.invalidate_cache() + return _json(result.to_json(), status=200 if result.ok else 500) + async def chat_get(request: Request) -> Response: session = _load_session(request, deps) if not session: @@ -1467,6 +1617,8 @@ async def _confirm(req: ToolConfirmRequired) -> bool: ), Route("/app/api/passkeys/delete", api_passkeys_delete, methods=["POST"]), Route("/app/passkeys/remove", passkeys_remove, methods=["POST"]), + Route("/app/api/update", api_update_status, methods=["GET"]), + Route("/app/api/update/apply", api_update_apply, methods=["POST"]), Route( "/app/api/passkeys/auth/options", api_passkeys_auth_options, methods=["POST"], @@ -1478,7 +1630,7 @@ async def _confirm(req: ToolConfirmRequired) -> bool: Route("/", index, methods=["GET"]), Mount( "/app/static", - app=StaticFiles(directory=_DASHBOARD_DIR / "static"), + app=_ImmutableStaticFiles(directory=_DASHBOARD_DIR / "static"), name="dashboard-static", ), ] diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index 7488464..b53ece4 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -2315,3 +2315,218 @@ a.cta:hover { background: var(--accent-hover); } white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .passkey-row .passkey-sub { font-size: 12px; color: var(--fg-muted); } + +/* =============================================================== + UPDATE TOAST (every /app page, signed-in only) + =============================================================== */ + +.update-toast { + position: fixed; + right: 18px; bottom: 18px; + z-index: 60; + width: min(380px, calc(100vw - 36px)); + max-height: min(72vh, 640px); + overflow-y: auto; + padding: 16px 18px; + background: var(--bg-elev); + border: 1px solid var(--accent-border); + border-radius: 14px; + box-shadow: var(--shadow-lg, 0 8px 32px rgba(20,14,8,0.16)); + font-size: 13px; + animation: ut-rise 320ms var(--ease-out) both; +} +@keyframes ut-rise { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.update-toast .ut-head { + display: flex; align-items: center; gap: 8px; + font-size: 14px; +} +.update-toast .ut-dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--accent); + flex-shrink: 0; + box-shadow: 0 0 0 3px var(--accent-soft); +} +.update-toast .ut-x { + margin-left: auto; + background: transparent; border: 0; padding: 2px; + color: var(--fg-faint); cursor: pointer; line-height: 0; + border-radius: 6px; +} +.update-toast .ut-x:hover { color: var(--fg); background: var(--bg-soft); } + +.update-toast .ut-sub { + margin: 6px 0 12px; + color: var(--fg-muted); +} +.update-toast code { + font-family: var(--font-mono); + font-size: 11.5px; + background: var(--bg-soft); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 1px 4px; +} + +.update-toast .ut-log { + list-style: none; + margin: 0 0 12px; padding: 0; + display: grid; gap: 4px; +} +.update-toast .ut-log li { + color: var(--fg-mid); + font-size: 12.5px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.update-toast .ut-log .ut-more { color: var(--fg-faint); } + +.update-toast .ut-warn { + margin: 0 0 12px; + padding: 9px 11px; + border-radius: 9px; + background: var(--danger-soft); + border: 1px solid color-mix(in oklab, var(--danger) 30%, var(--border)); + color: var(--fg); + display: grid; gap: 4px; + font-size: 12.5px; +} +.update-toast .ut-warn strong { color: var(--danger); } + +.update-toast .ut-steps-head { + display: flex; align-items: center; gap: 8px; + font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; + color: var(--fg-faint); + margin-bottom: 6px; +} +.update-toast .ut-copy { + margin-left: auto; + background: transparent; + border: 1px solid var(--border-strong); + border-radius: 6px; + padding: 2px 8px; + font: 500 11px var(--font); + color: var(--fg-mid); cursor: pointer; + text-transform: none; letter-spacing: 0; +} +.update-toast .ut-copy:hover { color: var(--fg); border-color: var(--accent-border); } + +.update-toast .ut-steps { + margin: 0 0 12px; + padding: 10px 12px; + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 9px; + overflow-x: auto; +} +.update-toast .ut-steps code { + background: transparent; border: 0; padding: 0; + white-space: pre; + font-size: 11.5px; + color: var(--fg-mid); +} + +.update-toast .ut-block { + margin: 0 0 12px; + font-size: 12px; + color: var(--fg-muted); +} + +.update-toast .ut-actions { + display: flex; align-items: center; gap: 10px; +} +.update-toast .ut-primary { + padding: 8px 14px; + border: 0; border-radius: 8px; + background: var(--accent); color: var(--accent-fg); + font: 600 13px var(--font); + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + position: relative; overflow: hidden; +} +.update-toast .ut-primary:hover:not(:disabled) { background: var(--accent-hover); } +.update-toast .ut-primary:disabled { opacity: 0.75; cursor: progress; } +.update-toast .ut-primary.is-loading::after { + content: ""; + position: absolute; inset: 0; + background: linear-gradient(100deg, transparent 20%, rgba(255,255,255,0.38) 50%, transparent 80%); + transform: translateX(-100%); + animation: shimmer-sweep 1150ms var(--ease-out) infinite; +} +.update-toast .ut-link { + color: var(--fg-mid); font-size: 12.5px; text-decoration: none; + border-bottom: 1px solid var(--border-strong); +} +.update-toast .ut-link:hover { color: var(--accent); border-color: var(--accent-border); } + +.update-toast .ut-result { margin-top: 12px; } +.update-toast .ut-label { + display: block; + font-size: 12px; color: var(--fg-mid); margin-bottom: 6px; +} +.update-toast .ut-totp-row { display: flex; gap: 8px; } +.update-toast .ut-totp-row input { + flex: 1; min-width: 0; + padding: 8px 10px; + background: var(--bg-soft); + border: 1px solid var(--border-strong); + border-radius: 8px; + color: var(--fg); + font-family: var(--font-mono); + letter-spacing: 0.18em; + outline: none; +} +.update-toast .ut-totp-row input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.update-toast .ut-note { + margin: 8px 0 0; + font-size: 11.5px; line-height: 1.5; color: var(--fg-muted); +} +.update-toast .ut-ok, +.update-toast .ut-err { + padding: 9px 11px; + border-radius: 9px; + font-size: 12.5px; + line-height: 1.5; +} +.update-toast .ut-ok { + background: var(--success-soft); + border: 1px solid color-mix(in oklab, var(--success) 32%, var(--border)); +} +.update-toast .ut-err { + background: var(--danger-soft); + border: 1px solid color-mix(in oklab, var(--danger) 32%, var(--border)); +} + +@media (max-width: 560px) { + .update-toast { right: 10px; left: 10px; bottom: 10px; width: auto; } +} +@media (prefers-reduced-motion: reduce) { + .update-toast { animation: none; } + .update-toast .ut-primary.is-loading::after { animation: none; opacity: 0.25; } +} + +/* One-line update mention on the post-2FA screen. The full toast opts out + of the auth pages (see update_banner.js) so it can't sit on top of + "Finish signing in" on a narrow viewport. */ +.auth-card .update-note { + display: block; + margin-top: 18px; + padding: 9px 12px; + border: 1px solid var(--accent-border); + border-radius: 9px; + background: var(--accent-softer); + color: var(--fg-mid); + font-size: 12.5px; + line-height: 1.45; + text-decoration: none; + transition: background 160ms var(--ease-out), color 160ms var(--ease-out); +} +.auth-card .update-note:hover { + background: var(--accent-soft); + color: var(--fg); +} diff --git a/src/beaconmcp/dashboard/static/login.js b/src/beaconmcp/dashboard/static/login.js index 5292230..5f8f00b 100644 --- a/src/beaconmcp/dashboard/static/login.js +++ b/src/beaconmcp/dashboard/static/login.js @@ -263,6 +263,26 @@ } } if (finishBtn) finishBtn.focus(); + mentionUpdate(); + } + + // The toast in base.html fetched its status before this session existed, + // so it came back 401 and stayed empty. Now that we're signed in, ask + // again -- a pending update is worth knowing about here, one click before + // entering the panel. Kept to a single line: this screen already has a + // primary action and the full card (with commands) waits on the landing + // page. + function mentionUpdate() { + var note = document.getElementById("update-note"); + if (!note || !window.BeaconUpdates) return; + window.BeaconUpdates.check().then(function(data) { + if (!data) return; + var behind = data.behind === 1 + ? "1 commit behind" : data.behind + " commits behind"; + note.textContent = "An update is available — " + behind + + " " + (data.branch || "main") + ". Details after signing in."; + note.hidden = false; + }); } function finish() { diff --git a/src/beaconmcp/dashboard/static/update_banner.js b/src/beaconmcp/dashboard/static/update_banner.js new file mode 100644 index 0000000..3008442 --- /dev/null +++ b/src/beaconmcp/dashboard/static/update_banner.js @@ -0,0 +1,237 @@ +// "An update is available" toast, shown on every /app page to a signed-in +// operator. The endpoint is session-authenticated, so a 401 (login page, +// signed out) simply leaves the toast hidden -- no branching needed here. +(function() { + "use strict"; + + var root = document.getElementById("update-toast"); + if (!root) return; + + var DISMISS_KEY = "beaconmcp-update-dismissed"; + var state = null; + + function csrfToken() { + var m = document.cookie.match(/(?:^|;\s*)beaconmcp_csrf_token=([^;]+)/); + return m ? decodeURIComponent(m[1]) : ""; + } + + function dismissed(ref) { + try { + return window.localStorage.getItem(DISMISS_KEY) === ref; + } catch (e) { + return false; + } + } + + function remember(ref) { + try { + window.localStorage.setItem(DISMISS_KEY, ref); + } catch (e) {} + } + + function esc(value) { + return String(value == null ? "" : value) + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """); + } + + function plural(n, one, many) { + return n + " " + (n === 1 ? one : many); + } + + function render(data) { + state = data; + var commits = data.commits || []; + var cfg = data.config || {}; + var newEnv = cfg.new_env_vars || []; + var newKeys = cfg.new_config_keys || []; + + var html = '' + + '
    ' + + '' + + 'Update available' + + '' + + '
    ' + + '

    ' + + esc(plural(data.behind, "commit", "commits")) + " behind " + + '' + esc(data.branch || "main") + '' + + (data.current_ref && data.latest_ref + ? ' · ' + esc(data.current_ref) + ' → ' + esc(data.latest_ref) + '' + : "") + + '

    '; + + if (commits.length) { + html += '
      '; + commits.slice(0, 4).forEach(function(c) { + html += '
    • ' + esc(c.sha) + ' ' + esc(c.subject) + '
    • '; + }); + if (commits.length > 4) { + html += '
    • + ' + + esc(plural(commits.length - 4, "more commit", "more commits")) + '
    • '; + } + html += '
    '; + } + + if (newEnv.length || newKeys.length) { + html += '
    Needs configuration'; + if (newEnv.length) { + html += '
    New .env variables: ' + + newEnv.map(function(v) { return '' + esc(v) + ''; }).join(", ") + + '
    '; + } + if (newKeys.length) { + html += '
    New beaconmcp.yaml settings: ' + + newKeys.slice(0, 6).map(function(v) { return '' + esc(v) + ''; }).join(", ") + + (newKeys.length > 6 ? ", …" : "") + + '
    '; + } + html += '
    '; + } + + var steps = (data.instructions || []).join("\n"); + html += '
    ' + + 'To update this ' + esc(data.install_kind) + ' install' + + '' + + '
    ' + + '
    ' + esc(steps) + '
    '; + + if (data.blockers && data.blockers.length) { + html += '

    Automatic update unavailable: ' + + esc(data.blockers.join("; ")) + '

    '; + } + + html += '
    '; + if (data.can_self_update && data.self_update_allowed) { + html += ''; + } + if (data.compare_url) { + html += 'View changes'; + } + html += '
    '; + html += ''; + + root.innerHTML = html; + root.hidden = false; + + var close = document.getElementById("ut-close"); + if (close) { + close.addEventListener("click", function() { + remember(data.latest_ref); + root.hidden = true; + }); + } + var copy = document.getElementById("ut-copy"); + if (copy) { + copy.addEventListener("click", function() { + navigator.clipboard.writeText(steps).then(function() { + copy.textContent = "Copied"; + setTimeout(function() { copy.textContent = "Copy"; }, 1600); + }, function() {}); + }); + } + var go = document.getElementById("ut-go"); + if (go) go.addEventListener("click", askForCode); + } + + // Applying an update pulls code and restarts the process -- gated on a + // fresh 2FA code, the same bar as minting a token. + function askForCode() { + var box = document.getElementById("ut-result"); + if (!box) return; + box.hidden = false; + box.innerHTML = '' + + '' + + '
    ' + + '' + + '' + + '
    ' + + '

    Pulls the new code, reinstalls dependencies, ' + + 're-validates your config, then restarts. If the new code can\'t load ' + + 'your config it rolls back and does not restart.

    '; + var input = document.getElementById("ut-totp"); + var confirm = document.getElementById("ut-confirm"); + if (input) input.focus(); + if (input) { + input.addEventListener("keydown", function(e) { + if (e.key === "Enter") { e.preventDefault(); run(); } + }); + } + if (confirm) confirm.addEventListener("click", run); + } + + function run() { + var input = document.getElementById("ut-totp"); + var confirm = document.getElementById("ut-confirm"); + var box = document.getElementById("ut-result"); + var code = input ? (input.value || "").replace(/\D/g, "") : ""; + if (code.length !== 6) { + if (input) input.focus(); + return; + } + if (confirm) { + confirm.classList.add("is-loading"); + confirm.disabled = true; + var label = confirm.querySelector(".btn-label"); + if (label) label.textContent = "Updating…"; + } + fetch("/app/api/update/apply", { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken(), + }, + body: JSON.stringify({ totp: code }), + }).then(function(res) { + return res.json().catch(function() { return {}; }); + }).then(function(data) { + if (!box) return; + if (data.ok) { + var tail = data.restart_scheduled + ? " The server restarts in " + data.restart_in_seconds + + "s — this page will be briefly unreachable." + : ""; + box.innerHTML = '
    Updated. ' + + esc(data.message || "") + esc(tail) + '
    '; + remember(state && state.latest_ref); + } else { + box.innerHTML = '
    Update failed. ' + + esc(data.message || data.error || "Unknown error.") + '
    '; + } + }).catch(function() { + if (box) { + box.innerHTML = '
    Network error while updating.
    '; + } + }); + } + + // Resolves to the payload when an undismissed update exists, else null. + function check() { + return fetch("/app/api/update", { credentials: "same-origin" }) + .then(function(res) { return res.ok ? res.json() : null; }) + .then(function(data) { + if (!data || !data.enabled || !data.available) return null; + return data; + }) + .catch(function() { return null; }); + } + + // Exposed so the login page can mention an update the moment the session + // exists -- its own fetch below already ran (and 401'd) before sign-in. + window.BeaconUpdates = { check: check, dismissed: dismissed }; + + // The auth pages opt out of the toast itself: they are single-purpose + // screens, and on a narrow viewport a bottom-anchored card this tall + // would sit right on top of their primary button. login.js renders a + // one-line mention instead, and the full card shows on the landing page. + if (document.body.classList.contains("auth-page")) return; + + check().then(function(data) { + if (data && !dismissed(data.latest_ref)) render(data); + }); +})(); diff --git a/src/beaconmcp/dashboard/templates/base.html b/src/beaconmcp/dashboard/templates/base.html index 3e817c0..3620fb4 100644 --- a/src/beaconmcp/dashboard/templates/base.html +++ b/src/beaconmcp/dashboard/templates/base.html @@ -8,11 +8,18 @@ - - + + {% block head %}{% endblock %} {% block body %}{% endblock %} + + {# Update notice. Populated by a session-authenticated fetch, so it stays + invisible on the login page and to anyone signed out. Anchored to the + viewport rather than the flow: /app/chat is a full-height grid and a + banner in the document flow would push its layout around. #} + + diff --git a/src/beaconmcp/dashboard/templates/chat.html b/src/beaconmcp/dashboard/templates/chat.html index a984238..4b0a847 100644 --- a/src/beaconmcp/dashboard/templates/chat.html +++ b/src/beaconmcp/dashboard/templates/chat.html @@ -192,5 +192,5 @@

    Rolling 7-day window

    - + {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/connectors.html b/src/beaconmcp/dashboard/templates/connectors.html index 20f3ee9..c8811fe 100644 --- a/src/beaconmcp/dashboard/templates/connectors.html +++ b/src/beaconmcp/dashboard/templates/connectors.html @@ -132,5 +132,5 @@

    OAuth connectors

    - + {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/login.html b/src/beaconmcp/dashboard/templates/login.html index 41684f2..a784041 100644 --- a/src/beaconmcp/dashboard/templates/login.html +++ b/src/beaconmcp/dashboard/templates/login.html @@ -145,6 +145,9 @@

    You're signed in

    localhost address).

    + {# Filled in by login.js once the session exists -- see update_banner.js #} + +
    - - + + {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index 032a97a..1e149be 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -811,5 +811,5 @@

    Other MCP-HTTP clients

    - + {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/totp_refresh.html b/src/beaconmcp/dashboard/templates/totp_refresh.html index d7d95ac..b552fd0 100644 --- a/src/beaconmcp/dashboard/templates/totp_refresh.html +++ b/src/beaconmcp/dashboard/templates/totp_refresh.html @@ -40,5 +40,5 @@

    Two-factor

    - + {% endblock %} diff --git a/src/beaconmcp/maintenance/__init__.py b/src/beaconmcp/maintenance/__init__.py new file mode 100644 index 0000000..1d488b2 --- /dev/null +++ b/src/beaconmcp/maintenance/__init__.py @@ -0,0 +1,5 @@ +"""Self-maintenance tools: update checking and applying.""" + +from .tools import register_maintenance_tools + +__all__ = ["register_maintenance_tools"] diff --git a/src/beaconmcp/maintenance/tools.py b/src/beaconmcp/maintenance/tools.py new file mode 100644 index 0000000..ec6364c --- /dev/null +++ b/src/beaconmcp/maintenance/tools.py @@ -0,0 +1,117 @@ +"""MCP tools for keeping the BeaconMCP server itself up to date.""" + +from __future__ import annotations + +from pathlib import Path + +from mcp.server.fastmcp import FastMCP + +from .. import audit, updates +from ..auth import current_client_id +from ..config import UpdatesConfig + + +def register_maintenance_tools( + mcp: FastMCP, + settings: UpdatesConfig | None = None, + *, + config_path: Path | None = None, +) -> None: + """Register ``beaconmcp_check_update`` and ``beaconmcp_self_update``. + + ``settings.enabled`` gates the whole module (no network egress at all); + ``settings.allow_self_update`` keeps the check but refuses to apply. + """ + settings = settings or UpdatesConfig() + if not settings.enabled: + return + + @mcp.tool() + def beaconmcp_check_update() -> dict: + """Check whether a newer BeaconMCP revision is available. + + Reports the running version, how many commits this install is + behind the upstream default branch, the changelog between the two, + and — importantly — any configuration the new revision knows about + that this install has not set yet (new ``.env`` variables, new + ``beaconmcp.yaml`` settings). + + Also returns the exact shell commands that would update *this* + install, which differ between a git checkout, a pip install and a + container. + + Read-only and safe to call at any time: it fetches git objects but + never modifies the working tree. Results are cached for a few hours; + this returns the cached answer when it is still fresh. + """ + info = updates.check_for_update(config_path=config_path) + payload = info.to_json() + payload["self_update_allowed"] = settings.allow_self_update + if info.can_self_update and not settings.allow_self_update: + payload["can_self_update"] = False + payload["blockers"] = [ + *payload.get("blockers", []), + "self-update is disabled by features.updates.allow_self_update", + ] + return payload + + if not settings.allow_self_update: + return + + @mcp.tool() + def beaconmcp_self_update(confirm: bool = False, restart: bool = True) -> dict: + """Update this BeaconMCP server to the latest upstream revision. + + Runs, in order: ``git pull --ff-only`` → reinstall the Python + package and its dependencies → **validate the configuration against + the new code** → schedule a service restart. + + The configuration check is a hard gate. If the new revision cannot + load the operator's config (because a setting was renamed, or a new + one is now required), the checkout is rolled back to exactly where + it started, dependencies are restored, and nothing is restarted. The + return value says so explicitly. + + Requires ``confirm=True``. Call ``beaconmcp_check_update`` first and + show the user what is about to change — including any new config + variables — before asking them to confirm. + + Refuses to run when the checkout has uncommitted changes, or when + this is not a git install; ``beaconmcp_check_update`` reports those + blockers in advance along with manual instructions. + + The restart is deliberately deferred a few seconds so this response + reaches you before the process dies. After that, expect the server + to be briefly unreachable. + """ + if not confirm: + info = updates.check_for_update(config_path=config_path) + return { + "ok": False, + "applied": False, + "reason": "confirmation_required", + "message": ( + "This will pull new code, reinstall dependencies and " + "restart the server. Review the pending changes, then " + "call again with confirm=True." + ), + "pending": info.to_json(), + } + + client_id = current_client_id() + audit.emit("maintenance.self_update.start", client_id=client_id) + result = updates.apply_update(restart=restart, config_path=config_path) + audit.emit( + "maintenance.self_update.finish", + client_id=client_id, + ok=result.ok, + from_ref=result.from_ref, + to_ref=result.to_ref, + rolled_back=result.rolled_back, + ) + # The next check must not serve a stale "update available". + updates.invalidate_cache() + + payload = result.to_json() + payload["applied"] = result.ok + return payload diff --git a/src/beaconmcp/server.py b/src/beaconmcp/server.py index 0265178..bcb3141 100644 --- a/src/beaconmcp/server.py +++ b/src/beaconmcp/server.py @@ -12,6 +12,7 @@ from .bmc import build_registry as build_bmc_registry from .bmc import register_bmc_tools from .config import Config +from .maintenance import register_maintenance_tools from .proxmox.aggregators import register_aggregator_tools from .proxmox.client import ProxmoxClient from .proxmox.monitoring import register_monitoring_tools @@ -290,3 +291,8 @@ def beaconmcp_context() -> str: if bmc_registry: register_bmc_tools(mcp, bmc_registry) register_security_tools(mcp) +# Not tied to any infrastructure capability: keeping the server itself +# current is useful on every deployment shape. +register_maintenance_tools( + mcp, config.features.updates, config_path=config.source_path, +) diff --git a/src/beaconmcp/updates.py b/src/beaconmcp/updates.py new file mode 100644 index 0000000..47f9c1e --- /dev/null +++ b/src/beaconmcp/updates.py @@ -0,0 +1,821 @@ +"""Update detection and self-update for BeaconMCP. + +BeaconMCP ships no PyPI package and cuts no releases: the canonical install +is a ``git clone`` at ``/opt/beaconmcp`` with a venv and a systemd unit (see +``deploy/install.sh``). So "is there an update?" means *is this checkout +behind the remote default branch?*, not "is there a newer version string". + +Three things live here: + +* :func:`detect_installation` -- how this server was installed, so the + advice we give matches reality instead of assuming everyone ran the + install script. +* :func:`check_for_update` -- a cached, fail-soft, read-only check. It also + diffs the *new* ``.env.example`` / ``beaconmcp.yaml.example`` against the + operator's actual files, which is how we can say "this update wants a + variable you haven't set" before they apply it. +* :func:`apply_update` -- pull, reinstall dependencies, **validate the + config**, and roll back if the new revision cannot load it. Restarting + into a config that refuses to parse would take the server down with no + one at the keyboard, so validation is a hard gate, not a warning. + +Nothing here ever raises into a caller: an air-gapped box, a missing git +binary or a detached HEAD all degrade to "couldn't check", never to a +broken dashboard or a failed tool call. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +_REPO_URL = "https://github.com/Showdown76py/BeaconMCP" + +#: How long a successful check stays fresh. Updates are not urgent and the +#: check shells out to git, so once every few hours is plenty. +CHECK_TTL_SECONDS = 6 * 3600 +#: Failures are retried sooner -- a transient DNS blip shouldn't mean six +#: hours of "unknown". +FAILED_CHECK_TTL_SECONDS = 15 * 60 + +#: Ceiling on any git/pip subprocess. `pip install -e .` on a cold cache is +#: the slow one; the rest finish in well under a second. +_GIT_TIMEOUT = 60 +_PIP_TIMEOUT = 900 + + +def current_version() -> str: + """Installed version string, preferring package metadata.""" + try: + from importlib.metadata import version + + return version("beaconmcp") + except Exception: # noqa: BLE001 - not installed as a distribution + from . import __version__ + + return __version__ + + +# --------------------------------------------------------------------------- +# Installation shape +# --------------------------------------------------------------------------- + +@dataclass +class Installation: + """How this particular server was installed.""" + + #: "git" (clone, the documented install), "pip" (installed as a + #: distribution from a URL/wheel), "docker", or "unknown". + kind: str + #: Root of the git checkout, when there is one. + root: Path | None + #: Interpreter running us -- also the venv's python when there is a venv. + python: str + #: Virtualenv prefix, or None when running against a system interpreter. + venv: Path | None + #: True when the package is imported straight from the checkout. + editable: bool + #: True when the process was started by systemd. + under_systemd: bool + #: systemd unit to restart, when we can name one. + service: str | None + #: True when running inside a container. + in_container: bool + + def to_json(self) -> dict[str, Any]: + return { + "kind": self.kind, + "root": str(self.root) if self.root else None, + "python": self.python, + "venv": str(self.venv) if self.venv else None, + "editable": self.editable, + "under_systemd": self.under_systemd, + "service": self.service, + "in_container": self.in_container, + } + + +def _package_root() -> Path: + """Directory holding ``src/beaconmcp`` -- i.e. the repo root when cloned.""" + # ...//src/beaconmcp/updates.py -> parents[2] == + return Path(__file__).resolve().parents[2] + + +def _detect_service() -> str | None: + """Name the systemd unit, if one is installed for us.""" + for candidate in ( + "/etc/systemd/system/beaconmcp.service", + "/lib/systemd/system/beaconmcp.service", + "/usr/lib/systemd/system/beaconmcp.service", + ): + if Path(candidate).is_file(): + return "beaconmcp" + return None + + +def detect_installation() -> Installation: + """Inspect the runtime to work out how BeaconMCP got here.""" + root = _package_root() + is_git = (root / ".git").exists() + venv = Path(sys.prefix) if sys.prefix != sys.base_prefix else None + # systemd exports INVOCATION_ID to every unit it starts; it is the one + # signal that does not require guessing at pid 1 or parsing /proc. + under_systemd = bool(os.environ.get("INVOCATION_ID")) + in_container = ( + Path("/.dockerenv").exists() + or os.environ.get("container") is not None + ) + + if is_git: + kind = "git" + elif in_container: + kind = "docker" + else: + try: + from importlib.metadata import distribution + + distribution("beaconmcp") + kind = "pip" + except Exception: # noqa: BLE001 + kind = "unknown" + + return Installation( + kind=kind, + root=root if is_git else None, + python=sys.executable, + venv=venv, + editable=is_git, + under_systemd=under_systemd, + service=_detect_service(), + in_container=in_container, + ) + + +# --------------------------------------------------------------------------- +# git plumbing +# --------------------------------------------------------------------------- + +def _git(root: Path, *args: str, timeout: int = _GIT_TIMEOUT) -> tuple[int, str, str]: + """Run a git command in ``root``. Never raises.""" + if not shutil.which("git"): + return 127, "", "git is not installed" + try: + proc = subprocess.run( + ["git", *args], + cwd=str(root), + capture_output=True, + text=True, + timeout=timeout, + # Never let git try to prompt for credentials: on a private + # remote it would hang until the timeout instead of failing. + env={**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": ""}, + ) + return proc.returncode, proc.stdout.strip(), proc.stderr.strip() + except subprocess.TimeoutExpired: + return 124, "", f"git {' '.join(args)} timed out" + except OSError as exc: + return 1, "", str(exc) + + +def _default_branch(root: Path) -> str: + """Remote default branch name, falling back to ``main``.""" + code, out, _ = _git(root, "symbolic-ref", "--short", "refs/remotes/origin/HEAD") + if code == 0 and out.startswith("origin/"): + return out.split("/", 1)[1] + # Not every clone has origin/HEAD set (shallow clones, older git). + code, out, _ = _git(root, "remote", "show", "origin") + if code == 0: + match = re.search(r"HEAD branch:\s*(\S+)", out) + if match: + return match.group(1) + return "main" + + +def working_tree_dirty(root: Path) -> bool: + """True when tracked files have uncommitted modifications.""" + code, out, _ = _git(root, "status", "--porcelain", "--untracked-files=no") + return code == 0 and bool(out) + + +# --------------------------------------------------------------------------- +# Config drift: what the new revision wants that the operator hasn't set +# --------------------------------------------------------------------------- + +_ENV_ASSIGNMENT = re.compile(r"^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=") + + +def _env_names(text: str) -> list[str]: + """Variable names assigned in a dotenv-style file (comments included). + + Comments count on purpose: ``.env.example`` documents optional settings + as ``# GEMINI_API_KEY=`` and those are exactly the ones an operator + wants to hear about after an update. + """ + names: list[str] = [] + for raw in text.splitlines(): + line = raw.lstrip() + if line.startswith("#"): + line = line.lstrip("#").lstrip() + match = _ENV_ASSIGNMENT.match(line) + if match: + names.append(match.group(1)) + return names + + +def _yaml_paths(text: str) -> set[str]: + """Dotted key paths in a YAML document, list items collapsed away.""" + try: + import yaml + + data = yaml.safe_load(text) + except Exception: # noqa: BLE001 - malformed example, nothing to diff + return set() + + paths: set[str] = set() + + def walk(node: Any, prefix: str) -> None: + if isinstance(node, dict): + for key, value in node.items(): + path = f"{prefix}.{key}" if prefix else str(key) + paths.add(path) + walk(value, path) + elif isinstance(node, list): + # Sequence entries are instances (nodes, hosts, devices), not + # settings -- their *shape* is what matters, so recurse without + # adding an index to the path. + for item in node: + walk(item, prefix) + + walk(data, "") + return paths + + +@dataclass +class ConfigDrift: + """Settings the incoming revision knows about and this install does not.""" + + new_env_vars: list[str] = field(default_factory=list) + new_config_keys: list[str] = field(default_factory=list) + + @property + def empty(self) -> bool: + return not self.new_env_vars and not self.new_config_keys + + def to_json(self) -> dict[str, Any]: + return { + "new_env_vars": self.new_env_vars, + "new_config_keys": self.new_config_keys, + } + + +def _read_local(root: Path, *names: str) -> str | None: + for name in names: + path = root / name + if path.is_file(): + try: + return path.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + return None + + +def config_drift(root: Path, ref: str, config_path: Path | None = None) -> ConfigDrift: + """Diff the example files at ``ref`` against what this install actually has. + + Deliberately compares against the operator's *real* files rather than + the local examples: someone who set ``GEMINI_API_KEY`` before it was + documented should not be told to set it again. + """ + drift = ConfigDrift() + + code, new_env, _ = _git(root, "show", f"{ref}:.env.example") + if code == 0: + local_env = _read_local(root, ".env") or "" + known = set(_env_names(local_env)) | set(os.environ) + for name in _env_names(new_env): + if name not in known and name not in drift.new_env_vars: + drift.new_env_vars.append(name) + + code, new_yaml, _ = _git(root, "show", f"{ref}:beaconmcp.yaml.example") + if code == 0: + local_yaml = None + if config_path and config_path.is_file(): + try: + local_yaml = config_path.read_text(encoding="utf-8", errors="replace") + except OSError: + local_yaml = None + if local_yaml is None: + local_yaml = _read_local(root, "beaconmcp.yaml") or "" + have = _yaml_paths(local_yaml) + # Anything the operator already configured, plus its ancestors, is + # "known"; only genuinely new leaves are worth reporting. + for path in sorted(_yaml_paths(new_yaml) - have): + if any(p.startswith(path + ".") for p in have): + continue # a parent of something already configured + drift.new_config_keys.append(path) + + return drift + + +# --------------------------------------------------------------------------- +# Update check +# --------------------------------------------------------------------------- + +@dataclass +class UpdateInfo: + """Result of one update check. Always renderable, even on failure.""" + + checked_at: float + available: bool = False + error: str | None = None + version: str = "" + install_kind: str = "unknown" + branch: str | None = None + current_ref: str | None = None + latest_ref: str | None = None + behind: int = 0 + commits: list[dict[str, str]] = field(default_factory=list) + drift: ConfigDrift = field(default_factory=ConfigDrift) + instructions: list[str] = field(default_factory=list) + can_self_update: bool = False + blockers: list[str] = field(default_factory=list) + repo_url: str = _REPO_URL + + def to_json(self) -> dict[str, Any]: + return { + "checked_at": self.checked_at, + "available": self.available, + "error": self.error, + "version": self.version, + "install_kind": self.install_kind, + "branch": self.branch, + "current_ref": self.current_ref, + "latest_ref": self.latest_ref, + "behind": self.behind, + "commits": self.commits, + "config": self.drift.to_json(), + "instructions": self.instructions, + "can_self_update": self.can_self_update, + "blockers": self.blockers, + "repo_url": self.repo_url, + "compare_url": ( + f"{self.repo_url}/compare/{self.current_ref}...{self.latest_ref}" + if self.current_ref and self.latest_ref and self.available + else None + ), + } + + +def manual_instructions(install: Installation) -> list[str]: + """Shell commands that update *this* install, in order.""" + if install.kind == "git" and install.root: + root = install.root + pip = ( + str(install.venv / "bin" / "pip") + if install.venv and (install.venv / "bin" / "pip").exists() + else f"{install.python} -m pip" + ) + steps = [f"cd {root}", "git pull --ff-only", f"{pip} install -e ."] + if install.service: + steps.append(f"systemctl restart {install.service}") + return steps + if install.kind == "docker": + return [ + "docker compose pull", + "docker compose up -d", + "# (or: docker pull && docker compose up -d)", + ] + if install.kind == "pip": + pip = f"{install.python} -m pip" + steps = [f"{pip} install --upgrade 'beaconmcp @ git+{_REPO_URL}.git'"] + if install.service: + steps.append(f"systemctl restart {install.service}") + return steps + return [ + "# Could not determine how BeaconMCP was installed here.", + "# Re-run the installer from a checkout: bash deploy/install.sh", + ] + + +def _self_update_blockers(install: Installation, root: Path | None) -> list[str]: + """Reasons ``apply_update`` would refuse, as operator-facing sentences.""" + blockers: list[str] = [] + if install.kind != "git" or root is None: + blockers.append( + f"this is a {install.kind} install, and automatic updates only " + "support a git checkout" + ) + return blockers + if not shutil.which("git"): + blockers.append("the git binary is not on PATH") + if working_tree_dirty(root): + blockers.append( + "the checkout has uncommitted changes -- commit or stash them " + "first so the update cannot discard your work" + ) + return blockers + + +_cache_lock = threading.Lock() +_cached: UpdateInfo | None = None + +#: Serializes the git work. Two entry points can reach this concurrently -- +#: the dashboard button and the MCP tool -- and two ``git pull`` / +#: ``pip install`` runs in one checkout would fight over index.lock and +#: could leave a half-applied tree. ``_apply_lock`` is never waited on: a +#: second updater is told one is already running rather than queueing +#: behind a pip that may take minutes. +_apply_lock = threading.Lock() +#: Held across an uncached check so N dashboard tabs opening at once cause +#: one ``git fetch``, not N. Waiters get the result the winner cached. +_check_lock = threading.RLock() + + +def check_for_update( + *, + force: bool = False, + config_path: Path | None = None, + install: Installation | None = None, +) -> UpdateInfo: + """Return update status, using a cached result when it is still fresh. + + Read-only: it fetches git objects (which never touches the working tree) + and shells out to ``git show``. Failures are captured in + :attr:`UpdateInfo.error`, never raised. + + ``install`` overrides autodetection; passing one also bypasses the + cache, since the cache is keyed on "this server" and nothing else. + """ + global _cached + + if install is not None: + return _check_uncached(config_path=config_path, install=install) + + def _fresh() -> UpdateInfo | None: + with _cache_lock: + cached = _cached + if cached is None: + return None + ttl = FAILED_CHECK_TTL_SECONDS if cached.error else CHECK_TTL_SECONDS + return cached if time.time() - cached.checked_at < ttl else None + + if not force: + hit = _fresh() + if hit is not None: + return hit + + with _check_lock: + # Someone may have refreshed it while we waited for the lock; a + # forced check still runs, since that is the point of forcing. + if not force: + hit = _fresh() + if hit is not None: + return hit + info = _check_uncached(config_path=config_path) + with _cache_lock: + _cached = info + return info + + +def cached_update() -> UpdateInfo | None: + """Last check result, without triggering a new one.""" + with _cache_lock: + return _cached + + +def invalidate_cache() -> None: + global _cached + with _cache_lock: + _cached = None + + +def _check_uncached( + *, config_path: Path | None = None, install: Installation | None = None, +) -> UpdateInfo: + install = install or detect_installation() + info = UpdateInfo( + checked_at=time.time(), + version=current_version(), + install_kind=install.kind, + instructions=manual_instructions(install), + ) + + root = install.root + if install.kind != "git" or root is None: + info.error = ( + f"cannot check automatically: this is a {install.kind} install, " + "not a git checkout" + ) + info.blockers = _self_update_blockers(install, root) + return info + + code, head, err = _git(root, "rev-parse", "--short", "HEAD") + if code != 0: + info.error = f"could not read the local revision ({err or 'git failed'})" + return info + info.current_ref = head + + branch = _default_branch(root) + info.branch = branch + + code, _, err = _git(root, "fetch", "--quiet", "origin", branch) + if code != 0: + info.error = f"could not reach the remote ({err or 'git fetch failed'})" + info.blockers = _self_update_blockers(install, root) + return info + + remote_ref = f"origin/{branch}" + code, latest, err = _git(root, "rev-parse", "--short", remote_ref) + if code != 0: + info.error = f"could not read {remote_ref} ({err or 'git failed'})" + return info + info.latest_ref = latest + + code, count, _ = _git(root, "rev-list", "--count", f"HEAD..{remote_ref}") + info.behind = int(count) if code == 0 and count.isdigit() else 0 + info.available = info.behind > 0 + + if not info.available: + return info + + code, log, _ = _git( + root, "log", "--no-merges", "--max-count=20", + "--pretty=format:%h\x1f%s\x1f%aI", f"HEAD..{remote_ref}", + ) + if code == 0 and log: + for line in log.splitlines(): + parts = line.split("\x1f") + if len(parts) == 3: + info.commits.append( + {"sha": parts[0], "subject": parts[1], "date": parts[2]} + ) + + info.drift = config_drift(root, remote_ref, config_path) + info.blockers = _self_update_blockers(install, root) + info.can_self_update = not info.blockers + return info + + +# --------------------------------------------------------------------------- +# Applying an update +# --------------------------------------------------------------------------- + +@dataclass +class UpdateStep: + name: str + ok: bool + detail: str = "" + + def to_json(self) -> dict[str, Any]: + return {"step": self.name, "ok": self.ok, "detail": self.detail} + + +@dataclass +class UpdateResult: + ok: bool + steps: list[UpdateStep] = field(default_factory=list) + from_ref: str | None = None + to_ref: str | None = None + rolled_back: bool = False + restart_scheduled: bool = False + restart_in_seconds: int = 0 + message: str = "" + drift: ConfigDrift = field(default_factory=ConfigDrift) + + def to_json(self) -> dict[str, Any]: + return { + "ok": self.ok, + "steps": [s.to_json() for s in self.steps], + "from_ref": self.from_ref, + "to_ref": self.to_ref, + "rolled_back": self.rolled_back, + "restart_scheduled": self.restart_scheduled, + "restart_in_seconds": self.restart_in_seconds, + "message": self.message, + "config": self.drift.to_json(), + } + + +def _pip_command(install: Installation) -> list[str]: + if install.venv: + for candidate in ( + install.venv / "bin" / "pip", + install.venv / "Scripts" / "pip.exe", + ): + if candidate.exists(): + return [str(candidate)] + return [install.python, "-m", "pip"] + + +def _run(cmd: list[str], cwd: Path, timeout: int) -> tuple[int, str]: + """Run a command, returning ``(returncode, combined output)``.""" + try: + proc = subprocess.run( + cmd, cwd=str(cwd), capture_output=True, text=True, timeout=timeout, + ) + except subprocess.TimeoutExpired: + return 124, f"{' '.join(cmd)} timed out after {timeout}s" + except OSError as exc: + return 1, str(exc) + output = (proc.stdout or "") + (proc.stderr or "") + return proc.returncode, output.strip() + + +def _tail(text: str, limit: int = 1500) -> str: + """Keep the end of a command's output -- that's where errors are.""" + text = text.strip() + return text if len(text) <= limit else "…" + text[-limit:] + + +def _schedule_restart(service: str, delay: int) -> bool: + """Restart the unit after ``delay`` seconds, detached from this process. + + A direct ``systemctl restart`` would kill us mid-response, so the caller + would never learn whether the update worked. Detaching and sleeping lets + the tool result (or the HTTP response) reach the client first. + """ + if not shutil.which("systemctl"): + return False + try: + subprocess.Popen( + # Values go through argv, never interpolated into the script: + # `service` is a literal today, but a future change that made it + # configurable must not turn this into a shell injection. + [ + "sh", "-c", 'sleep "$1"; systemctl restart "$2"', + "sh", str(int(delay)), service, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return True + except OSError: + return False + + +def apply_update( + *, + restart: bool = True, + restart_delay: int = 5, + config_path: Path | None = None, + install: Installation | None = None, +) -> UpdateResult: + """Pull, reinstall dependencies, validate the config, then restart. + + The config validation is a **gate**: if the new revision cannot load the + operator's configuration (a newly required setting, a renamed key), the + checkout is rolled back to where it started and nothing is restarted. + An unattended update that bricks the server is worse than no update. + """ + if not _apply_lock.acquire(blocking=False): + busy = UpdateResult(ok=False) + busy.message = ( + "An update is already running on this server. Wait for it to " + "finish before starting another one." + ) + busy.steps.append(UpdateStep("preflight", False, busy.message)) + return busy + try: + return _apply_update_locked( + restart=restart, + restart_delay=restart_delay, + config_path=config_path, + install=install, + ) + finally: + _apply_lock.release() + + +def _apply_update_locked( + *, + restart: bool, + restart_delay: int, + config_path: Path | None, + install: Installation | None, +) -> UpdateResult: + install = install or detect_installation() + result = UpdateResult(ok=False) + + root = install.root + blockers = _self_update_blockers(install, root) + if blockers or root is None: + result.message = "Refusing to update: " + "; ".join(blockers) + result.steps.append(UpdateStep("preflight", False, result.message)) + return result + result.steps.append(UpdateStep("preflight", True, "git checkout is clean")) + + code, from_ref, _ = _git(root, "rev-parse", "HEAD") + if code != 0: + result.message = "Could not read the current revision." + result.steps.append(UpdateStep("read-head", False, result.message)) + return result + result.from_ref = from_ref[:12] + + branch = _default_branch(root) + code, out, err = _git(root, "pull", "--ff-only", "origin", branch) + if code != 0: + detail = _tail(err or out) + result.message = ( + f"git pull failed: {detail}. Nothing was changed." + ) + result.steps.append(UpdateStep("git-pull", False, detail)) + return result + code, to_ref, _ = _git(root, "rev-parse", "HEAD") + result.to_ref = to_ref[:12] if code == 0 else None + result.steps.append( + UpdateStep("git-pull", True, f"{result.from_ref} -> {result.to_ref}") + ) + + if result.from_ref == result.to_ref: + result.ok = True + result.message = "Already up to date; nothing to do." + return result + + def _rollback(reason: str) -> UpdateResult: + code, out, err = _git(root, "reset", "--hard", from_ref) + rolled = code == 0 + if rolled: + # Put the dependency set back too, so a half-applied update + # doesn't leave newer libraries against older code. + _run([*_pip_command(install), "install", "-e", "."], root, _PIP_TIMEOUT) + result.rolled_back = rolled + result.steps.append( + UpdateStep( + "rollback", rolled, + f"restored {result.from_ref}" if rolled else _tail(err or out), + ) + ) + result.ok = False + result.message = reason + ( + " The checkout was rolled back and the server was NOT restarted." + if rolled + else " ROLLBACK FAILED -- fix the checkout by hand before restarting." + ) + return result + + code, out = _run( + [*_pip_command(install), "install", "-e", "."], root, _PIP_TIMEOUT, + ) + if code != 0: + result.steps.append(UpdateStep("pip-install", False, _tail(out))) + return _rollback(f"Dependency install failed: {_tail(out, 400)}.") + result.steps.append(UpdateStep("pip-install", True, "dependencies up to date")) + + # Config gate. Run in a subprocess so the *new* code parses the config, + # not the copy this process imported at boot. + validate = [install.python, "-m", "beaconmcp", "validate-config"] + if config_path: + validate += ["--config", str(config_path)] + code, out = _run(validate, root, _GIT_TIMEOUT) + if code != 0: + result.steps.append(UpdateStep("validate-config", False, _tail(out))) + return _rollback( + f"The new revision cannot load your configuration: {_tail(out, 600)}" + ) + result.steps.append(UpdateStep("validate-config", True, "config still loads")) + + result.drift = config_drift(root, "HEAD", config_path) + result.ok = True + + if restart and install.service: + scheduled = _schedule_restart(install.service, restart_delay) + result.restart_scheduled = scheduled + result.restart_in_seconds = restart_delay if scheduled else 0 + result.steps.append( + UpdateStep( + "restart", scheduled, + f"systemctl restart {install.service} in {restart_delay}s" + if scheduled else "could not schedule a restart (no systemctl)", + ) + ) + + bits = [f"Updated {result.from_ref} -> {result.to_ref}."] + if result.restart_scheduled: + bits.append( + f"The service restarts in {restart_delay}s to run the new code." + ) + elif install.service: + bits.append(f"Restart it with: systemctl restart {install.service}") + else: + bits.append("Restart the server process to run the new code.") + if result.drift.new_env_vars: + bits.append( + "New environment variables you may need to set in .env: " + + ", ".join(result.drift.new_env_vars) + ) + if result.drift.new_config_keys: + bits.append( + "New beaconmcp.yaml settings are available: " + + ", ".join(result.drift.new_config_keys[:10]) + ) + result.message = " ".join(bits) + return result diff --git a/tests/test_updates.py b/tests/test_updates.py new file mode 100644 index 0000000..4ebc9a2 --- /dev/null +++ b/tests/test_updates.py @@ -0,0 +1,907 @@ +"""Update detection / self-update tests. + +git operations run for real against throwaway repositories built in +``tmp_path``: the whole point of this module is that it drives git +correctly, and a mocked ``subprocess.run`` would only prove the mock +agrees with itself. The two genuinely unsafe steps -- ``pip install`` and +the config validation subprocess -- are the ones we stub, so the tests +exercise the *orchestration* (pull, validate, roll back) without touching +the interpreter this suite runs in. + +Run with:: + + pytest tests/test_updates.py -v +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from beaconmcp import updates # noqa: E402 +from beaconmcp.auth import TotpResult # noqa: E402 +from beaconmcp.config import UpdatesConfig # noqa: E402 +from beaconmcp.dashboard.app import ( # noqa: E402 + DashboardDeps, + build_dashboard_routes, +) +from beaconmcp.dashboard.csrf import CSRF_COOKIE # noqa: E402 +from beaconmcp.dashboard.db import Database # noqa: E402 +from beaconmcp.dashboard.session import SessionStore # noqa: E402 +from beaconmcp.updates import Installation # noqa: E402 + + +pytestmark = pytest.mark.skipif( + shutil.which("git") is None, reason="git is required for update tests" +) + + +# --------------------------------------------------------------------------- +# git fixtures +# --------------------------------------------------------------------------- + +def _run(*args: str, cwd: Path) -> None: + subprocess.run( + list(args), cwd=str(cwd), check=True, + capture_output=True, text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + + +def _commit(repo: Path, message: str, files: dict[str, str] | None = None) -> None: + for name, content in (files or {}).items(): + (repo / name).write_text(content, encoding="utf-8") + _run("git", "add", name, cwd=repo) + _run("git", "commit", "--allow-empty", "-m", message, cwd=repo) + + +@pytest.fixture() +def upstream(tmp_path: Path) -> Path: + """An 'upstream' repo standing in for GitHub.""" + repo = tmp_path / "upstream" + repo.mkdir() + _run("git", "init", "--initial-branch=main", cwd=repo) + _run("git", "config", "user.email", "t@example.com", cwd=repo) + _run("git", "config", "user.name", "Test", cwd=repo) + _commit(repo, "initial", { + ".env.example": "BEACONMCP_SESSION_KEY=\n# GEMINI_API_KEY=\n", + "beaconmcp.yaml.example": "server:\n port: 8420\n", + "pyproject.toml": "[project]\nname = 'x'\n", + }) + return repo + + +@pytest.fixture() +def checkout(tmp_path: Path, upstream: Path) -> Path: + """A clone of ``upstream``, i.e. what /opt/beaconmcp looks like.""" + local = tmp_path / "local" + _run("git", "clone", str(upstream), str(local), cwd=tmp_path) + _run("git", "config", "user.email", "t@example.com", cwd=local) + _run("git", "config", "user.name", "Test", cwd=local) + return local + + +def _install(root: Path) -> Installation: + return Installation( + kind="git", root=root, python=sys.executable, venv=None, + editable=True, under_systemd=False, service=None, in_container=False, + ) + + +def _advance(upstream: Path, message: str, files: dict[str, str] | None = None) -> None: + """Push a new commit upstream so the checkout falls behind.""" + _commit(upstream, message, files) + + +# --------------------------------------------------------------------------- +# Installation detection +# --------------------------------------------------------------------------- + +def test_detects_this_checkout_as_git(): + install = updates.detect_installation() + assert install.kind == "git" + assert install.root is not None and (install.root / ".git").exists() + + +def test_systemd_detected_from_invocation_id(monkeypatch): + monkeypatch.setenv("INVOCATION_ID", "deadbeef") + assert updates.detect_installation().under_systemd is True + monkeypatch.delenv("INVOCATION_ID") + assert updates.detect_installation().under_systemd is False + + +def test_current_version_is_not_the_stale_placeholder(): + # __init__ used to hard-code 0.1.0 while pyproject said 2.0.0. + assert updates.current_version() != "0.1.0" + + +# --------------------------------------------------------------------------- +# Instructions per install kind +# --------------------------------------------------------------------------- + +def test_git_instructions_include_pull_and_install(tmp_path): + steps = updates.manual_instructions(_install(tmp_path)) + assert any("git pull" in s for s in steps) + assert any("install -e ." in s for s in steps) + assert not any("systemctl" in s for s in steps) + + +def test_git_instructions_add_restart_when_a_service_exists(tmp_path): + install = _install(tmp_path) + install.service = "beaconmcp" + steps = updates.manual_instructions(install) + assert steps[-1] == "systemctl restart beaconmcp" + + +def test_git_instructions_prefer_the_venv_pip(tmp_path): + venv = tmp_path / "venv" + (venv / "bin").mkdir(parents=True) + (venv / "bin" / "pip").write_text("#!/bin/sh\n") + install = _install(tmp_path) + install.venv = venv + steps = updates.manual_instructions(install) + assert str(venv / "bin" / "pip") in " ".join(steps) + + +def test_docker_instructions_do_not_mention_pip(): + install = Installation( + kind="docker", root=None, python=sys.executable, venv=None, + editable=False, under_systemd=False, service=None, in_container=True, + ) + steps = updates.manual_instructions(install) + assert any("docker" in s for s in steps) + assert not any("pip install" in s for s in steps) + + +def test_pip_instructions_point_at_the_git_url(): + install = Installation( + kind="pip", root=None, python="/usr/bin/python3", venv=None, + editable=False, under_systemd=False, service=None, in_container=False, + ) + steps = updates.manual_instructions(install) + assert any("git+https://github.com/Showdown76py/BeaconMCP" in s for s in steps) + + +# --------------------------------------------------------------------------- +# Check +# --------------------------------------------------------------------------- + +def test_up_to_date_checkout_reports_no_update(checkout): + info = updates.check_for_update(install=_install(checkout)) + assert info.error is None + assert info.available is False + assert info.behind == 0 + assert info.current_ref == info.latest_ref + + +def test_behind_checkout_reports_commits_and_log(checkout, upstream): + _advance(upstream, "feat: shiny thing") + _advance(upstream, "fix: subtle bug") + info = updates.check_for_update(install=_install(checkout)) + assert info.available is True + assert info.behind == 2 + assert [c["subject"] for c in info.commits] == [ + "fix: subtle bug", "feat: shiny thing", + ] + assert info.can_self_update is True + assert info.blockers == [] + assert info.to_json()["compare_url"].endswith( + f"{info.current_ref}...{info.latest_ref}" + ) + + +def test_dirty_checkout_blocks_self_update(checkout, upstream): + _advance(upstream, "feat: thing") + (checkout / "pyproject.toml").write_text("[project]\nname = 'edited'\n") + info = updates.check_for_update(install=_install(checkout)) + assert info.available is True + assert info.can_self_update is False + assert any("uncommitted" in b for b in info.blockers) + + +def test_non_git_install_reports_why_it_cannot_check(): + install = Installation( + kind="pip", root=None, python=sys.executable, venv=None, + editable=False, under_systemd=False, service=None, in_container=False, + ) + info = updates.check_for_update(install=install) + assert info.available is False + assert "pip install" in (info.error or "") or "not a git checkout" in (info.error or "") + assert info.can_self_update is False + + +def test_unreachable_remote_degrades_to_an_error(checkout, tmp_path): + _run("git", "remote", "set-url", "origin", str(tmp_path / "gone"), cwd=checkout) + info = updates.check_for_update(install=_install(checkout)) + assert info.available is False + assert info.error and "remote" in info.error + + +def test_check_result_is_cached(monkeypatch): + updates.invalidate_cache() + calls = [] + + def fake(**kwargs): + calls.append(1) + return updates.UpdateInfo(checked_at=time.time()) + + monkeypatch.setattr(updates, "_check_uncached", fake) + updates.check_for_update() + updates.check_for_update() + assert len(calls) == 1 + updates.check_for_update(force=True) + assert len(calls) == 2 + updates.invalidate_cache() + + +# --------------------------------------------------------------------------- +# Config drift +# --------------------------------------------------------------------------- + +def test_env_names_include_commented_examples(): + text = "A=1\n# B=\nexport C=3\n#not_a_var\n" + assert updates._env_names(text) == ["A", "B", "C"] + + +def test_yaml_paths_are_dotted_and_ignore_list_indices(): + paths = updates._yaml_paths("a:\n b: 1\nlist:\n - x: 1\n") + assert "a" in paths and "a.b" in paths + assert "list" in paths and "list.x" in paths + + +def test_drift_reports_new_env_var(checkout, upstream, monkeypatch): + monkeypatch.delenv("NEW_SECRET", raising=False) + (checkout / ".env").write_text("BEACONMCP_SESSION_KEY=abc\n") + _advance(upstream, "feat: new secret", { + ".env.example": "BEACONMCP_SESSION_KEY=\nNEW_SECRET=\n", + }) + info = updates.check_for_update(install=_install(checkout)) + assert info.drift.new_env_vars == ["NEW_SECRET"] + + +def test_drift_ignores_vars_already_set_in_the_environment( + checkout, upstream, monkeypatch, +): + monkeypatch.setenv("NEW_SECRET", "already-there") + (checkout / ".env").write_text("BEACONMCP_SESSION_KEY=abc\n") + _advance(upstream, "feat: new secret", { + ".env.example": "BEACONMCP_SESSION_KEY=\nNEW_SECRET=\n", + }) + info = updates.check_for_update(install=_install(checkout)) + assert info.drift.new_env_vars == [] + + +def test_drift_reports_new_yaml_settings(checkout, upstream): + (checkout / "beaconmcp.yaml").write_text("server:\n port: 8420\n") + _advance(upstream, "feat: knob", { + "beaconmcp.yaml.example": "server:\n port: 8420\nfeatures:\n updates:\n enabled: true\n", + }) + info = updates.check_for_update(install=_install(checkout)) + assert "features" in info.drift.new_config_keys + assert "features.updates.enabled" in info.drift.new_config_keys + + +def test_drift_is_empty_when_nothing_new(checkout, upstream): + (checkout / "beaconmcp.yaml").write_text("server:\n port: 8420\n") + (checkout / ".env").write_text("BEACONMCP_SESSION_KEY=abc\nGEMINI_API_KEY=x\n") + _advance(upstream, "docs: typo") + info = updates.check_for_update(install=_install(checkout)) + assert info.drift.empty + + +# --------------------------------------------------------------------------- +# Apply +# --------------------------------------------------------------------------- + +@pytest.fixture() +def stub_side_effects(monkeypatch): + """Stub pip + validate-config; git stays real. + + Returns the recorded command list plus a dict the test mutates to make + a given step fail. + """ + recorded: list[list[str]] = [] + outcomes = {"pip": 0, "validate": 0} + + def fake_run(cmd, cwd, timeout): + recorded.append(list(cmd)) + if "pip" in " ".join(cmd): + return outcomes["pip"], "pip output" + if "validate-config" in cmd: + return outcomes["validate"], "config error: BEACONMCP_NEW_KEY is required" + return 0, "" + + monkeypatch.setattr(updates, "_run", fake_run) + monkeypatch.setattr(updates, "_schedule_restart", lambda service, delay: True) + return recorded, outcomes + + +def test_apply_refuses_a_dirty_checkout(checkout, upstream, stub_side_effects): + _advance(upstream, "feat: thing") + (checkout / "pyproject.toml").write_text("[project]\nname = 'edited'\n") + result = updates.apply_update(install=_install(checkout)) + assert result.ok is False + assert "uncommitted" in result.message + assert result.steps[0].name == "preflight" and result.steps[0].ok is False + + +def test_apply_refuses_a_non_git_install(stub_side_effects): + install = Installation( + kind="docker", root=None, python=sys.executable, venv=None, + editable=False, under_systemd=False, service=None, in_container=True, + ) + result = updates.apply_update(install=install) + assert result.ok is False + assert "docker install" in result.message + + +def test_apply_is_a_noop_when_already_current(checkout, stub_side_effects): + result = updates.apply_update(install=_install(checkout)) + assert result.ok is True + assert "Already up to date" in result.message + + +def test_apply_pulls_installs_validates_and_restarts( + checkout, upstream, stub_side_effects, +): + recorded, _ = stub_side_effects + _advance(upstream, "feat: shiny") + install = _install(checkout) + install.service = "beaconmcp" + + result = updates.apply_update(install=install, restart_delay=3) + + assert result.ok is True + assert result.rolled_back is False + assert result.from_ref != result.to_ref + names = [s.name for s in result.steps] + assert names == [ + "preflight", "git-pull", "pip-install", "validate-config", "restart", + ] + # The pull actually moved the checkout. + head = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=str(checkout), + capture_output=True, text=True, + ).stdout.strip() + assert head.startswith(result.to_ref) + assert result.restart_scheduled is True + assert result.restart_in_seconds == 3 + assert any("validate-config" in c for cmd in recorded for c in cmd) + + +def test_apply_rolls_back_when_the_new_code_rejects_the_config( + checkout, upstream, stub_side_effects, +): + recorded, outcomes = stub_side_effects + outcomes["validate"] = 1 + _advance(upstream, "feat: needs a new setting") + before = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=str(checkout), + capture_output=True, text=True, + ).stdout.strip() + + result = updates.apply_update(install=_install(checkout)) + + assert result.ok is False + assert result.rolled_back is True + assert "cannot load your configuration" in result.message + assert "was NOT restarted" in result.message + assert "BEACONMCP_NEW_KEY" in result.message + after = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=str(checkout), + capture_output=True, text=True, + ).stdout.strip() + assert after == before, "checkout must be back where it started" + + +def test_apply_rolls_back_when_dependencies_fail( + checkout, upstream, stub_side_effects, +): + _, outcomes = stub_side_effects + outcomes["pip"] = 1 + _advance(upstream, "feat: new dep") + before = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=str(checkout), + capture_output=True, text=True, + ).stdout.strip() + + result = updates.apply_update(install=_install(checkout)) + + assert result.ok is False + assert result.rolled_back is True + assert "Dependency install failed" in result.message + after = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=str(checkout), + capture_output=True, text=True, + ).stdout.strip() + assert after == before + + +def test_apply_reports_new_config_after_success( + checkout, upstream, stub_side_effects, +): + (checkout / ".env").write_text("BEACONMCP_SESSION_KEY=abc\n") + _advance(upstream, "feat: new knob", { + ".env.example": "BEACONMCP_SESSION_KEY=\nBEACONMCP_NEW_THING=\n", + }) + result = updates.apply_update(install=_install(checkout)) + assert result.ok is True + assert result.drift.new_env_vars == ["BEACONMCP_NEW_THING"] + assert "BEACONMCP_NEW_THING" in result.message + + +def test_apply_without_a_service_tells_you_to_restart( + checkout, upstream, stub_side_effects, +): + _advance(upstream, "feat: thing") + result = updates.apply_update(install=_install(checkout)) + assert result.ok is True + assert result.restart_scheduled is False + assert "Restart the server process" in result.message + + +# --------------------------------------------------------------------------- +# Concurrency +# +# Two entry points reach this code: the dashboard button and the MCP tool. +# Two `git pull` / `pip install` runs in one checkout would fight over +# index.lock and could leave a half-applied tree. +# --------------------------------------------------------------------------- + +def test_a_second_update_is_refused_while_one_runs(checkout, upstream, monkeypatch): + import threading + + _advance(upstream, "feat: thing") + started = threading.Event() + release = threading.Event() + second: dict[str, object] = {} + + real_run = updates._run + + def blocking_run(cmd, cwd, timeout): + if "pip" in " ".join(cmd): + started.set() + release.wait(10) + return 0, "pip output" + if "validate-config" in cmd: + return 0, "" + return real_run(cmd, cwd, timeout) + + monkeypatch.setattr(updates, "_run", blocking_run) + monkeypatch.setattr(updates, "_schedule_restart", lambda service, delay: True) + + install = _install(checkout) + first: dict[str, object] = {} + + def run_first(): + first["result"] = updates.apply_update(install=install) + + t = threading.Thread(target=run_first) + t.start() + try: + assert started.wait(10), "first update never reached the pip step" + # A second caller must be told, not queued behind a long pip run. + second["result"] = updates.apply_update(install=install) + finally: + release.set() + t.join(15) + + busy = second["result"] + assert busy.ok is False + assert "already running" in busy.message + assert busy.from_ref is None, "the refused caller must not touch git" + assert first["result"].ok is True + + +def test_the_lock_is_released_after_a_failure(checkout, upstream, stub_side_effects): + _, outcomes = stub_side_effects + outcomes["validate"] = 1 + _advance(upstream, "feat: thing") + first = updates.apply_update(install=_install(checkout)) + assert first.ok is False and first.rolled_back is True + # Second call must run, not report "already running". + second = updates.apply_update(install=_install(checkout)) + assert "already running" not in second.message + + +def test_concurrent_checks_fetch_once(monkeypatch): + import threading + + updates.invalidate_cache() + calls = [] + gate = threading.Event() + + def slow_check(**kwargs): + calls.append(1) + gate.wait(5) + return updates.UpdateInfo(checked_at=time.time()) + + monkeypatch.setattr(updates, "_check_uncached", slow_check) + threads = [ + threading.Thread(target=lambda: updates.check_for_update()) + for _ in range(4) + ] + for t in threads: + t.start() + time.sleep(0.4) + gate.set() + for t in threads: + t.join(10) + + assert len(calls) == 1, f"expected one fetch, got {len(calls)}" + updates.invalidate_cache() + + +def test_restart_command_passes_values_through_argv(monkeypatch): + """No shell interpolation, so a future configurable unit name is safe.""" + seen = {} + + class FakePopen: + def __init__(self, cmd, **kwargs): + seen["cmd"] = cmd + + monkeypatch.setattr(updates.shutil, "which", lambda name: "/bin/systemctl") + monkeypatch.setattr(updates.subprocess, "Popen", FakePopen) + assert updates._schedule_restart("beaconmcp; rm -rf /", 5) is True + cmd = seen["cmd"] + # The dangerous string is an argument, never part of the script. + assert "beaconmcp; rm -rf /" in cmd + assert "rm -rf" not in cmd[2] + assert cmd[2] == 'sleep "$1"; systemctl restart "$2"' + + +# --------------------------------------------------------------------------- +# Dashboard endpoints +# --------------------------------------------------------------------------- + +class FakeClientStore: + def __init__(self): + self.clients = { + "beaconmcp_test": { + "secret": "sk_test", "name": "Test Client", "totp": "123456", + } + } + + def verify(self, client_id, secret): + c = self.clients.get(client_id) + return bool(c and c["secret"] == secret) + + def check_totp(self, client_id, code): + c = self.clients.get(client_id) + return TotpResult.OK if (c and c["totp"] == code) else TotpResult.INVALID + + def get_name(self, client_id): + c = self.clients.get(client_id) + return c["name"] if c else None + + +class FakeTokenStore: + def __init__(self): + self._tokens: dict[str, str] = {} + + def issue(self, client_id, name=None): + token = f"bearer_{len(self._tokens)}" + self._tokens[token] = client_id + return token, 86400 + + def validate(self, token): + return self._tokens.get(token) + + def revoke(self, token): + self._tokens.pop(token, None) + return True + + def list_named(self, client_id): + return [] + + +def _make_client(tmp_path, monkeypatch, **overrides): + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "d.db")) + db = Database(tmp_path / "d.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + **overrides, + ) + client = TestClient( + Starlette(routes=build_dashboard_routes(deps)), follow_redirects=False, + ) + return client, deps + + +def _sign_in(client) -> str: + client.get("/app/login") + token = client.cookies.get(CSRF_COOKIE) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "123456", + }, + headers={"X-CSRF-Token": token, "X-BeaconMCP-Mode": "json"}, + ) + assert res.status_code == 200, res.text + return res.json()["csrf_token"] + + +def test_update_status_requires_a_session(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + assert client.get("/app/api/update").status_code == 401 + + +def test_update_status_returns_the_check(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + _sign_in(client) + monkeypatch.setattr( + updates, "check_for_update", + lambda **kw: updates.UpdateInfo( + checked_at=time.time(), available=True, behind=3, branch="main", + current_ref="aaaaaaa", latest_ref="bbbbbbb", install_kind="git", + can_self_update=True, instructions=["git pull --ff-only"], + ), + ) + body = client.get("/app/api/update").json() + assert body["enabled"] is True + assert body["available"] is True and body["behind"] == 3 + assert body["self_update_allowed"] is True + + +def test_update_status_is_inert_when_disabled(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch, updates_enabled=False) + _sign_in(client) + body = client.get("/app/api/update").json() + assert body == {"enabled": False, "available": False} + + +def test_status_hides_self_update_when_not_allowed(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch, allow_self_update=False) + _sign_in(client) + monkeypatch.setattr( + updates, "check_for_update", + lambda **kw: updates.UpdateInfo( + checked_at=time.time(), available=True, can_self_update=True, + ), + ) + body = client.get("/app/api/update").json() + assert body["can_self_update"] is False + assert body["self_update_allowed"] is False + + +def test_apply_requires_csrf(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + _sign_in(client) + res = client.post("/app/api/update/apply", json={"totp": "123456"}) + assert res.status_code == 403 + + +def test_apply_requires_a_fresh_totp(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + token = _sign_in(client) + res = client.post( + "/app/api/update/apply", json={"totp": "000000"}, + headers={"X-CSRF-Token": token}, + ) + assert res.status_code == 401 + assert "2FA" in res.json()["error"] + + res = client.post( + "/app/api/update/apply", json={}, + headers={"X-CSRF-Token": token}, + ) + assert res.status_code == 400 + + +def test_apply_refused_when_self_update_is_disabled(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch, allow_self_update=False) + token = _sign_in(client) + res = client.post( + "/app/api/update/apply", json={"totp": "123456"}, + headers={"X-CSRF-Token": token}, + ) + assert res.status_code == 403 + assert "disabled" in res.json()["error"] + + +def test_apply_runs_the_update_with_a_valid_code(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + token = _sign_in(client) + seen = {} + + def fake_apply(**kwargs): + seen.update(kwargs) + return updates.UpdateResult( + ok=True, from_ref="aaaaaaa", to_ref="bbbbbbb", + restart_scheduled=True, restart_in_seconds=5, message="Updated.", + ) + + monkeypatch.setattr(updates, "apply_update", fake_apply) + res = client.post( + "/app/api/update/apply", json={"totp": "123456"}, + headers={"X-CSRF-Token": token}, + ) + assert res.status_code == 200, res.text + body = res.json() + assert body["ok"] is True and body["restart_scheduled"] is True + assert "config_path" in seen + + +def test_failed_apply_surfaces_a_500(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + token = _sign_in(client) + monkeypatch.setattr( + updates, "apply_update", + lambda **kw: updates.UpdateResult( + ok=False, rolled_back=True, message="rolled back", + ), + ) + res = client.post( + "/app/api/update/apply", json={"totp": "123456"}, + headers={"X-CSRF-Token": token}, + ) + assert res.status_code == 500 + assert res.json()["rolled_back"] is True + + +def test_every_page_carries_the_toast_and_its_script(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + body = client.get("/app/login").text + assert 'id="update-toast"' in body + assert "/app/static/update_banner.js" in body + + +def test_signed_in_screen_has_a_slot_for_the_update_mention(tmp_path, monkeypatch): + """The toast opts out of the auth pages; login.js fills this instead.""" + client, _ = _make_client(tmp_path, monkeypatch) + assert 'id="update-note"' in client.get("/app/login").text + + +# --------------------------------------------------------------------------- +# Asset cache busting +# +# A server that updates itself must not leave browsers executing the +# previous release's JavaScript, which is what Starlette's default +# (ETag/Last-Modified but no Cache-Control -> heuristic freshness) allows. +# --------------------------------------------------------------------------- + +def test_asset_urls_carry_a_fingerprint(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + body = client.get("/app/login").text + for asset in ("app.css", "theme.js", "update_banner.js", "login.js"): + assert f"/app/static/{asset}?v=" in body, asset + + +def test_fingerprint_changes_when_an_asset_changes(tmp_path, monkeypatch): + from beaconmcp.dashboard import app as dashboard_app + + before = dashboard_app._compute_asset_version() + target = dashboard_app._DASHBOARD_DIR / "static" / "app.css" + original = target.stat() + try: + os.utime(target, (original.st_atime, original.st_mtime + 120)) + assert dashboard_app._compute_asset_version() != before + finally: + os.utime(target, (original.st_atime, original.st_mtime)) + assert dashboard_app._compute_asset_version() == before + + +def test_versioned_assets_are_cacheable_forever(tmp_path, monkeypatch): + from beaconmcp.dashboard.app import ASSET_VERSION + + client, _ = _make_client(tmp_path, monkeypatch) + res = client.get(f"/app/static/app.css?v={ASSET_VERSION}") + assert res.status_code == 200 + assert "immutable" in res.headers["cache-control"] + + +def test_unversioned_assets_must_revalidate(tmp_path, monkeypatch): + """A hand-typed or legacy URL can never pin stale code.""" + client, _ = _make_client(tmp_path, monkeypatch) + res = client.get("/app/static/app.css") + assert res.status_code == 200 + assert res.headers["cache-control"] == "no-cache" + + +def test_pages_and_api_are_never_cached(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path, monkeypatch) + assert client.get("/app/login").headers["cache-control"] == "no-store" + _sign_in(client) + assert client.get("/app/api/update").headers["cache-control"] == "no-store" + + +# --------------------------------------------------------------------------- +# MCP tools +# --------------------------------------------------------------------------- + +class _RecordingMCP: + """Minimal stand-in for FastMCP that just collects registrations.""" + + def __init__(self): + self.tools: dict[str, object] = {} + + def tool(self, *args, **kwargs): + def decorator(func): + self.tools[func.__name__] = func + return func + + return decorator + + +def test_tools_are_not_registered_when_updates_are_disabled(): + from beaconmcp.maintenance import register_maintenance_tools + + mcp = _RecordingMCP() + register_maintenance_tools(mcp, UpdatesConfig(enabled=False)) + assert mcp.tools == {} + + +def test_only_the_check_tool_when_self_update_is_disabled(): + from beaconmcp.maintenance import register_maintenance_tools + + mcp = _RecordingMCP() + register_maintenance_tools(mcp, UpdatesConfig(allow_self_update=False)) + assert set(mcp.tools) == {"beaconmcp_check_update"} + + +def test_both_tools_by_default(): + from beaconmcp.maintenance import register_maintenance_tools + + mcp = _RecordingMCP() + register_maintenance_tools(mcp, UpdatesConfig()) + assert set(mcp.tools) == {"beaconmcp_check_update", "beaconmcp_self_update"} + + +def test_self_update_tool_requires_confirmation(monkeypatch): + from beaconmcp.maintenance import register_maintenance_tools + + mcp = _RecordingMCP() + register_maintenance_tools(mcp, UpdatesConfig()) + monkeypatch.setattr( + updates, "check_for_update", + lambda **kw: updates.UpdateInfo(checked_at=time.time(), available=True), + ) + called = [] + monkeypatch.setattr( + updates, "apply_update", + lambda **kw: called.append(1) or updates.UpdateResult(ok=True), + ) + + out = mcp.tools["beaconmcp_self_update"]() + assert out["ok"] is False + assert out["reason"] == "confirmation_required" + assert out["pending"]["available"] is True + assert called == [], "must not touch the checkout without confirm=True" + + out = mcp.tools["beaconmcp_self_update"](confirm=True) + assert called == [1] + assert out["applied"] is True + + +def test_check_tool_reports_the_self_update_policy(monkeypatch): + from beaconmcp.maintenance import register_maintenance_tools + + mcp = _RecordingMCP() + register_maintenance_tools(mcp, UpdatesConfig(allow_self_update=False)) + monkeypatch.setattr( + updates, "check_for_update", + lambda **kw: updates.UpdateInfo( + checked_at=time.time(), available=True, can_self_update=True, + ), + ) + out = mcp.tools["beaconmcp_check_update"]() + assert out["can_self_update"] is False + assert any("allow_self_update" in b for b in out["blockers"]) From 87caf1afb10451dec32e10c3443c9335a3f609f3 Mon Sep 17 00:00:00 2001 From: Lony <66854264+Showdown76py@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:52:08 +0200 Subject: [PATCH 152/155] feat: interactive panels via the MCP Apps extension (#34) * feat(proxmox): interactive VM panel via the MCP Apps extension proxmox_vm_panel carries _meta.ui.resourceUri pointing at a ui:// resource served as text/html;profile=mcp-app, which an Apps-capable client renders in a sandboxed iframe: live CPU/RAM/disk, start/stop/restart, and fields for core count and memory. Runs on mcp 1.x. The Apps class that wraps this lives in 2.0 and needs MCPServer, but the two knobs it sets -- meta= on the tool, mime_type= on the resource -- are already on FastMCP, so the panel does not wait on that migration. The panel holds no cluster access of its own. Its buttons issue ordinary tools/call requests for proxmox_vm_start / _stop / _restart / _config, so the client's approval prompt still stands in front of every action. Clients that skipped the extension ignore _meta.ui and get the same snapshot as data, which is why the tool returns the full state rather than a placeholder. Verified against a harness that speaks the host side of the protocol: handshake, initial render, power actions with refresh, config apply sending only changed keys, tool errors surfacing without wedging the controls, and the theme switch. * fix(panel): send ui/initialize params flat, as the host expects The handshake nested appCapabilities under a `capabilities` key and sent `appInfo` as `clientInfo`. The real shape, per the ext-apps App.connect() implementation, is flat: appInfo / appCapabilities / protocolVersion. A rejected handshake is silent -- the host simply does not reply. So the promise never settled, `ui/notifications/initialized` never went out, the host never delivered `ui/notifications/tool-result`, and the panel sat on "Loading..." with an empty frame and nothing in the console. That is what showed up in Claude: the host reported the widget as rendered while the iframe stayed blank. Also surface the failure instead of hanging on it. A handshake that goes unanswered for 5s now replaces the spinner with the reason, so the next protocol mismatch is one glance rather than an afternoon. The browser harness this was first tested against replied to any ui/initialize it received, which is why the bad shape passed. It now validates the params like a host does, and the new test pins the flat shape against the shipped HTML -- it fails on the old file. * feat(panels): log viewer, cluster dashboard, and model-context sync Three additions on top of the VM panel. proxmox_logs_panel renders a node's syslog or task history as a scrollable list with level and substring filters, error and warning lines coloured, and a fullscreen request. Logs are the worst thing to read through a chat transcript: the model summarises them and the lines you wanted are gone. cluster_overview_interactive is cluster_overview as a browsable panel -- node cards with CPU and memory pressure, a searchable guest table with inline start/stop, storage pools with usage bars. It reuses the aggregators' collection helpers rather than re-querying Proxmox its own way. Both panels, and now the VM panel, push state back with ui/update-model-context after an action. Without it the model keeps whatever the tool returned when the panel opened, so stopping a VM from the panel left the next turn believing it was still running. Where the host advertises ui/message, the VM panel offers an "ask about this guest" button and the dashboard an "open" button per row; both are hidden when it does not. Three panels meant three copies of the JSON-RPC bridge, so it moves to apps/bridge.js with the shared look in apps/panel.css, spliced in at the marker when the resource is read. Each panel still ships as one self-contained document. Verified in a browser against a harness that validates the handshake the way a host does: rendering, filters, source switching, inline power actions with reload, context updates and messages arriving with the right shapes, and the graceful path when the host advertises neither capability. * feat(auth): passkey sign-in + confirmation step on both login pages (#36) * feat(auth): passkey sign-in and a confirmation step on both login pages Adds WebAuthn as an alternative second factor on /app/login and /oauth/authorize. A passkey replaces the TOTP code, never the client secret: both pages keep the client_id + client_secret step first, so a stolen passkey is useless on its own -- and the dashboard session has to encrypt the secret anyway to re-mint MCP bearers later. Both pages now confirm before moving on, instead of redirecting the instant 2FA clears: * the validate button shimmers while the request is in flight, and Enter submits as soon as the sixth digit lands; * the screen that follows shows when access expires, offers to enrol a passkey on this device, and waits for "Finish signing in". On /oauth/authorize that also fixes a latent papercut: the authorization code is minted at "Finish", not before, so its 60 s OAuth 2.1 lifetime is no longer burned while a human reads the page. The approval is held as a single-use in-memory ticket instead. Both flows stay fully functional with JavaScript off -- the forms POST and the confirmation panel renders server-side. Storage is a new `passkeys` table (schema v5) holding a credential id, a public key and a signature counter; challenges are in-memory and single-use. Credentials are listed and removable from /app/tokens. Dynamically-registered clients delegate to their owner's passkeys, the same way they already delegate TOTP. py_webauthn is imported lazily: without it the pages simply hide every passkey affordance and TOTP remains the only way in. Same when the browser has no secure context (plain-HTTP LAN deployments). Tests drive the real ceremonies against a software ES256 authenticator built in the test module, covering wrong origin, wrong RP ID, replayed challenge, foreign credential, counter regression and the CSRF rotation that signing in performs. * fix(passkeys): say why passkeys are unavailable instead of hiding silently When the second factor cleared, the confirmation screen simply had no "Add a passkey" button and no explanation. Two independent gates can switch the feature off and neither was surfaced anywhere: * server side, the optional `webauthn` package may not be installed (a fresh `git pull` without `pip install -e .` is enough); * browser side, WebAuthn is only exposed on a secure context, so a plain-HTTP origin has no API to call. Now: * the boot banner prints `Passkeys: enabled` or `disabled - `; * `beaconmcp doctor` gained a Passkeys section naming the fix command; * the pages render a short hint when the *server* offers passkeys but the *browser* refuses them, instead of dropping the button with no trace. Hiding the affordance from an anonymous visitor is still right -- there is nothing they could do about it -- but the operator now has three places to ask the question and get an answer. * feat(updates): update notice for signed-in operators + self-update MCP tools (#37) * feat(updates): tell signed-in operators about updates, and offer to apply them BeaconMCP cuts no releases and ships no PyPI package: the canonical install is a git clone with a venv and a systemd unit. So "is there an update?" means "is this checkout behind the upstream default branch?", and nothing in the server was answering that question. Operators found out by happening to read the repo. Adds three things. **A notice, for signed-in operators only.** A card on any /app/* page when the checkout is behind: how far, the recent commit subjects, a link to the diff, and the commands to update. GET /app/api/update requires a live session and 401s otherwise -- the exact revision a server runs is free reconnaissance for anyone who hasn't authenticated, and the card is only ever rendered to someone signed in. Dismissing it hides that revision until a newer one lands. **Instructions that match the install**, rather than assuming everyone ran deploy/install.sh. A git checkout gets its own root and its real venv pip path, plus a systemctl line only when a unit file actually exists; a container gets docker compose; a pip distribution gets the git+https URL. **Two MCP tools.** beaconmcp_check_update is read-only. beaconmcp_self_update applies: pull --ff-only, reinstall dependencies, validate the config, then restart. It requires confirm=True, refuses a dirty checkout so local edits are never discarded, and refuses a non-git install. The config validation is a hard gate, not a warning, and it is what makes this safe to run unattended: it shells out to `beaconmcp validate-config` so the *new* code parses the operator's *actual* config. If a setting was renamed or a new one is now required, the checkout is reset to where it started, dependencies are restored, and nothing is restarted -- an update that bricks the server is worse than no update. The check also diffs the incoming .env.example / beaconmcp.yaml.example against the operator's real files (not the local examples, and honouring variables already exported), so the notice can say "this update wants a variable you haven't set" *before* it is applied. The dashboard's "Update now" re-prompts for 2FA: pulling code and restarting is the most privileged thing the panel can do, so a session alone is not the right bar -- same gate as minting a token. Both are switchable: features.updates.enabled is the air-gap switch (no egress, no tools, no notice) and allow_self_update keeps the notice while forbidding the apply, for deployments where updates go through a pipeline. Also fixes __version__, which had been pinned at "0.1.0" while pyproject said 2.0.0 -- it now reads package metadata, with the real number as the source-tree fallback. Tests drive git for real against throwaway repositories: a mocked subprocess would only prove the mock agrees with itself. pip and the validation subprocess are the two steps stubbed, so the pull/validate/ roll-back orchestration is exercised without touching the interpreter running the suite. * fix(updates): mention updates on the post-2FA screen, and stop caches pinning old assets Two gaps found by actually looking at the rendered pages. **The "You're signed in" screen said nothing.** The toast fetches its status once at page load, which on /app/login happens before the session exists -- so it 401'd and stayed empty, and signing in never re-checks because it does not reload the page. The one moment the operator is guaranteed to pass through said nothing about a pending update. login.js now re-asks once the session is created and renders a one-line mention above "Finish signing in". Deliberately not the full card: that screen has a single primary action, and on a narrow viewport a bottom-anchored card this tall would sit on top of it. The card now opts out of the auth pages entirely and shows on the landing page instead. **Browsers could keep running the previous release's JavaScript.** Starlette serves static files with ETag/Last-Modified but no Cache-Control, which leaves browsers on heuristic freshness -- a file untouched for weeks is reused for a long time without ever revalidating. That was survivable when upgrading meant running commands by hand; it is not once the server can update itself and the next page load is expected to match the new backend. This was not theoretical: it bit the browser used to verify the change, which kept executing a stale bundle across several restarts. Asset URLs now carry a fingerprint of the bundle, recomputed at start from the newest mtime in the static directory (which a git pull bumps). New bytes mean a new URL, so no cache can serve it from an old entry -- which also lets the files be cached hard instead of revalidated: ?v= present -> public, max-age=31536000, immutable ?v= absent -> no-cache (a legacy or hand-typed URL can't pin old code) /app/* pages -> no-store (per-session, and they carry the fingerprint) * fix(updates): serialize update work, and keep the restart off the shell Self-review findings on the update flow. **Two updates could run at once.** The dashboard button and the MCP tool reach `apply_update` independently, so nothing stopped a second one starting mid-pull: two `git pull` / `pip install -e .` runs in one checkout fight over index.lock and can leave a half-applied tree, and one caller's rollback could discard the other's successful update. A second caller is now told an update is already running rather than queued behind a pip that may take minutes -- it never touches git. **Cold-cache checks stampeded.** Every dashboard tab opening at once fired its own `git fetch`, piling up 60 s subprocesses for one answer. The uncached path is now single-flighted; waiters get the result the winner cached. **The deferred restart built a shell string.** `service` is the literal "beaconmcp" today, so this was not exploitable, but interpolating it into `sh -c` means a future change that made the unit name configurable would silently become a shell injection. Values now go through argv. All three are covered by tests, and both locks were mutation-checked: removing either makes its test fail (4 concurrent checks instead of 1; "release unlocked lock" when the second updater proceeds). * feat(dashboard): render MCP Apps panels in the integrated chat Closes #35. The ui:// panels from #34 only rendered in external hosts; /app/chat showed the tool's JSON. The dashboard is both the MCP client and the host, so both halves were missing. Not blocked by mcp 2.0 after all. A client declares Apps support through ClientCapabilities.extensions, which 1.x has no attribute for -- but the model is declared extra="allow", so the field serialises under the name the spec gives it and the server reads the same JSON either way. The pin costs the typed attribute, not the capability. Client half (dashboard/mcp_bridge.py, chat.py): an AppsClientSession that tags the outgoing InitializeRequest rather than reimplementing initialize(), the tool -> ui:// map read off each tool's _meta, and the full CallToolResult carried on ToolCallEnd -- the panel needs the whole payload, not the 500-char preview the tool card shows. Host half (chat.js, two routes): the document is served by /app/api/mcp/panel under its own CSP and framed with sandbox="allow- scripts" and no allow-same-origin. Verified in a browser: the frame gets a SecurityError on document.cookie and on window.parent, and CSP blocks fetch. Its only way out is postMessage, which is what makes the parent page the place where policy is decided. chat.js answers ui/initialize, pushes tool-input/tool-result, relays tools/call through /app/api/mcp/call, routes ui/message into a real turn and ui/update-model-context into the next one, and honours size-changed and request-display-mode. The confirmation question #35 raised, decided: a panel button is a human click on a labelled control, so it is not gated -- but "calls from an iframe skip the gate" is not the rule. A ui:// document is HTML the server wrote and this dashboard is a general MCP host, so the exemption is a closed list enforced in panel_call_allowed(): start/stop/restart on one guest, and proxmox_vm_config only for sizing keys (exempting the tool would exempt hookscript, raw QEMU args and device passthrough with it). Everything else is refused rather than prompted, because there is no turn in flight to hang a modal on -- and a panel that needs more sends ui/message, which puts the request back under the modal. Only the ui:// URI is persisted with a tool call, never the snapshot: a panel reopened from history refetches rather than showing week-old figures in a live-looking frame. Also fixes the panels' theming against a real host: panel.css now reads the spec's standardized variable names with its own values as fallbacks, so hostContext.styles.variables actually lands. 505 passed (+44), ruff clean. Co-Authored-By: Claude Opus 5 * feat(dashboard): move the model picker to Gemini 3.6 Flash / 3.5 Flash-Lite / 3.1 Pro Gemini 3.6 Flash went GA on 2026-07-21 and supersedes gemini-3-flash-preview; 3.5 Flash-Lite is the Flash-Lite that shipped alongside it, and lands at the price 2.5 Flash used to hold. Gemini 2.5 Flash / Pro and gemini-3-flash-preview leave the picker; 3.1 Pro stays as the preview option. There is no gemini-3.6-flash-lite -- the Lite in that launch is 3.5. Rates (AI Studio, 2026-07-29): 3.6 Flash $1.50/$0.15/$7.50, 3.5 Flash-Lite $0.30/$0.03/$2.50. 3.1 Pro is unchanged. The retired models keep their entries in _PRICING: cost_usd re-prices stored turns, so dropping a rate would silently re-bill that history at the fallback model's price. Schema 6 moves conversations off the retired ids -- conversations.model is what the *next* turn runs on, and a retired id there would fail VALID_MODELS and silently fall back, reading as the picker forgetting the operator's choice. messages.model is deliberately left alone: it records which model actually wrote a reply, which is history rather than configuration. That is the difference from migration 2, which renamed the same model. Also fixes an unrelated fragility in test_fingerprint_changes_when_an_asset_changes: it bumped app.css past its own mtime, but the fingerprint is the directory maximum, so the assertion failed whenever another static file happened to be newer. 510 passed, ruff clean. Picker verified in the browser: chip reads "3.6 Flash", groups Flash / Pro, 3.1 Pro carries the Preview badge. Co-Authored-By: Claude Opus 5 * fix(dashboard): gate beaconmcp_self_update behind the confirmation modal Found reviewing this branch before merge. Enumerating the 49 registered tools against _NEEDS_CONFIRMATION turned up beaconmcp_self_update sitting outside it: with confirm=True it runs git pull, reinstalls dependencies and restarts the service, so one injected instruction in a log line could replace the process that enforces the gate. It landed ungated with the self-update tools in #37; the panel relay added here would have inherited the hole. _CONFIRM_WHEN_ARG_PRESENT rather than _NEEDS_CONFIRMATION: confirm=False only previews. Reading the argument is sound here because `confirm` is a parameter the tool declares -- the trap the dry_run note describes is an argument the tool does *not* declare, which pydantic drops during validation. Adds a test that walks every @mcp.tool in src/ and fails on any name that is neither gated nor on an explicit reviewed-as-safe list, so the next tool cannot land outside the gate unnoticed. Also restores the composer text when submit() fails before the message is rendered -- moving the clear ahead of sendUserText() for ui/message made a failed conversation-create eat what the operator typed. 513 passed, ruff clean. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- docs/dashboard.md | 57 +- docs/security.md | 10 +- docs/tools.md | 41 +- src/beaconmcp/dashboard/app.py | 180 ++++++ src/beaconmcp/dashboard/chat.py | 172 +++++- src/beaconmcp/dashboard/conversations.py | 21 +- src/beaconmcp/dashboard/db.py | 25 +- src/beaconmcp/dashboard/mcp_bridge.py | 212 +++++++ src/beaconmcp/dashboard/static/app.css | 90 +++ src/beaconmcp/dashboard/static/chat.js | 478 ++++++++++++++- src/beaconmcp/dashboard/templates/chat.html | 1 + src/beaconmcp/dashboard/usage.py | 26 +- src/beaconmcp/proxmox/apps/bridge.js | 176 ++++++ src/beaconmcp/proxmox/apps/cluster_panel.html | 292 +++++++++ src/beaconmcp/proxmox/apps/logs_panel.html | 194 ++++++ src/beaconmcp/proxmox/apps/panel.css | 96 +++ src/beaconmcp/proxmox/apps/vm_panel.html | 215 +++++++ src/beaconmcp/proxmox/panel.py | 222 +++++++ src/beaconmcp/server.py | 2 + tests/test_dashboard_chat.py | 74 ++- tests/test_dashboard_mcp_apps.py | 560 ++++++++++++++++++ tests/test_dashboard_unit.py | 69 ++- tests/test_dashboard_usage.py | 22 +- tests/test_mcp_apps_panel.py | 247 ++++++++ tests/test_updates.py | 10 +- 25 files changed, 3440 insertions(+), 52 deletions(-) create mode 100644 src/beaconmcp/dashboard/mcp_bridge.py create mode 100644 src/beaconmcp/proxmox/apps/bridge.js create mode 100644 src/beaconmcp/proxmox/apps/cluster_panel.html create mode 100644 src/beaconmcp/proxmox/apps/logs_panel.html create mode 100644 src/beaconmcp/proxmox/apps/panel.css create mode 100644 src/beaconmcp/proxmox/apps/vm_panel.html create mode 100644 src/beaconmcp/proxmox/panel.py create mode 100644 tests/test_dashboard_mcp_apps.py create mode 100644 tests/test_mcp_apps_panel.py diff --git a/docs/dashboard.md b/docs/dashboard.md index 6f4d567..d27239f 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -3,7 +3,7 @@ Optional web panel served by BeaconMCP on the same origin as the MCP endpoint (`https:///app/...`). Three pages: - **`/app/login`** — exchanges a client id + client secret + a second factor (TOTP code or passkey) for an MCP bearer, and stores it in a 90-day HttpOnly session cookie. Removes the need to issue `curl` requests from a phone. -- **`/app/chat`** — multi-conversation chat with Gemini 2.5 Flash/Pro (stable) or Gemini 3 Flash / 3.1 Pro (preview, Google allowlist required). **Requires `GEMINI_API_KEY`.** +- **`/app/chat`** — multi-conversation chat with Gemini 3.6 Flash / 3.5 Flash-Lite (GA) or Gemini 3.1 Pro (preview, Google allowlist required). **Requires `GEMINI_API_KEY`.** - **`/app/tokens`** — generates named bearers so external MCP clients (Gemini web, ChatGPT, Assistant Desktop) can be wired up without the OAuth dance. **Works without `GEMINI_API_KEY`.** ## Enabling @@ -86,9 +86,12 @@ Constraints: ## Chat — models and thinking -- **Gemini 2.5 Flash / Pro** — available on every AI Studio key. **Used by default** (`gemini-2.5-flash`). -- **Gemini 3 Flash / 3.1 Pro (preview)** — gated by a Google allowlist. Without allowlist access, the dashboard surfaces a clear message pointing back to 2.5. -- **Thinking effort** — dropdown with `minimal` / `low` / `medium` / `high`. `gemini-2.5-pro` cannot disable thinking, so `minimal` is clamped to the 128-token floor automatically. +- **Gemini 3.6 Flash** — GA, available on every AI Studio key. **Used by default** (`gemini-3.6-flash`). +- **Gemini 3.5 Flash-Lite** — GA, the cheap high-throughput option (`gemini-3.5-flash-lite`), 5× cheaper in and out than 3.6 Flash. +- **Gemini 3.1 Pro (preview)** — gated by a Google allowlist. Without allowlist access, the dashboard surfaces a clear message pointing back to the two GA models. +- **Thinking effort** — dropdown with `minimal` / `low` / `medium` / `high`. The Gemini 3 family takes a `thinking_level` enum; the token-budget mapping in `_BUDGET_BY_EFFORT` (and the 128-token floor `gemini-2.5-pro` needed) only applies to models outside it, and is kept for conversations that predate the switch. + +Gemini 2.5 Flash / Pro and `gemini-3-flash-preview` were retired from the picker when 3.6 Flash and 3.5 Flash-Lite went GA (2026-07-21). A conversation sitting on one of them is moved to the closest current model by the schema-6 migration; `messages.model` is left alone, so the transcript keeps naming whichever model actually wrote each reply, and `usage.py` keeps their rates so old turns are never re-priced. - **Markdown rendering** — the client parses headings (`#`–`######`), ordered and unordered lists, blockquotes, horizontal rules, code fences (with `lang-*` class), inline code, bold/italic/strikethrough, and HTTP(S) links. ## Mandatory confirmation for dangerous tools @@ -97,13 +100,13 @@ The model's input is untrusted: a log line, a config file, or a web-search resul **Arbitrary code execution** — `ssh_run`, `proxmox_run`, and the primitives that become code execution in one hop (`proxmox_write_file`, `proxmox_upload_file`, `proxmox_download_file`, `proxmox_delete_transfer`). Writing `~/.ssh/authorized_keys` or a file under `/etc/cron.d` is exactly as good as a shell, which is why the file tools sit alongside the exec tools. -**Destructive or irreversible** — `vm_bulk_action`, `proxmox_vm_stop`, `proxmox_vm_restart`, `proxmox_vm_migrate`, `proxmox_snapshot_rollback`, `proxmox_snapshot_delete`, `proxmox_backup_restore`, `bmc_power_off`, `bmc_power_reset`. +**Destructive or irreversible** — `vm_bulk_action`, `proxmox_vm_stop`, `proxmox_vm_restart`, `proxmox_vm_migrate`, `proxmox_snapshot_rollback`, `proxmox_snapshot_delete`, `proxmox_backup_restore`, `bmc_power_off`, `bmc_power_reset`, and `beaconmcp_self_update` with `confirm=True` — which pulls new code, reinstalls dependencies and restarts the service, i.e. replaces the very process enforcing this gate. Three call shapes are let through without a modal, because they don't change anything: - `ssh_run` / `proxmox_run` carrying only `exec_id=` — that's read-only polling of an already-approved session. - `proxmox_snapshot_create` / `_rollback` / `_delete` called with `dry_run=True` — they only report what they *would* do. The exemption is limited to those three by name, never inferred from the argument: an undeclared `dry_run` is silently dropped during argument validation, so trusting it would let `ssh_run(command=..., dry_run=True)` past the modal and then run for real. -- `proxmox_vm_config` without `updates` — the read shape of a read-or-write tool. +- `proxmox_vm_config` without `updates`, and `beaconmcp_self_update` without `confirm=True` — the read shape of a read-or-write tool. Reading the argument is sound for these two because both parameters are declared by the tool, so what the gate reads is what the tool acts on; that is precisely what makes it unsound for an undeclared `dry_run`. When Gemini fires a gated call: @@ -114,6 +117,38 @@ When Gemini fires a gated call: The allow-list is hard-coded in `src/beaconmcp/dashboard/chat.py` (`_NEEDS_CONFIRMATION`, `_CONFIRM_WHEN_ARG_PRESENT`, `_tool_call_requires_confirmation`). Only the integrated chat enforces this gate; external MCP clients (Assistant Desktop, Gemini CLI, ChatGPT MCP) must enable their own per-call approval mode (see the **Security** section of the root README). +## Interactive panels (MCP Apps) + +Tools that carry `_meta.ui.resourceUri` — `proxmox_vm_panel`, `proxmox_logs_panel`, `cluster_overview_interactive` — render as a live interface in the chat instead of a block of JSON. The dashboard implements both halves of the [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps/overview): it announces `io.modelcontextprotocol/ui` when it opens its MCP session, and it plays host to the `ui://` document over `postMessage`. + +The extension is declared through `ClientCapabilities.extensions`, a field mcp only types in 2.0. The `<2` pin is not in the way: the model accepts extra fields, so the capability serialises under the name the spec gives it and the server reads the same JSON either way (`dashboard/mcp_bridge.py`). + +### How a panel is isolated + +The document is served by `/app/api/mcp/panel` and framed with `sandbox="allow-scripts"` and **no** `allow-same-origin`. That puts it on an opaque origin: it cannot read the session cookie, cannot read the CSRF token, cannot reach into the parent page. Its response also carries its own `Content-Security-Policy` — `default-src 'none'`, `connect-src 'none'`, `frame-ancestors 'self'` — so it cannot open a socket of its own either. + +What is left is `postMessage` to the parent. Every tool call a panel makes therefore goes through `/app/api/mcp/call`, which is session-authenticated and CSRF-protected, and where the policy below is applied. + +### What a panel may call on its own + +A panel button is a labelled control a human clicked, so the approval modal — which exists because the *model's* input is untrusted — would restate the click rather than check it. Panel calls are therefore not gated. What is *not* granted is a blanket exemption for anything running in a frame: a `ui://` document is HTML the server wrote, and this dashboard is a general MCP host, so a blanket rule would hand every connected server a way around the gate it is documented to be subject to. + +The exemption is a closed list, enforced server-side in `panel_call_allowed()`: + +- `proxmox_vm_start` / `_stop` / `_restart` — one guest per call, visible in the panel, reversible from it. +- `proxmox_vm_config`, but only when every key in `updates` is sizing (`cores`, `sockets`, `memory`, `balloon`, `cpulimit`, `cpuunits`). Exempting the tool itself would exempt `hookscript`, raw QEMU `args` and device passthrough along with it. +- Everything that was never gated in the first place — the read-only tools, including the three panel tools themselves. + +Anything else is refused with `403 confirmation_required`, and the panel shows the reason. It is refused rather than prompted because there is no turn in flight to hang a modal on — and because the panel already has a way through: `ui/message` hands the request to the model, which puts it back under the modal where it belongs. + +### Model context + +A panel that acts on the cluster pushes the fresh state back with `ui/update-model-context`. The page holds the latest update per panel and sends it with the next message, labelled as coming from the panel rather than from the operator. Without it, stopping a VM from the panel would leave the next turn believing it still runs — the button's result goes to the iframe, not into the conversation. + +### Reopening from history + +Only the `ui://` URI is stored with the tool call, never the snapshot behind it. A panel in an older conversation renders as an **Open panel** button; clicking it mounts the frame and refetches. Live figures in a panel that has been sitting in the transcript for a week would be worse than a short spinner. + ## Stored data SQLite at `/opt/beaconmcp/dashboard.db` (WAL mode). Five tables: @@ -141,14 +176,16 @@ Setting a variable to `0` disables that window. When a cap is exceeded, the next The chat footer shows a compact `5H XX% · 7D XX%` line updated after every turn via an SSE `usage_update` event. Clicking the bar opens a modal with progress bars, the 5 h reset time, a "rolling 7-day window" label, and a refresh button. -Rates used (USD per 1 M tokens, aligned with the public Google AI Studio pricing on 2026-04-17): +Rates used (USD per 1 M tokens, aligned with the public Google AI Studio pricing on 2026-07-29). The retired rows are kept because the ledger re-prices stored turns, and dropping a rate would silently re-bill that history at the fallback model's price: | Model | Input | Cached | Output | |-------|-------|--------|--------| -| `gemini-2.5-flash` | $0.30 | $0.03 | $2.50 | -| `gemini-2.5-pro` (≤200k / >200k) | $1.25 / $2.50 | $0.125 / $0.25 | $10.00 / $15.00 | -| `gemini-3-flash-preview` | $0.50 | $0.05 | $3.00 | +| `gemini-3.6-flash` | $1.50 | $0.15 | $7.50 | +| `gemini-3.5-flash-lite` | $0.30 | $0.03 | $2.50 | | `gemini-3.1-pro-preview` (≤200k / >200k) | $2.00 / $4.00 | $0.20 / $0.40 | $12.00 / $18.00 | +| `gemini-2.5-flash` *(retired)* | $0.30 | $0.03 | $2.50 | +| `gemini-2.5-pro` *(retired, ≤200k / >200k)* | $1.25 / $2.50 | $0.125 / $0.25 | $10.00 / $15.00 | +| `gemini-3-flash-preview` *(retired)* | $0.50 | $0.05 | $3.00 | Constants live in `src/beaconmcp/dashboard/usage.py` — update them when Google adjusts its prices. diff --git a/docs/security.md b/docs/security.md index d26862a..ee92b29 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,7 +21,7 @@ stop` was meant. The integrated chat at `/app/chat` forces a human confirmation for every code-execution tool (`ssh_run`, `proxmox_run`, `proxmox_write_file` and the transfer tools) and every destructive one (`vm_bulk_action`, `proxmox_vm_stop`, snapshot rollback/delete, backup restore, `bmc_power_off`, -`bmc_power_reset`). Writing a guest file counts as code execution: `~/.ssh/authorized_keys` and +`bmc_power_reset`, and `beaconmcp_self_update` with `confirm=True`). Writing a guest file counts as code execution: `~/.ssh/authorized_keys` and `/etc/cron.d/` are one hop from a shell. Skipping the modal is reserved for calls that cannot change anything — polling by `exec_id` alone, `dry_run=True` on the snapshot tools that implement it, and the read shape of `proxmox_vm_config`. The full list is in @@ -29,6 +29,14 @@ it, and the read shape of `proxmox_vm_config`. The full list is in confirmation card even when you're clicking through fast. No answer within 5 minutes counts as a refusal. +The interactive panels are the one place a gated tool runs without that modal, and only for a +closed list: starting, stopping and restarting a single guest, and resizing its CPU or memory. +That is a different question from the one the modal answers — the modal exists because the model's +input is untrusted, and a panel button is a human click on a labelled control. Everything else a +panel asks for is refused outright, including any `proxmox_vm_config` key that is not sizing. The +boundary is enforced server-side, not in the frame: +[dashboard.md](dashboard.md#interactive-panels-mcp-apps). + ## Tokens A `/app/tokens` bearer grants arbitrary shell access on your Proxmox nodes for its full lifetime diff --git a/docs/tools.md b/docs/tools.md index 9eb368d..364158a 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,6 +1,6 @@ # MCP tools -46 tools across seven modules. The infrastructure modules are only registered when the matching +49 tools across eight modules. The infrastructure modules are only registered when the matching capability is configured, so an SSH-only deployment exposes the 2 SSH tools and nothing else from Proxmox or BMC. `security_end_session` and the two maintenance tools are always registered, whatever the topology. @@ -51,6 +51,45 @@ tools below exist for when you already know what you're looking at. | `proxmox_backup_list` | List vzdump archives on a storage pool. | | `proxmox_backup_restore` | Restore from an archive. | +## Proxmox — interactive panels (3) + +| Tool | Description | +|------|-------------| +| `proxmox_vm_panel` | Control panel for one guest: live CPU/RAM/disk, power buttons, CPU and memory fields. | +| `proxmox_logs_panel` | Scrollable syslog and task viewer for one node, with level and substring filters. | +| `cluster_overview_interactive` | Cluster dashboard: node pressure, searchable guest table with inline start/stop, storage bars. | + +These three ship a UI. They use the [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps/overview) +(`io.modelcontextprotocol/ui`): the tool carries `_meta.ui.resourceUri` pointing at a `ui://` +resource, a self-contained HTML document served as `text/html;profile=mcp-app` that the client +renders in a sandboxed iframe and talks to over JSON-RPC on `postMessage`. + +The panels live in `src/beaconmcp/proxmox/apps/`. They share `bridge.js` (the JSON-RPC client) +and `panel.css` (the look), spliced in at the `` marker when the resource is +read, so each one still ships as a single document. + +**They hold no cluster access of their own.** Every button issues an ordinary `tools/call` for +the tools listed elsewhere in this document — `proxmox_vm_start`, `_stop`, `_restart`, `_config` +— which the host relays, and which the host decides whether to approve. A panel is a nicer way +to issue the call, not a channel that bypasses the host. + +What the host does with that call is the host's policy. BeaconMCP's own dashboard lets a panel +drive the guest lifecycle unattended (a labelled button is the click) but refuses anything in its +confirmation list, and refuses `proxmox_vm_config` for any key that is not sizing — see +[the dashboard guide](dashboard.md#interactive-panels-mcp-apps). Other hosts set their own line. + +After an action, a panel pushes the fresh state back into the conversation with +`ui/update-model-context`. Without it the model keeps whatever the tool returned when the panel +opened: stop a VM from the panel and the next turn still thinks it is running, because the +button's result goes to the iframe, not to the model. Where the host supports `ui/message`, the +VM panel also offers a button that asks the model to investigate the guest, and the cluster +dashboard one that asks it to open a specific VM's panel. Both features are gated on the host +advertising the capability and are simply hidden when it does not. + +Clients that did not negotiate the extension ignore `_meta.ui` and show the tool's return value, +which is the same snapshot as data. Nothing breaks; you just don't get the frame. BeaconMCP's own +`/app/chat` negotiates it and renders the panels inline. + ## Proxmox — system and files (9) | Tool | Description | diff --git a/src/beaconmcp/dashboard/app.py b/src/beaconmcp/dashboard/app.py index ab7e34a..699c01d 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -1401,6 +1401,7 @@ async def api_chat_stream(request: Request) -> Response: user_text = str(body.get("content") or "").strip() override_model = body.get("model") override_effort = body.get("effort") + app_context = _sanitize_app_context(body.get("app_context")) if not conv_id or not user_text: return _json({"error": "invalid_request"}, status=400) @@ -1474,6 +1475,7 @@ async def _confirm(req: ToolConfirmRequired) -> bool: mcp_url=_resolve_mcp_url(request, deps), mcp_mode=deps.mcp_mode, confirm_tool=_confirm, + app_context=app_context, ) try: @@ -1495,6 +1497,11 @@ async def _confirm(req: ToolConfirmRequired) -> bool: yield _sse("tool_result", { "id": event.id, "status": event.status, "preview": event.preview, "duration_ms": event.duration_ms, + # Present only for tools that ship an MCP Apps + # panel: {resourceUri, result}. The browser + # mounts the iframe and pushes ``result`` into + # it as ui/notifications/tool-result. + "ui": event.ui, }) elif isinstance(event, UsageAccumulated): # Engine-internal event: keep the last one the @@ -1582,6 +1589,144 @@ async def _confirm(req: ToolConfirmRequired) -> bool: _apply_security_headers(response) return response + # ----- MCP Apps host --------------------------------------------------- + # + # The chat page is an MCP Apps host: a tool that carries + # ``_meta.ui.resourceUri`` gets its ``ui://`` document rendered in a + # sandboxed iframe instead of showing raw JSON. Two routes serve that + # frame -- one hands it the document, one relays the tool calls its + # buttons make. Both are same-origin and session-authenticated, which + # the frame itself is not: it runs under ``sandbox="allow-scripts"`` + # with no ``allow-same-origin``, so its origin is opaque, it carries no + # cookies, and it cannot read the CSRF token. postMessage to its parent + # is the only way out, which is what makes the parent page the place + # where policy is decided. + + async def api_mcp_panel(request: Request) -> Response: + """Serve a ``ui://`` document for framing, under its own CSP.""" + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + uri = (request.query_params.get("uri") or "").strip() + if not uri.startswith("ui://"): + return _json({"error": "invalid_uri"}, status=400) + + from . import mcp_bridge # local: keeps the mcp import off test paths + + mcp_url = _resolve_mcp_url(request, deps) + html = mcp_bridge.cache_get(mcp_url, uri) + if html is None: + try: + async with mcp_bridge.open_session( + mcp_url, session.mcp_bearer or "", + ) as mcp_session: + html = await mcp_bridge.read_ui_resource(mcp_session, uri) + except mcp_bridge.UiResourceError as exc: + return _json( + {"error": "resource_unavailable", "message": str(exc)}, + status=502, + ) + except Exception as exc: # noqa: BLE001 + return _json( + {"error": "mcp_unavailable", "message": str(exc)}, + status=502, + ) + mcp_bridge.cache_put(mcp_url, uri, html) + + response = HTMLResponse(html) + # Deliberately NOT _apply_security_headers: that sets + # X-Frame-Options: DENY and a CSP whose script-src 'self' would + # kill the panel's inline bridge. This document needs the opposite + # of both -- framable by us, inline script allowed, and nothing + # else. connect-src 'none' is the important line: a panel talks to + # the cluster through its parent, never directly. + response.headers["Content-Security-Policy"] = ( + "default-src 'none'; " + "script-src 'unsafe-inline'; " + "style-src 'unsafe-inline'; " + "img-src data:; " + "font-src data:; " + "connect-src 'none'; " + "form-action 'none'; " + "base-uri 'none'; " + "frame-src 'none'; " + "object-src 'none'; " + "frame-ancestors 'self'" + ) + response.headers["X-Frame-Options"] = "SAMEORIGIN" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Cache-Control"] = "no-store" + response.headers["Referrer-Policy"] = "no-referrer" + return response + + async def api_mcp_call(request: Request) -> Response: + """Relay a panel's ``tools/call`` to the MCP server.""" + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + + body = await _read_json(request) + name = str(body.get("name") or "").strip() + args = body.get("arguments") + if not name or (args is not None and not isinstance(args, dict)): + return _json({"error": "invalid_request"}, status=400) + args = args or {} + + from .chat import panel_call_allowed + + if not panel_call_allowed(name, args): + # Not a modal, because there is no turn in flight to hang one + # on. The panel's way through is ui/message: hand the request + # to the model and let it meet the approval gate there. + audit.emit( + "dashboard.panel_call_refused", + tool=name, + client_id=session.client_id, + args=audit.compact_args(args), + ) + return _json( + { + "error": "confirmation_required", + "message": ( + f"{name} needs explicit approval and cannot be called " + "straight from a panel. Ask the assistant to run it." + ), + }, + status=403, + ) + + from . import mcp_bridge + + start = time.monotonic() + try: + async with mcp_bridge.open_session( + _resolve_mcp_url(request, deps), session.mcp_bearer or "", + ) as mcp_session: + result = await mcp_session.call_tool(name, args) + except Exception as exc: # noqa: BLE001 + audit.emit( + "dashboard.panel_call", + tool=name, status="error", + client_id=session.client_id, + args=audit.compact_args(args), + ) + return _json( + {"error": "tool_failed", "message": str(exc)}, status=502, + ) + + wire = mcp_bridge.call_result_to_wire(result) + audit.emit( + "dashboard.panel_call", + tool=name, + status="error" if wire.get("isError") else "ok", + duration_ms=round((time.monotonic() - start) * 1000, 1), + client_id=session.client_id, + args=audit.compact_args(args), + ) + return _json({"result": wire}) + return [ Route("/app/login", login_get, methods=["GET"]), Route("/app/login", login_post, methods=["POST"]), @@ -1605,6 +1750,8 @@ async def _confirm(req: ToolConfirmRequired) -> bool: Route("/app/api/conversations/{conv_id}", api_conv_delete, methods=["DELETE"]), Route("/app/api/chat/stream", api_chat_stream, methods=["POST"]), Route("/app/api/chat/confirm", api_chat_confirm, methods=["POST"]), + Route("/app/api/mcp/panel", api_mcp_panel, methods=["GET"]), + Route("/app/api/mcp/call", api_mcp_call, methods=["POST"]), Route("/app/api/usage", api_usage, methods=["GET"]), Route("/app/api/passkeys", api_passkeys_list, methods=["GET"]), Route( @@ -1791,6 +1938,39 @@ def _resolve_mcp_url(request: Request, deps: DashboardDeps) -> str: return f"http://127.0.0.1:{port}/mcp" +_MAX_APP_CONTEXT_ENTRIES = 8 + + +def _sanitize_app_context(raw: Any) -> list[dict[str, Any]]: + """Normalise the ``app_context`` a chat page sends with a turn. + + These are ``ui/update-model-context`` payloads the panels pushed, held + in the page and replayed with the next message. The page is trusted no + more than any other client input here: we keep the shape, drop the + rest, and cap how many rides along so an open dashboard cannot grow the + prompt without bound. + """ + if not isinstance(raw, list): + return [] + out: list[dict[str, Any]] = [] + for item in raw[:_MAX_APP_CONTEXT_ENTRIES]: + if not isinstance(item, dict): + continue + tool = str(item.get("tool") or "").strip()[:64] + text = item.get("text") + structured = item.get("structured") + if not isinstance(text, str): + text = None + if text is None and structured is None: + continue + out.append({ + "tool": tool or "panel", + "text": text, + "structured": structured, + }) + return out + + def _sse(event: str, data: Any) -> bytes: payload = json.dumps(data, ensure_ascii=False) return f"event: {event}\ndata: {payload}\n\n".encode("utf-8") diff --git a/src/beaconmcp/dashboard/chat.py b/src/beaconmcp/dashboard/chat.py index d57618f..efac73b 100644 --- a/src/beaconmcp/dashboard/chat.py +++ b/src/beaconmcp/dashboard/chat.py @@ -103,6 +103,10 @@ class ToolCallEnd: status: str # ok | error preview: str duration_ms: int + # Set only when the tool declares an MCP Apps panel. Carries the + # ``ui://`` URI plus the full ``CallToolResult`` -- the panel needs the + # whole payload, not the 500-char ``preview`` the tool card shows. + ui: dict[str, Any] | None = None @dataclass @@ -188,8 +192,20 @@ class UsageAccumulated: # ``proxmox_vm_config`` returns the config without ``updates`` and rewrites # it with them, so gating the whole tool would put a modal in front of # every read. Map: tool name -> arg whose presence makes the call mutate. +# +# ``beaconmcp_self_update`` belongs here for the same reason and is the +# sharper case: with ``confirm=False`` it only previews, but with +# ``confirm=True`` it pulls new code, reinstalls dependencies and restarts +# the service. That is the single most consequential call this server +# exposes, and nothing stops an injected instruction from asking for it. +# +# Reading the argument is sound here, unlike the ``dry_run`` case below: +# ``confirm`` is a parameter the tool actually declares, so what we read is +# what the tool will act on. The trap that note describes is an argument the +# tool does *not* declare, which pydantic drops during validation. _CONFIRM_WHEN_ARG_PRESENT: dict[str, str] = { "proxmox_vm_config": "updates", + "beaconmcp_self_update": "confirm", } # Tools that actually implement a ``dry_run`` parameter and return a @@ -242,6 +258,97 @@ def _tool_call_requires_confirmation(name: str, args: Any) -> bool: return True +# Tools a ui:// panel is allowed to call by itself, without the modal. +# +# The gate above exists because the *model* decides those calls and the +# model's input is untrusted. A panel button is not that: it is a labelled +# control a human clicked, and putting "Approve stopping VM 104?" in front +# of a button that says "Stop" restates the click rather than checking it. +# +# What the exemption is NOT is "calls from an iframe skip the gate". The +# panel document is HTML the *server* wrote, not something the operator +# typed, and this dashboard is a general MCP host -- any server an operator +# connects can ship a ui:// resource. A blanket exemption would hand every +# such server a way around the approval it is documented to be subject to. +# So the list is closed and enumerated: guest lifecycle, one guest per +# call, visible in the panel and reversible from it. +# +# There is a deliberate escape hatch for everything else. A panel that +# wants a shell sends ``ui/message``, which puts the request back on the +# model's path -- and therefore back under the modal it belongs to. That is +# why the relay refuses out-of-list tools outright instead of prompting: +# the prompt already exists, one hop further along. +_PANEL_CALL_EXEMPT: frozenset[str] = frozenset({ + "proxmox_vm_start", + "proxmox_vm_stop", + "proxmox_vm_restart", +}) + +# ``proxmox_vm_config`` cannot go in the set above, because ``updates`` is an +# open-ended guest config: exempting the tool would exempt ``hookscript``, +# raw QEMU ``args`` and device passthrough along with it. The panels edit +# sizing, so sizing is what is exempt -- any other key falls back to the +# normal gate and is refused. +_PANEL_CONFIG_KEYS: frozenset[str] = frozenset({ + "cores", "sockets", "memory", "balloon", "cpulimit", "cpuunits", +}) + + +def panel_call_allowed(name: str, args: Any) -> bool: + """Return True when a panel may issue this ``tools/call`` unattended.""" + if name in _PANEL_CALL_EXEMPT: + return True + if name == "proxmox_vm_config": + updates = args.get("updates") if isinstance(args, dict) else None + if not updates: + return True # reading a config, not writing one + return isinstance(updates, dict) and set(updates).issubset(_PANEL_CONFIG_KEYS) + return not _tool_call_requires_confirmation(name, args) + + +# Cap on what one panel's ``ui/update-model-context`` may inject, so a +# cluster dashboard holding a few hundred guests cannot quietly become the +# bulk of the prompt. The panels send a sentence plus a small object; this +# is a backstop, not a working limit. +_APP_CONTEXT_MAX_CHARS = 4000 + + +def format_app_context(entries: Any) -> str: + """Render panel context updates as one labelled block for the model. + + Marked as coming from a panel rather than from the operator: the text + is written by the ``ui://`` document, so the model should weigh it as + tool output, which is exactly what it is. + """ + if not isinstance(entries, list) or not entries: + return "" + lines: list[str] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + tool = str(entry.get("tool") or "panel") + parts: list[str] = [] + text = entry.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + structured = entry.get("structured") + if structured is not None: + parts.append(_json.dumps(structured, ensure_ascii=False, default=str)) + if not parts: + continue + body = "\n".join(parts) + if len(body) > _APP_CONTEXT_MAX_CHARS: + body = body[: _APP_CONTEXT_MAX_CHARS - 1] + "…" + lines.append(f"[{tool}] {body}") + if not lines: + return "" + return ( + "State reported by the interactive panels open in this conversation " + "(the user acted in the panel; these calls did not go through you):\n" + + "\n".join(lines) + ) + + # --------------------------------------------------------------------------- # Turn input # --------------------------------------------------------------------------- @@ -266,6 +373,13 @@ class TurnInput: # must return ``True`` for approve / ``False`` for reject. If # ``None``, confirmation-gated tools auto-reject. confirm_tool: Callable[[ToolConfirmRequired], Awaitable[bool]] | None = None + # Latest ``ui/update-model-context`` payload from each open panel, as + # ``{"tool": name, "text": str | None, "structured": Any}``. Folded into + # this turn's user content so the model sees what the operator did in + # the frame -- without it, stopping a VM from the panel leaves the next + # turn still believing it runs, because the button's tools/call result + # went to the iframe rather than into the conversation. + app_context: list[dict[str, Any]] = field(default_factory=list) # --------------------------------------------------------------------------- @@ -339,6 +453,12 @@ def assemble_assistant_message( tc.status = event.status tc.preview = event.preview tc.duration_ms = event.duration_ms + if isinstance(event.ui, dict): + # Only the URI is persisted, not the payload behind it. A + # cluster snapshot is large and goes stale the moment the + # turn ends; reopening the panel from history refetches + # rather than replaying what the cluster looked like then. + tc.ui_resource_uri = event.ui.get("resourceUri") # UsageAccumulated / ToolConfirmRequired / ErrorEvent don't # contribute to the persisted message body. @@ -408,7 +528,7 @@ def _build_thinking_config(model: str, effort: str): ) @staticmethod - def _build_contents(history, user_text): + def _build_contents(history, user_text, app_context=None): from google.genai import types # type: ignore contents: list = [] for msg in history: @@ -422,6 +542,15 @@ def _build_contents(history, user_text): role="model", parts=[types.Part(text=msg.content)], )) + block = format_app_context(app_context) + if block: + # Its own user turn rather than a prefix on the operator's + # message: the operator did not write this, and a model that + # quotes their message back should not quote panel telemetry + # as if they had typed it. + contents.append(types.Content( + role="user", parts=[types.Part(text=block)], + )) contents.append(types.Content( role="user", parts=[types.Part(text=user_text)], @@ -484,7 +613,9 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: usage_total_output = 0 emitted_visible_text = False - contents = self._build_contents(turn.history, turn.user_text) + contents = self._build_contents( + turn.history, turn.user_text, turn.app_context, + ) thinking = self._build_thinking_config(turn.model, turn.effort) if turn.mcp_mode == "remote": @@ -526,10 +657,16 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: # ``session.call_tool()``, and feed the results back in a new # ``generate_content_stream`` call until no function_call remains. try: - from mcp.client.session import ClientSession # type: ignore from mcp.client.streamable_http import ( # type: ignore streamablehttp_client, ) + + from .mcp_bridge import ( + AppsClientSession, + call_result_to_wire, + ui_resource_uri, + ui_resource_uris_by_tool, + ) except ImportError as e: yield ErrorEvent(code="sdk_missing", message=str(e)) return @@ -552,7 +689,10 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: timeout=30, sse_read_timeout=300, ) as (read_stream, write_stream, _get_session_id): - async with ClientSession(read_stream, write_stream) as session: + # Apps-aware session: it declares io.modelcontextprotocol/ui at + # initialize, which is what lets the dashboard render the ui:// + # panels its own tools point at instead of showing their JSON. + async with AppsClientSession(read_stream, write_stream) as session: try: init_result = await session.initialize() except Exception as e: # noqa: BLE001 @@ -598,6 +738,11 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: ) return + # tool name -> ui:// panel, from each tool's _meta.ui. + # Read once per turn; the tool list does not change + # under us mid-turn. + panel_uris = ui_resource_uris_by_tool(mcp_tools) + function_decls = [ _mcp_tool_to_declaration(t, types) for t in mcp_tools ] @@ -854,6 +999,7 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: ) continue + ui: dict[str, Any] | None = None try: result = await session.call_tool(name, args) payload = _mcp_call_result_to_response(result) @@ -862,6 +1008,17 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: if getattr(result, "isError", False) else "ok" ) + # A result-level _meta.ui wins over the one + # the tool declared, per the Apps spec. + uri = ( + ui_resource_uri(getattr(result, "meta", None)) + or panel_uris.get(name) + ) + if uri and status == "ok": + ui = { + "resourceUri": uri, + "result": call_result_to_wire(result), + } except Exception as e: # noqa: BLE001 payload = {"error": str(e)} status = "error" @@ -871,6 +1028,7 @@ async def _run(self, turn: TurnInput) -> AsyncIterator[ChatEvent]: id=fc_id, status=status, preview=_short_preview(payload), duration_ms=duration, + ui=ui, ) response_parts.append( @@ -1160,9 +1318,9 @@ def _classify_error(exc: BaseException, model: str) -> tuple[str, str]: ( f"Your Gemini key does not have access to {model} " "(Google allowlist required for preview models). Switch " - "to gemini-2.5-flash or gemini-2.5-pro via the dropdown " - "in the bottom-left — those are available on every AI " - "Studio key." + "to gemini-3.6-flash or gemini-3.5-flash-lite via the " + "dropdown in the bottom-left — those are GA and " + "available on every AI Studio key." ), ) return ( diff --git a/src/beaconmcp/dashboard/conversations.py b/src/beaconmcp/dashboard/conversations.py index 64aa015..d665a8d 100644 --- a/src/beaconmcp/dashboard/conversations.py +++ b/src/beaconmcp/dashboard/conversations.py @@ -18,15 +18,19 @@ VALID_EFFORTS = ("minimal", "low", "medium", "high") -# Stable Gemini 2.5 ship first (wider access), then Gemini 3 preview -# variants for users on the allowlist. +# GA models first (available on every AI Studio key), then the preview +# variant, which needs Google allowlist access. +# +# Retired here but still priced in ``usage.py``: gemini-2.5-flash, +# gemini-2.5-pro and gemini-3-flash-preview. Conversations sitting on one +# of those are moved forward by the schema-6 migration in ``db.py``; +# ``messages.model`` keeps naming whichever model actually wrote the reply. VALID_MODELS = ( - "gemini-2.5-flash", - "gemini-2.5-pro", - "gemini-3-flash-preview", + "gemini-3.6-flash", + "gemini-3.5-flash-lite", "gemini-3.1-pro-preview", ) -DEFAULT_MODEL = "gemini-2.5-flash" +DEFAULT_MODEL = "gemini-3.6-flash" DEFAULT_EFFORT = "low" @@ -52,6 +56,10 @@ class ToolCall: status: str = "pending" # pending | ok | error preview: str | None = None duration_ms: int | None = None + # ``ui://`` panel this tool declared, when it ships one (MCP Apps). + # Enough to re-open the frame from history; the snapshot behind it is + # deliberately not stored -- see assemble_assistant_message. + ui_resource_uri: str | None = None def to_json(self) -> dict[str, Any]: return asdict(self) @@ -110,6 +118,7 @@ def _decode_tool_calls(raw: str | None) -> list[ToolCall]: status=item.get("status", "ok"), preview=item.get("preview"), duration_ms=item.get("duration_ms"), + ui_resource_uri=item.get("ui_resource_uri"), ) ) return out diff --git a/src/beaconmcp/dashboard/db.py b/src/beaconmcp/dashboard/db.py index dd80fd7..8689d71 100644 --- a/src/beaconmcp/dashboard/db.py +++ b/src/beaconmcp/dashboard/db.py @@ -19,7 +19,7 @@ def db_path() -> Path: return Path(override) if override else DEFAULT_DB_PATH -_LATEST_VERSION = 5 +_LATEST_VERSION = 6 def _migrate(conn: sqlite3.Connection) -> None: @@ -49,7 +49,7 @@ def _migrate(conn: sqlite3.Connection) -> None: id TEXT PRIMARY KEY, client_id TEXT NOT NULL, title TEXT, - model TEXT NOT NULL DEFAULT 'gemini-3-flash-preview', + model TEXT NOT NULL DEFAULT 'gemini-3.6-flash', thinking_effort TEXT NOT NULL DEFAULT 'low', created_at REAL NOT NULL, updated_at REAL NOT NULL @@ -171,6 +171,27 @@ def _migrate(conn: sqlite3.Connection) -> None: """ ) + if version < 6: + # Gemini 2.5 and gemini-3-flash-preview left the model picker when + # 3.6 Flash and 3.5 Flash-Lite went GA. ``conversations.model`` is + # the model the *next* turn will use, so a conversation left on a + # retired id would fail validation and silently fall back; move it + # to the closest current model instead. + # + # ``messages.model`` is deliberately NOT rewritten: it records + # which model actually produced a reply, and that is history, not + # configuration. Migration 2 rewrote it because those were renames + # of the same model; these are substitutions. + for old, new in ( + ("gemini-2.5-flash", "gemini-3.6-flash"), + ("gemini-3-flash-preview", "gemini-3.6-flash"), + ("gemini-2.5-pro", "gemini-3.1-pro-preview"), + ): + conn.execute( + "UPDATE conversations SET model = ? WHERE model = ?", + (new, old), + ) + conn.execute(f"PRAGMA user_version = {_LATEST_VERSION}") conn.commit() diff --git a/src/beaconmcp/dashboard/mcp_bridge.py b/src/beaconmcp/dashboard/mcp_bridge.py new file mode 100644 index 0000000..648d8f3 --- /dev/null +++ b/src/beaconmcp/dashboard/mcp_bridge.py @@ -0,0 +1,212 @@ +"""MCP client plumbing shared by the chat engine and the MCP Apps host. + +The dashboard wears two hats. It is an MCP *client*: every turn opens a +``ClientSession`` against ``/mcp`` with the operator's bearer and runs the +tool loop. It is also an MCP Apps *host*: when a tool it just called points +at a ``ui://`` resource, it fetches that document and renders it in a +sandboxed iframe, then speaks the postMessage dialect to it. + +Both hats need the same three things, which is what lives here: a session +that declares the Apps extension, a way to read a ``ui://`` document, and +the ``_meta.ui.resourceUri`` lookup that ties a tool to its panel. + +**On the mcp 1.x pin.** A client announces Apps support through +``ClientCapabilities.extensions``, a field that only exists in mcp 2.0 -- +which is why this looked blocked behind the ``<2`` pin. It is not: +``ClientCapabilities`` is declared ``extra="allow"``, so the field rides +onto the wire under exactly the name 2.0 will emit, and the server reads +the same JSON either way. The pin costs us the typed attribute, not the +capability. +""" + +from __future__ import annotations + +import time +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator + +from mcp import types +from mcp.client.session import ClientSession +from mcp.client.streamable_http import streamablehttp_client +from pydantic import AnyUrl + +# Extension identifier and content type from the MCP Apps spec (SEP-1865). +UI_EXTENSION_ID = "io.modelcontextprotocol/ui" +APP_MIME_TYPE = "text/html;profile=mcp-app" + +# postMessage dialect version the host speaks with the iframe. Matches the +# PROTOCOL_VERSION the panels' bridge.js sends in ui/initialize. +UI_PROTOCOL_VERSION = "2026-01-26" + +_UI_CAPABILITY: dict[str, Any] = {"mimeTypes": [APP_MIME_TYPE]} + + +class AppsClientSession(ClientSession): + """A ``ClientSession`` that declares the MCP Apps extension. + + ``ClientSession.initialize()`` builds its own ``ClientCapabilities`` and + takes no hook for extra fields, so rather than reimplement it -- version + negotiation, the ``notifications/initialized`` follow-up, the capability + bookkeeping -- we tag the outgoing ``InitializeRequest`` on its way past. + Everything else stays the SDK's business. + """ + + async def send_request(self, request, *args, **kwargs): # type: ignore[override] + root = getattr(request, "root", None) + if isinstance(root, types.InitializeRequest): + # extra="allow" on ClientCapabilities: the attribute lands in + # __pydantic_extra__ and serialises as a real field. + setattr( + root.params.capabilities, + "extensions", + {UI_EXTENSION_ID: dict(_UI_CAPABILITY)}, + ) + return await super().send_request(request, *args, **kwargs) + + +@asynccontextmanager +async def open_session( + mcp_url: str, + bearer: str, + *, + timeout: int = 30, + sse_read_timeout: int = 300, +) -> AsyncIterator[ClientSession]: + """Open an initialized Apps-aware session against ``mcp_url``. + + ``streamablehttp_client`` (no underscore) is the variant that threads + ``headers`` through every request; the other one drops the bearer and + the handshake 401s on loopback. Same reason as in ``chat.py``. + """ + async with streamablehttp_client( + mcp_url, + headers={"Authorization": f"Bearer {bearer}"}, + timeout=timeout, + sse_read_timeout=sse_read_timeout, + ) as (read_stream, write_stream, _session_id): + async with AppsClientSession(read_stream, write_stream) as session: + await session.initialize() + yield session + + +def ui_resource_uri(meta: Any) -> str | None: + """Return the ``ui://`` panel a ``_meta`` block points at, if any. + + Accepts the ``_meta`` of a tool declaration or of a ``CallToolResult``; + the spec puts the pointer in the same place on both, and a result-level + one wins when present. + """ + if not isinstance(meta, dict): + return None + ui = meta.get("ui") + if not isinstance(ui, dict): + return None + uri = ui.get("resourceUri") + if isinstance(uri, str) and uri.startswith("ui://"): + return uri + return None + + +def ui_resource_uris_by_tool(tools: Any) -> dict[str, str]: + """Map tool name -> panel URI over a ``list_tools()`` result.""" + out: dict[str, str] = {} + for tool in tools or []: + name = getattr(tool, "name", "") + uri = ui_resource_uri(getattr(tool, "meta", None)) + if name and uri: + out[name] = uri + return out + + +def call_result_to_wire(result: Any) -> dict[str, Any]: + """Flatten a ``CallToolResult`` into the JSON the iframe expects. + + ``ui/notifications/tool-result`` carries a standard ``CallToolResult``, + and the panels' ``unwrap()`` reads ``structuredContent`` first and falls + back to parsing ``content[0].text``. We keep both so a panel renders + whichever the tool happened to produce. + """ + content: list[dict[str, Any]] = [] + for item in getattr(result, "content", None) or []: + text = getattr(item, "text", None) + if text is not None: + content.append({"type": "text", "text": text}) + continue + mime = getattr(item, "mimeType", None) + content.append({ + "type": getattr(item, "type", "resource") or "resource", + "mimeType": mime or "application/octet-stream", + }) + wire: dict[str, Any] = { + "content": content, + "isError": bool(getattr(result, "isError", False)), + } + structured = getattr(result, "structuredContent", None) + if structured is not None: + wire["structuredContent"] = structured + return wire + + +class UiResourceError(Exception): + """A ``ui://`` read failed, or came back as something we won't frame.""" + + +async def read_ui_resource(session: ClientSession, uri: str) -> str: + """Read a ``ui://`` document, refusing anything that is not an app. + + The MIME check is the gate on the panel route: it is what stops the + endpoint from being used as a general resource proxy that renders, say, + ``beaconmcp://infrastructure`` as HTML inside the page. + """ + if not uri.startswith("ui://"): + raise UiResourceError("not a ui:// resource") + try: + result = await session.read_resource(AnyUrl(uri)) + except Exception as exc: # noqa: BLE001 + raise UiResourceError(str(exc)) from exc + + for item in getattr(result, "contents", None) or []: + text = getattr(item, "text", None) + if text is None: + continue + mime = (getattr(item, "mimeType", None) or "").replace(" ", "") + if mime != APP_MIME_TYPE: + raise UiResourceError(f"unexpected mime type {mime or '(none)'}") + return text + raise UiResourceError("resource has no text content") + + +# --------------------------------------------------------------------------- +# Document cache +# --------------------------------------------------------------------------- + +# Panel documents are static assets read off the server's disk, so a page +# with four panels on it should not mean four round-trips through the MCP +# handshake. Keyed by (mcp_url, uri) because one dashboard build can point +# at different servers across restarts; short TTL so a `beaconmcp update` +# that ships a new panel is picked up without a dashboard restart. +_CACHE_TTL_SECONDS = 300.0 +_CACHE_MAX_ENTRIES = 32 +_cache: dict[tuple[str, str], tuple[float, str]] = {} + + +def cache_get(mcp_url: str, uri: str) -> str | None: + entry = _cache.get((mcp_url, uri)) + if entry is None: + return None + stored_at, html = entry + if time.monotonic() - stored_at > _CACHE_TTL_SECONDS: + _cache.pop((mcp_url, uri), None) + return None + return html + + +def cache_put(mcp_url: str, uri: str, html: str) -> None: + if len(_cache) >= _CACHE_MAX_ENTRIES: + oldest = min(_cache, key=lambda k: _cache[k][0]) + _cache.pop(oldest, None) + _cache[(mcp_url, uri)] = (time.monotonic(), html) + + +def cache_clear() -> None: + _cache.clear() diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index b53ece4..2c14ba5 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -762,6 +762,96 @@ body.chat-page { } .tool-card.open .tool-chev { transform: rotate(180deg); } +/* MCP Apps panels ------------------------------------------------- */ + +.panel { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-elev); + overflow: hidden; + animation: tool-in 350ms var(--ease-out) both; +} + +.panel-head { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px 8px 12px; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-soft); +} +.panel-icon { display: flex; color: var(--accent); } +.panel-name { + font-family: var(--font-mono); + font-size: 12px; + color: var(--fg-mid); +} +.panel-spacer { flex: 1; } + +.panel-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--fg-faint); + cursor: pointer; + transition: color 150ms var(--ease-out), background 150ms var(--ease-out); +} +.panel-btn:hover { color: var(--fg); background: var(--bg-softer); } + +.panel-frame { display: block; } +.panel-iframe { + display: block; + width: 100%; + border: 0; + background: var(--bg-elev); + transition: height 200ms var(--ease-out); +} + +.panel-open { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 18px 12px; + border: 0; + background: transparent; + color: var(--fg-muted); + font: inherit; + font-size: 13px; + cursor: pointer; +} +.panel-open:hover { background: var(--bg-soft); color: var(--fg); } + +.panel-note { + padding: 8px 12px; + border-top: 1px solid var(--border-subtle); + font-size: 12px; + color: var(--fg-mid); +} +.panel-note[data-kind="error"] { color: var(--danger); background: var(--danger-soft); } +.panel-note[data-kind="warn"] { color: var(--warn); background: var(--warn-soft); } + +/* Fullscreen is a host decision, so it is the host that resizes -- the + panel only asks via ui/request-display-mode. */ +.panel[data-mode="fullscreen"] { + position: fixed; + inset: 16px; + z-index: 60; + display: flex; + flex-direction: column; + border-radius: var(--radius); + box-shadow: var(--shadow-lg); +} +.panel[data-mode="fullscreen"] .panel-frame { flex: 1; min-height: 0; } +.panel[data-mode="fullscreen"] .panel-iframe { height: 100%; } +body.panel-fullscreen { overflow: hidden; } + /* Confirmation card ---------------------------------------------- */ .confirm-card { diff --git a/src/beaconmcp/dashboard/static/chat.js b/src/beaconmcp/dashboard/static/chat.js index d7dcc45..9b13eea 100644 --- a/src/beaconmcp/dashboard/static/chat.js +++ b/src/beaconmcp/dashboard/static/chat.js @@ -14,17 +14,25 @@ const VALID_EFFORTS = JSON.parse(root.dataset.validEfforts || "[]"); // --- Model catalog (mirrors src/beaconmcp/dashboard/conversations.py) --- const MODEL_CATALOG = { + "gemini-3.6-flash": { + shortName: "3.6 Flash", name: "Gemini 3.6 Flash", group: "Flash", preview: false, + }, + "gemini-3.5-flash-lite": { + shortName: "3.5 Lite", name: "Gemini 3.5 Flash-Lite", group: "Flash", preview: false, + }, + "gemini-3.1-pro-preview": { + shortName: "3.1 Pro", name: "Gemini 3.1 Pro", group: "Pro", preview: true, + }, + // Retired from the picker but still rendered on stored messages, which + // record whichever model actually wrote the reply. "gemini-2.5-flash": { - shortName: "2.5 Flash", name: "Gemini 2.5 Flash", group: "Gemini 2", preview: false, + shortName: "2.5 Flash", name: "Gemini 2.5 Flash", group: "Legacy", preview: false, }, "gemini-2.5-pro": { - shortName: "2.5 Pro", name: "Gemini 2.5 Pro", group: "Gemini 2", preview: false, + shortName: "2.5 Pro", name: "Gemini 2.5 Pro", group: "Legacy", preview: false, }, "gemini-3-flash-preview": { - shortName: "3 Flash", name: "Gemini 3 Flash", group: "Gemini 3", preview: true, - }, - "gemini-3.1-pro-preview": { - shortName: "3.1 Pro", name: "Gemini 3.1 Pro", group: "Gemini 3", preview: true, + shortName: "3 Flash", name: "Gemini 3 Flash", group: "Legacy", preview: true, }, }; @@ -300,6 +308,10 @@ const state = { abortController: null, model: DEFAULT_MODEL, effort: DEFAULT_EFFORT, + // Latest ui/update-model-context from each open panel, keyed by tool-call + // id so a panel that pushes twice overwrites itself rather than piling up. + // Drained into the next turn, then cleared. + appContext: new Map(), }; const el = { @@ -567,6 +579,7 @@ function toolNeedsConfirm(name, args) { } function renderMessages() { + destroyAllPanels("conversation re-rendered"); el.messages.innerHTML = ""; if (!state.messages.length) { el.messages.append(renderEmptyState()); @@ -609,6 +622,7 @@ function renderMessage(m) { const card = renderToolCard(tc, m.id); toolCardMap.set(tc.id, card); row.append(card); + if (tc.ui_resource_uri) attachPanel(card, tc, null); } if (!m.streaming) { @@ -774,6 +788,426 @@ function scrollToBottom() { el.messages.scrollTop = el.messages.scrollHeight; } +// ---------------- MCP Apps host ---------------- +// +// A tool that carries _meta.ui.resourceUri gets its ui:// document rendered +// here instead of its JSON. The document is served by /app/api/mcp/panel and +// framed with sandbox="allow-scripts" and NO allow-same-origin, so it runs on +// an opaque origin: no cookies, no CSRF token, no reach into this page. Its +// only way out is postMessage to us, which is why every policy decision -- +// which tools it may call, whether it may talk to the model -- is made on +// this side of the boundary and never inside the frame. +// +// The dialect is JSON-RPC 2.0 (MCP Apps, SEP-1865). We implement the host +// half by hand rather than porting the SDK's AppBridge: it is TypeScript and +// this dashboard ships JS with no build step. + +const UI_PROTOCOL_VERSION = "2026-01-26"; +const HOST_INFO = { name: "beaconmcp-dashboard", version: "1.0.0" }; +const PANEL_MAX_HEIGHT = 640; +const PANEL_MIN_HEIGHT = 140; + +const panels = new Set(); + +function currentTheme() { + return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light"; +} + +// The panels are told our palette under the spec's standardized names, so +// they sit inside the dashboard's theme rather than next to it. Computed +// values, not var() references: the frame cannot resolve our variables. +function hostStyleVariables() { + const cs = getComputedStyle(document.documentElement); + const v = (name) => cs.getPropertyValue(name).trim(); + const map = { + "--color-background-primary": v("--bg-elev") || v("--bg"), + "--color-background-secondary": v("--bg-soft"), + "--color-background-tertiary": v("--bg-softer"), + "--color-text-primary": v("--fg"), + "--color-text-secondary": v("--fg-mid"), + "--color-text-tertiary": v("--fg-muted"), + "--color-text-danger": v("--danger"), + "--color-text-success": v("--success"), + "--color-text-warning": v("--warn"), + "--color-border-primary": v("--border"), + "--color-border-secondary": v("--border-subtle"), + "--color-ring-primary": v("--accent"), + "--font-sans": v("--font"), + "--font-mono": v("--font-mono"), + "--border-radius-sm": v("--radius-sm"), + "--border-radius-md": v("--radius"), + }; + for (const key of Object.keys(map)) if (!map[key]) delete map[key]; + return map; +} + +function hostContext(panel) { + return { + theme: currentTheme(), + styles: { variables: hostStyleVariables() }, + displayMode: panel.displayMode, + availableDisplayModes: ["inline", "fullscreen"], + containerDimensions: { maxHeight: PANEL_MAX_HEIGHT }, + locale: navigator.language || "en-US", + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + userAgent: HOST_INFO.name, + platform: "web", + deviceCapabilities: { + touch: matchMedia("(pointer: coarse)").matches, + hover: matchMedia("(hover: hover)").matches, + }, + toolInfo: { tool: { name: panel.toolName } }, + }; +} + +// Capabilities we actually implement. Anything absent here is a method the +// panels are expected to hide rather than call -- ui/open-link for instance +// is not advertised, and the relay rejects it. +function hostCapabilities() { + return { + serverTools: {}, + updateModelContext: { text: {}, structuredContent: {} }, + message: { text: {} }, + }; +} + +async function relayToolCall(name, args) { + const res = await fetch("/app/api/mcp/call", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "X-CSRF-Token": csrfToken(), + }, + body: JSON.stringify({ name, arguments: args || {} }), + credentials: "same-origin", + }); + if (res.status === 401) { + window.location.href = "/app/refresh?next=/app/chat"; + throw new Error("unauthorized"); + } + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(data.message || data.error || `tool call failed (${res.status})`); + err.refused = data.error === "confirmation_required"; + throw err; + } + return data.result; +} + +function createPanel({ callId, toolName, toolArgs, uri, result }) { + const panel = { + callId, + toolName, + toolArgs: toolArgs || {}, + uri, + result: result || null, + displayMode: "inline", + frame: null, + ready: false, + root: null, + note: null, + }; + + const note = h("div", { class: "panel-note", hidden: true }); + const wrap = h("div", { class: "panel-frame" }); + + const fullscreenBtn = h("button", { + type: "button", class: "panel-btn", "aria-label": "Toggle fullscreen", + onClick: () => setDisplayMode(panel, panel.displayMode === "fullscreen" ? "inline" : "fullscreen"), + }, [icon("expand", 13)]); + + const closeBtn = h("button", { + type: "button", class: "panel-btn", "aria-label": "Close panel", + onClick: () => destroyPanel(panel, "closed by user"), + }, [icon("x", 13)]); + + const root = h("div", { class: "panel", dataset: { mode: "inline" } }, [ + h("div", { class: "panel-head" }, [ + h("div", { class: "panel-icon" }, [icon("gauge", 13)]), + h("span", { class: "panel-name", text: toolName }), + h("span", { class: "panel-spacer" }), + fullscreenBtn, + closeBtn, + ]), + wrap, + note, + ]); + + panel.root = root; + panel.note = note; + panel.wrap = wrap; + return panel; +} + +function panelSetNote(panel, message, kind) { + if (!message) { + panel.note.hidden = true; + panel.note.textContent = ""; + return; + } + panel.note.hidden = false; + panel.note.textContent = message; + panel.note.dataset.kind = kind || "info"; +} + +function mountPanelFrame(panel) { + if (panel.frame) return; + const frame = h("iframe", { + class: "panel-iframe", + // No allow-same-origin: the document must stay on an opaque origin. + // Adding it would let the frame read this page's cookies and call the + // dashboard API directly, which is precisely what the relay exists to + // prevent. + sandbox: "allow-scripts allow-forms", + referrerpolicy: "no-referrer", + title: `${panel.toolName} panel`, + src: `/app/api/mcp/panel?uri=${encodeURIComponent(panel.uri)}`, + style: { height: `${PANEL_MIN_HEIGHT}px` }, + }); + frame.addEventListener("error", () => { + panelSetNote(panel, "The panel document could not be loaded.", "error"); + }); + panel.frame = frame; + panel.wrap.innerHTML = ""; + panel.wrap.append(frame); + panels.add(panel); +} + +function destroyPanel(panel, reason) { + if (panel.frame && panel.ready) { + // Best effort: the frame is about to go away, so we do not wait for the + // acknowledgement the spec allows it to send. + postToPanel(panel, { + jsonrpc: "2.0", id: `teardown-${panel.callId}`, + method: "ui/resource-teardown", params: { reason: reason || "teardown" }, + }); + } + panels.delete(panel); + panel.ready = false; + panel.frame = null; + if (panel.displayMode === "fullscreen") document.body.classList.remove("panel-fullscreen"); + panel.root.remove(); +} + +function destroyAllPanels(reason) { + for (const panel of [...panels]) destroyPanel(panel, reason || "conversation changed"); +} + +function setDisplayMode(panel, mode) { + const next = mode === "fullscreen" ? "fullscreen" : "inline"; + panel.displayMode = next; + panel.root.dataset.mode = next; + document.body.classList.toggle("panel-fullscreen", next === "fullscreen"); + if (next === "inline" && panel.frame) { + panel.frame.style.height = `${panel.lastHeight || PANEL_MIN_HEIGHT}px`; + } else if (panel.frame) { + panel.frame.style.height = ""; + } + notifyPanel(panel, "ui/notifications/host-context-changed", { displayMode: next }); + return next; +} + +function postToPanel(panel, message) { + if (!panel.frame || !panel.frame.contentWindow) return; + // "*" because the frame is sandboxed onto an opaque origin, which cannot + // be named as a targetOrigin. Confidentiality comes from the frame being + // a document we served and nobody else being able to receive this. + panel.frame.contentWindow.postMessage(message, "*"); +} + +function notifyPanel(panel, method, params) { + if (!panel.ready) return; + postToPanel(panel, { jsonrpc: "2.0", method, params }); +} + +function replyToPanel(panel, id, result) { + postToPanel(panel, { jsonrpc: "2.0", id, result }); +} + +function replyErrorToPanel(panel, id, message, code) { + postToPanel(panel, { + jsonrpc: "2.0", id, error: { code: code || -32000, message }, + }); +} + +// Push the tool result the panel renders from. On a live turn we already +// have it; reopening a panel from history refetches instead, because the +// snapshot a panel was built on is stale by then and showing month-old CPU +// figures in a live-looking dashboard is worse than a short spinner. +async function deliverToolResult(panel) { + notifyPanel(panel, "ui/notifications/tool-input", { arguments: panel.toolArgs }); + let result = panel.result; + if (!result) { + try { + result = await relayToolCall(panel.toolName, panel.toolArgs); + panel.result = result; + } catch (err) { + notifyPanel(panel, "ui/notifications/tool-cancelled", { + reason: err.message || "could not refresh the panel", + }); + panelSetNote(panel, err.message || "Could not refresh the panel.", "error"); + return; + } + } + notifyPanel(panel, "ui/notifications/tool-result", result); +} + +async function handlePanelRequest(panel, msg) { + const { id, method, params } = msg; + switch (method) { + case "ui/initialize": { + panel.ready = true; + replyToPanel(panel, id, { + protocolVersion: UI_PROTOCOL_VERSION, + hostInfo: HOST_INFO, + hostCapabilities: hostCapabilities(), + hostContext: hostContext(panel), + }); + return; + } + case "ping": + replyToPanel(panel, id, {}); + return; + case "tools/call": { + const name = params && params.name; + const args = (params && params.arguments) || {}; + if (!name) { + replyErrorToPanel(panel, id, "tools/call needs a name", -32602); + return; + } + try { + replyToPanel(panel, id, await relayToolCall(name, args)); + } catch (err) { + if (err.refused) panelSetNote(panel, err.message, "warn"); + replyErrorToPanel(panel, id, err.message || "tool call failed"); + } + return; + } + case "ui/update-model-context": { + const text = (params && Array.isArray(params.content)) + ? params.content.filter((c) => c && c.type === "text").map((c) => c.text).join("\n") + : null; + state.appContext.set(panel.callId, { + tool: panel.toolName, + text: text || null, + structured: (params && params.structuredContent) ?? null, + }); + replyToPanel(panel, id, {}); + return; + } + case "ui/message": { + const content = params && params.content; + const blocks = Array.isArray(content) ? content : [content]; + const text = blocks + .filter((c) => c && c.type === "text" && typeof c.text === "string") + .map((c) => c.text).join("\n").trim(); + if (!text) { + replyErrorToPanel(panel, id, "Invalid message format", -32602); + return; + } + if (state.streaming) { + replyErrorToPanel(panel, id, "A turn is already running. Try again once it finishes."); + return; + } + replyToPanel(panel, id, {}); + // The panel asked the model to do something. It goes in as an ordinary + // user turn, which is what puts anything dangerous it leads to back + // under the approval modal. + sendUserText(text).catch((err) => console.error(err)); + return; + } + case "ui/request-display-mode": { + const mode = setDisplayMode(panel, params && params.mode); + replyToPanel(panel, id, { mode }); + return; + } + default: + replyErrorToPanel(panel, id, `Unsupported method: ${method}`, -32601); + } +} + +function handlePanelNotification(panel, msg) { + switch (msg.method) { + case "ui/notifications/initialized": + deliverToolResult(panel).catch((err) => console.error(err)); + break; + case "ui/notifications/size-changed": { + const height = Number(msg.params && msg.params.height) || 0; + if (!panel.frame || !height) break; + panel.lastHeight = Math.min(PANEL_MAX_HEIGHT, Math.max(PANEL_MIN_HEIGHT, Math.ceil(height))); + if (panel.displayMode === "inline") panel.frame.style.height = `${panel.lastHeight}px`; + break; + } + case "notifications/message": + console.debug("[panel]", panel.toolName, msg.params); + break; + } +} + +window.addEventListener("message", (event) => { + const msg = event.data; + if (!msg || msg.jsonrpc !== "2.0") return; + for (const panel of [...panels]) { + if (!panel.frame || !panel.frame.isConnected) { panels.delete(panel); continue; } + // Identity by window handle, not by origin: a sandboxed frame reports + // origin "null", which is a value any other opaque context also has. + if (event.source !== panel.frame.contentWindow) continue; + if (msg.method && msg.id != null) handlePanelRequest(panel, msg).catch((e) => console.error(e)); + else if (msg.method) handlePanelNotification(panel, msg); + return; + } +}); + +// Follow the dashboard's own light/dark switch. +new MutationObserver(() => { + for (const panel of panels) { + notifyPanel(panel, "ui/notifications/host-context-changed", { + theme: currentTheme(), + styles: { variables: hostStyleVariables() }, + }); + } +}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + +document.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + for (const panel of panels) { + if (panel.displayMode === "fullscreen") { setDisplayMode(panel, "inline"); break; } + } +}); + +/** + * Insert the panel a tool call points at, right after its tool card. + * + * ``result`` is present on a live turn and absent when the conversation is + * reloaded from history -- in that case the panel is a click away rather + * than mounted eagerly, so opening an old thread does not fan out a tool + * call per panel it happens to contain. + */ +function attachPanel(afterNode, tc, uiPayload) { + const uri = (uiPayload && uiPayload.resourceUri) || tc.ui_resource_uri; + if (!uri || !afterNode || !afterNode.parentNode) return; + if (afterNode.nextSibling && afterNode.nextSibling.classList?.contains("panel")) return; + + const panel = createPanel({ + callId: tc.id, + toolName: tc.name, + toolArgs: tc.args || {}, + uri, + result: uiPayload && uiPayload.result, + }); + afterNode.parentNode.insertBefore(panel.root, afterNode.nextSibling); + + if (panel.result) { + mountPanelFrame(panel); + } else { + panel.wrap.append(h("button", { + type: "button", class: "panel-open", + onClick: (e) => { e.currentTarget.remove(); mountPanelFrame(panel); }, + }, [icon("gauge", 14), h("span", { text: "Open panel" })])); + } +} + // ---------------- Usage footer + modal ---------------- const stateUsage = { last: null, lastLoadedAt: 0 }; @@ -959,7 +1393,21 @@ async function submit() { } const text = el.composer.value.trim(); if (!text) return; + el.composer.value = ""; + autogrow(); + try { + await sendUserText(text); + } catch (err) { + // Creating the conversation can fail before the message is ever shown. + // Put what they typed back rather than swallowing it. + if (!el.composer.value) { el.composer.value = text; autogrow(); } + throw err; + } +} +// Also the entry point for ui/message: a panel that asks the model to look +// into something produces exactly the turn the operator would have typed. +async function sendUserText(text) { if (!state.active) await createConversation(); // Empty-state becomes inner list. @@ -978,9 +1426,6 @@ async function submit() { inner.append(renderMessage(optimistic)); scrollToBottom(); - el.composer.value = ""; - autogrow(); - await streamTurn(text, inner); } @@ -1034,6 +1479,10 @@ async function streamTurn(userText, inner) { content: userText, model: state.model, effort: state.effort, + // Whatever the open panels have pushed since the last turn. Sent + // once and dropped: the spec says only the latest update needs to + // reach the model, and it is already in the transcript afterwards. + app_context: appContextPayload(), }), credentials: "same-origin", signal: controller.signal, @@ -1101,6 +1550,13 @@ async function streamTurn(userText, inner) { } } +function appContextPayload() { + if (!state.appContext.size) return []; + const payload = [...state.appContext.values()]; + state.appContext.clear(); + return payload; +} + function parseSseFrame(chunk) { let event = null; const dataLines = []; @@ -1181,9 +1637,13 @@ function handleEvent({ event, data }, assistantMsg, row, toolCardMap, thinkingCt entry.tc.status = data.status; entry.tc.preview = data.preview; entry.tc.duration_ms = data.duration_ms; + if (data.ui && data.ui.resourceUri) entry.tc.ui_resource_uri = data.ui.resourceUri; const fresh = renderToolCard(entry.tc, assistantMsg.id); entry.card.replaceWith(fresh); entry.card = fresh; + // After the swap: the panel is a sibling of the card, so replacing + // the card would otherwise tear down a freshly mounted iframe. + if (data.ui) attachPanel(fresh, entry.tc, data.ui); break; } case "session_expired": { diff --git a/src/beaconmcp/dashboard/templates/chat.html b/src/beaconmcp/dashboard/templates/chat.html index 4b0a847..4104c4c 100644 --- a/src/beaconmcp/dashboard/templates/chat.html +++ b/src/beaconmcp/dashboard/templates/chat.html @@ -30,6 +30,7 @@ +
    float: - rates = _PRICING.get(model) or _PRICING["gemini-2.5-flash"] + rates = _PRICING.get(model) or _PRICING["gemini-3.6-flash"] use_hi = prompt_tokens > _TIER_THRESHOLD and "input_hi" in rates in_rate = rates["input_hi"] if use_hi else rates["input"] out_rate = rates["output_hi"] if use_hi else rates["output"] diff --git a/src/beaconmcp/proxmox/apps/bridge.js b/src/beaconmcp/proxmox/apps/bridge.js new file mode 100644 index 0000000..008d6c1 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/bridge.js @@ -0,0 +1,176 @@ +// Shared MCP Apps client bridge. +// +// JSON-RPC 2.0 over postMessage to the host, per the ext-apps spec. Injected +// into each ui:// document by panel.py in place of the +// marker, so the panels stay single self-contained resources. +// +// Deliberately small and dependency-free: an app resource is preloaded by the +// host before the tool even runs, so every kilobyte is paid up front. + +const MCPApp = (() => { + "use strict"; + + const PROTOCOL_VERSION = "2026-01-26"; + + let nextId = 0; + const pending = new Map(); + let hostCapabilities = {}; + let onToolResult = null; + let onHostContext = null; + let ready = false; + + function post(message) { + window.parent.postMessage(message, "*"); + } + + function request(method, params) { + const id = ++nextId; + post({ jsonrpc: "2.0", id, method, params }); + return new Promise((resolve, reject) => pending.set(id, { resolve, reject })); + } + + function notify(method, params) { + post({ jsonrpc: "2.0", method, params }); + } + + // The host owns the iframe height, so it has to be told when the content + // reflows -- otherwise the panel renders into a fixed sliver. + function reportSize() { + notify("ui/notifications/size-changed", { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }); + } + + // A CallToolResult carries the dict in structuredContent, but a host that + // strips it still sends the JSON as text -- fall back rather than blank out. + function unwrap(result) { + if (result && result.structuredContent) return result.structuredContent; + const first = result && result.content && result.content[0]; + if (first && first.type === "text") { + try { return JSON.parse(first.text); } catch { return null; } + } + return null; + } + + async function callTool(name, args) { + const result = await request("tools/call", { name, arguments: args }); + const data = unwrap(result); + if (result && result.isError) { + throw new Error((data && data.error) || "tool call failed"); + } + if (data && data.error) throw new Error(data.error); + return data; + } + + // Both of these are gated on host capabilities. Calling one the host did not + // advertise gets an error back, so check first and no-op quietly: a panel + // that works everywhere beats one that throws on a stricter host. + function updateModelContext(structuredContent, text) { + if (!hostCapabilities.updateModelContext) return Promise.resolve(false); + return request("ui/update-model-context", { + content: text ? [{ type: "text", text }] : undefined, + structuredContent, + }).then(() => true, () => false); + } + + function sendMessage(text) { + if (!hostCapabilities.message) return Promise.resolve(false); + return request("ui/message", { + role: "user", + content: [{ type: "text", text }], + }).then((r) => !(r && r.isError), () => false); + } + + function requestDisplayMode(mode) { + return request("ui/request-display-mode", { mode }).then( + (r) => (r && r.mode) || null, + () => null, + ); + } + + window.addEventListener("message", (event) => { + const message = event.data; + if (!message || message.jsonrpc !== "2.0") return; + + if (message.id != null && pending.has(message.id)) { + const { resolve, reject } = pending.get(message.id); + pending.delete(message.id); + if (message.error) reject(new Error(message.error.message || "request failed")); + else resolve(message.result); + return; + } + + switch (message.method) { + case "ui/notifications/tool-result": + if (onToolResult) onToolResult(unwrap(message.params)); + break; + case "ui/notifications/host-context-changed": + if (onHostContext) onHostContext(message.params); + break; + case "ui/resource-teardown": + post({ jsonrpc: "2.0", id: message.id, result: {} }); + break; + } + }); + + function applyHostContext(ctx) { + if (!ctx) return; + if (ctx.theme) document.documentElement.dataset.theme = ctx.theme; + const vars = ctx.styles && ctx.styles.variables; + if (vars) { + for (const [key, value] of Object.entries(vars)) { + document.documentElement.style.setProperty(key, value); + } + } + } + + /** + * Perform the ui/initialize handshake. + * + * Params are flat: appInfo / appCapabilities / protocolVersion. Nesting the + * capabilities or sending clientInfo instead of appInfo fails the host's + * schema check, and a rejected handshake is silent -- the host simply never + * answers, so the app never sends `initialized` and the host never delivers + * the tool result. See @modelcontextprotocol/ext-apps App.connect(). + * + * `onFail` is called if the host never completes the handshake, so the panel + * can say so instead of sitting on a spinner forever. + */ + function connect({ name, version = "1.0.0", onResult, onContext, onFail }) { + onToolResult = onResult; + onHostContext = (ctx) => { applyHostContext(ctx); if (onContext) onContext(ctx); }; + + setTimeout(() => { + if (!ready && onFail) { + onFail(new Error("The host did not complete the ui/initialize handshake.")); + } + }, 5000); + + return request("ui/initialize", { + appInfo: { name, version }, + appCapabilities: { availableDisplayModes: ["inline", "fullscreen"] }, + protocolVersion: PROTOCOL_VERSION, + }).then((result) => { + ready = true; + hostCapabilities = (result && result.hostCapabilities) || {}; + applyHostContext(result && result.hostContext); + notify("ui/notifications/initialized", {}); + reportSize(); + return result; + }).catch((err) => { + if (onFail) onFail(err); + throw err; + }); + } + + return { + connect, + callTool, + updateModelContext, + sendMessage, + requestDisplayMode, + reportSize, + hostSupports: (name) => Boolean(hostCapabilities[name]), + }; +})(); diff --git a/src/beaconmcp/proxmox/apps/cluster_panel.html b/src/beaconmcp/proxmox/apps/cluster_panel.html new file mode 100644 index 0000000..c89f853 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/cluster_panel.html @@ -0,0 +1,292 @@ + + + +Cluster dashboard + + + +
    Loading…
    + +
    +
    +
    +

    Cluster

    +
    +
    + +
    + +
    + +
    Guests
    +
    +
    + +
    +
    + +
    +
    +
    + + + + +
    + + diff --git a/src/beaconmcp/proxmox/apps/logs_panel.html b/src/beaconmcp/proxmox/apps/logs_panel.html new file mode 100644 index 0000000..907b565 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/logs_panel.html @@ -0,0 +1,194 @@ + + + +Log viewer + + + +
    Loading…
    + +
    +
    +
    +

    +
    +
    + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + +
    + +
    + + diff --git a/src/beaconmcp/proxmox/apps/panel.css b/src/beaconmcp/proxmox/apps/panel.css new file mode 100644 index 0000000..5d31a81 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/panel.css @@ -0,0 +1,96 @@ +/* Shared look for the ui:// panels. Injected alongside bridge.js by panel.py. + + Every colour is a host variable with a local fallback. A host that sends + hostContext.styles.variables (the spec's standardized names, which + bridge.js writes onto :root) drives the palette; one that sends nothing + but a theme falls through to the values below. Both halves of the theme + read the same variable so the host's choice wins either way -- the dark + block only changes what happens when it stays silent. */ + +:root { + --bg: var(--color-background-primary, #ffffff); + --fg: var(--color-text-primary, #1a1a1a); + --muted: var(--color-text-tertiary, #6b7280); + --line: var(--color-border-primary, #e5e7eb); + --accent: var(--color-ring-primary, #2563eb); + --danger: var(--color-text-danger, #dc2626); + --ok: var(--color-text-success, #16a34a); + --warn: var(--color-text-warning, #d97706); + --track: var(--color-background-tertiary, #f3f4f6); +} +:root[data-theme="dark"] { + --bg: var(--color-background-primary, #1c1c1c); + --fg: var(--color-text-primary, #ededed); + --muted: var(--color-text-tertiary, #9ca3af); + --line: var(--color-border-primary, #333333); + --accent: var(--color-ring-primary, #60a5fa); + --danger: var(--color-text-danger, #f87171); + --ok: var(--color-text-success, #4ade80); + --warn: var(--color-text-warning, #fbbf24); + --track: var(--color-background-tertiary, #2a2a2a); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 14px; + background: var(--bg); + color: var(--fg); + font: 13px/1.45 var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif); +} + +h1 { font-size: 15px; margin: 0; font-weight: 600; } +.sub { color: var(--muted); font-size: 12px; margin-top: 2px; } +header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } + +.badge { + padding: 2px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: .04em; white-space: nowrap; + border: 1px solid var(--line); color: var(--muted); +} +.badge[data-status="running"], .badge[data-status="online"], .badge[data-status="OK"] { color: var(--ok); border-color: currentColor; } +.badge[data-status="paused"] { color: var(--accent); border-color: currentColor; } +.badge[data-status="unreachable"], .badge[data-status="unknown"] { color: var(--danger); border-color: currentColor; } + +.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 14px 0; } +.metric { border: 1px solid var(--line); border-radius: 8px; padding: 9px 10px; } +.metric .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; } +.metric .value { font-size: 15px; font-weight: 600; margin-top: 3px; font-variant-numeric: tabular-nums; } + +.bar { height: 4px; border-radius: 2px; background: var(--track); margin-top: 7px; overflow: hidden; } +.bar > i { display: block; height: 100%; background: var(--accent); } +.bar > i[data-level="warn"] { background: var(--warn); } +.bar > i[data-level="high"] { background: var(--danger); } + +fieldset { border: 0; margin: 0; padding: 0; } +fieldset[disabled] { opacity: .55; } +.row { display: flex; flex-wrap: wrap; gap: 7px; align-items: flex-end; } + +button { + font: inherit; padding: 6px 13px; border-radius: 6px; cursor: pointer; + border: 1px solid var(--line); background: transparent; color: var(--fg); +} +button:hover:not(:disabled) { border-color: var(--accent); } +button:disabled { cursor: not-allowed; opacity: .45; } +button.danger:hover:not(:disabled) { border-color: var(--danger); color: var(--danger); } + +label { display: block; font-size: 11px; color: var(--muted); margin-bottom: 3px; } +input, select { + font: inherit; padding: 5px 7px; border-radius: 6px; + border: 1px solid var(--line); background: var(--bg); color: var(--fg); +} + +hr { border: 0; border-top: 1px solid var(--line); margin: 14px 0; } +.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 8px; } + +.note { margin-top: 12px; padding: 8px 10px; border-radius: 6px; font-size: 12px; border: 1px solid var(--line); } +.note[data-kind="error"], #loading[data-kind="error"] { color: var(--danger); border-color: currentColor; } +.note[data-kind="ok"] { color: var(--ok); border-color: currentColor; } + +table { border-collapse: collapse; width: 100%; font-size: 12px; } +th { text-align: left; font-weight: 600; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; padding: 5px 8px; } +td { padding: 5px 8px; border-top: 1px solid var(--line); } +tbody tr:hover { background: var(--track); } + +[hidden] { display: none !important; } diff --git a/src/beaconmcp/proxmox/apps/vm_panel.html b/src/beaconmcp/proxmox/apps/vm_panel.html new file mode 100644 index 0000000..29fe698 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/vm_panel.html @@ -0,0 +1,215 @@ + + + +VM control panel + + +
    Loading…
    + +
    +
    +
    +

    +
    +
    + +
    + +
    +
    +
    CPU
    +
    +
    +
    +
    +
    Memory
    +
    +
    +
    +
    +
    Disk
    +
    +
    +
    +
    +
    Uptime
    +
    +
    +
    + +
    +
    Power
    +
    + + + + + +
    + +
    + +
    Resources
    +
    +
    + + +
    +
    + + +
    + +
    +
    + + +
    + + diff --git a/src/beaconmcp/proxmox/panel.py b/src/beaconmcp/proxmox/panel.py new file mode 100644 index 0000000..0dd1670 --- /dev/null +++ b/src/beaconmcp/proxmox/panel.py @@ -0,0 +1,222 @@ +"""MCP Apps panels. + +The MCP Apps extension (`io.modelcontextprotocol/ui`) lets a tool carry a +reference to an interactive UI: `_meta.ui.resourceUri` points at a `ui://` +resource served as `text/html;profile=mcp-app`, which the host renders in a +sandboxed iframe and talks to over JSON-RPC on `postMessage`. + +The wire format is all this module needs, so it runs on mcp 1.x. The `Apps` +extension class that wraps it lives in mcp 2.0 and requires `MCPServer`; the +two knobs it sets -- `meta=` on the tool and `mime_type=` on the resource -- +are already on `FastMCP`. + +Hosts that did not negotiate Apps ignore `_meta.ui` and just show the tool's +return value, which is why every panel tool returns its full snapshot as data +rather than a "see the panel" placeholder. + +Each panel is one HTML file under ``apps/``. They share ``bridge.js`` (the +JSON-RPC client) and ``panel.css`` (the look), spliced in at the +```` marker so what ships to the host stays a single +self-contained document. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from .aggregators import ( + _collect_node_summaries, + _collect_storage_summaries, + _collect_vm_summaries, +) +from .client import ProxmoxClient + +APP_MIME_TYPE = "text/html;profile=mcp-app" + +VM_PANEL_URI = "ui://beaconmcp/vm-panel.html" +LOGS_PANEL_URI = "ui://beaconmcp/logs-panel.html" +CLUSTER_PANEL_URI = "ui://beaconmcp/cluster-panel.html" + +_APPS_DIR = Path(__file__).parent / "apps" +_RUNTIME_MARKER = "" + +_MB = 1048576 +_GB = 1073741824 + + +def _read_app(name: str) -> str: + """Load a panel document with the shared CSS and bridge spliced in.""" + html = (_APPS_DIR / name).read_text(encoding="utf-8") + runtime = ( + f"" + f"" + ) + return html.replace(_RUNTIME_MARKER, runtime) + + +def _vm_snapshot(client: ProxmoxClient, node: str, vmid: int) -> dict[str, Any]: + """Everything the VM panel renders, in one pass over both guest types.""" + for vm_type in ("qemu", "lxc"): + data = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/status/current") + if isinstance(data, dict) and "error" in data: + if "does not exist" in str(data["error"]).lower(): + continue + return data + if not isinstance(data, dict) or not data.get("status"): + continue + + # QEMU reports disk=0 unless the guest agent is answering, so a zero + # here means "unknown", not "empty". LXC reports it for real. + disk_used = data.get("disk") or 0 + conf = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/config") + if not isinstance(conf, dict) or "error" in conf: + conf = {} + + return { + "node": node, + "vmid": vmid, + "type": vm_type, + "name": data.get("name", ""), + "status": data.get("status"), + "cpu_pct": round(data.get("cpu", 0) * 100, 1), + "cpus": data.get("cpus"), + "mem_used_mb": round(data.get("mem", 0) / _MB), + "mem_max_mb": round(data.get("maxmem", 0) / _MB), + "disk_used_gb": round(disk_used / _GB, 1) if disk_used else None, + "disk_max_gb": round(data.get("maxdisk", 0) / _GB, 1), + "uptime_h": round(data.get("uptime", 0) / 3600, 1), + "cores": conf.get("cores"), + "memory_mb": conf.get("memory"), + } + + return {"error": f"VM/CT {vmid} not found on node '{node}'. Check the VMID and node name."} + + +def register_panel_tools(mcp: FastMCP, client: ProxmoxClient) -> None: + @mcp.tool( + meta={"ui": {"resourceUri": VM_PANEL_URI, "visibility": ["model", "app"]}}, + ) + def proxmox_vm_panel(node: str, vmid: int) -> dict[str, Any]: + """Open an interactive control panel for one VM or container. + + Renders live CPU / RAM / disk state with buttons for start, stop and + restart, and fields to change the CPU core count and memory. Use this + instead of proxmox_vm_status when the user wants to *act* on a guest + rather than just read its numbers, or when they ask to "manage", + "control" or "open" a VM. + + The panel drives the ordinary tools (proxmox_vm_start / _stop / + _restart / _config), so every action it takes goes through the same + approval the client applies to any other tool call. + + Returns: {node, vmid, type, name, status, cpu_pct, cpus, mem_used_mb, + mem_max_mb, disk_used_gb, disk_max_gb, uptime_h, cores, memory_mb}. + ``disk_used_gb`` is null when the guest does not report it. + """ + return _vm_snapshot(client, node, vmid) + + @mcp.tool( + meta={"ui": {"resourceUri": LOGS_PANEL_URI, "visibility": ["model", "app"]}}, + ) + def proxmox_logs_panel(node: str, source: str = "syslog", limit: int = 200) -> dict[str, Any]: + """Open a scrollable, filterable log and task viewer for one node. + + Prefer this over proxmox_get_logs whenever the user wants to *read* + logs rather than have them summarised: the panel keeps every line, + highlights errors and warnings, filters as you type, and can switch + between the syslog and the Proxmox task list without another turn. + + Args: + source: 'syslog' for system logs, 'tasks' for the task history. + limit: Lines to fetch, capped at 500 by the Proxmox API. + + Returns: {node, source, entries: [...]}. For syslog each entry is + {text, level} where level is error/warn/info, guessed from the line. + For tasks each entry is {upid, type, status, user, starttime, endtime, + level}. + """ + limit = min(limit, 500) + + if source == "tasks": + data = client.get(node, f"nodes/{node}/tasks", limit=limit) + if isinstance(data, dict) and "error" in data: + return data + entries = [ + { + "upid": t.get("upid"), + "type": t.get("type"), + "status": t.get("status"), + "user": t.get("user"), + "starttime": t.get("starttime"), + "endtime": t.get("endtime"), + # Proxmox writes "OK" for success and a message otherwise; + # a still-running task has no status yet. + "level": "info" if t.get("status") in ("OK", None) else "error", + } + for t in (data if isinstance(data, list) else []) + ] + return {"node": node, "source": "tasks", "entries": entries} + + data = client.get(node, f"nodes/{node}/syslog", limit=limit) + if isinstance(data, dict) and "error" in data: + return data + entries = [ + {"text": line, "level": _syslog_level(line)} + for line in (entry.get("t", "") for entry in (data if isinstance(data, list) else [])) + ] + return {"node": node, "source": "syslog", "entries": entries} + + @mcp.tool( + meta={"ui": {"resourceUri": CLUSTER_PANEL_URI, "visibility": ["model", "app"]}}, + ) + def cluster_overview_interactive(include_storage: bool = True) -> dict[str, Any]: + """Open an interactive cluster dashboard: nodes, guests and storage. + + The same data as cluster_overview, but rendered as a browsable panel: + nodes with CPU and memory pressure, a searchable guest table with + per-row start/stop, and storage pools with usage bars. Use it when the + user wants to look around the cluster rather than ask one question + about it. + + Returns: {nodes: [...], vms: [...], total_vms, storage: [...]}. + """ + nodes = _collect_node_summaries(client) + vms, total_vms = _collect_vm_summaries(client) + out: dict[str, Any] = {"nodes": nodes, "vms": vms, "total_vms": total_vms} + if include_storage: + out["storage"] = _collect_storage_summaries(client) + return out + + _register_app_resource(mcp, VM_PANEL_URI, "vm-panel", "VM control panel", "vm_panel.html") + _register_app_resource(mcp, LOGS_PANEL_URI, "logs-panel", "Log viewer", "logs_panel.html") + _register_app_resource( + mcp, CLUSTER_PANEL_URI, "cluster-panel", "Cluster dashboard", "cluster_panel.html", + ) + + +# Cheap keyword scan. Proxmox hands us raw journald text with no severity +# field, so the alternative to guessing is showing every line flat -- which is +# the thing this panel exists to fix. Over-flagging a line is harmless; the +# filter box is there for when it gets noisy. +_ERROR_WORDS = ("error", "fail", "fatal", "critical", "panic", "segfault", "refused", "timeout") +_WARN_WORDS = ("warn", "deprecat", "retry", "degraded", "unable") + + +def _syslog_level(line: str) -> str: + lowered = line.lower() + if any(word in lowered for word in _ERROR_WORDS): + return "error" + if any(word in lowered for word in _WARN_WORDS): + return "warn" + return "info" + + +def _register_app_resource( + mcp: FastMCP, uri: str, name: str, title: str, filename: str, +) -> None: + @mcp.resource(uri, name=name, title=title, mime_type=APP_MIME_TYPE) + def _app() -> str: + return _read_app(filename) diff --git a/src/beaconmcp/server.py b/src/beaconmcp/server.py index bcb3141..967a0ad 100644 --- a/src/beaconmcp/server.py +++ b/src/beaconmcp/server.py @@ -16,6 +16,7 @@ from .proxmox.aggregators import register_aggregator_tools from .proxmox.client import ProxmoxClient from .proxmox.monitoring import register_monitoring_tools +from .proxmox.panel import register_panel_tools from .proxmox.system import register_system_tools from .proxmox.vms import register_vm_tools from .security.tools import register_security_tools @@ -282,6 +283,7 @@ def beaconmcp_context() -> str: if config.pve_nodes: register_monitoring_tools(mcp, proxmox_client) register_vm_tools(mcp, proxmox_client) + register_panel_tools(mcp, proxmox_client) register_system_tools(mcp, proxmox_client, ssh_client if config.ssh and config.ssh.hosts else None) # Aggregators ride on top of the Proxmox client and opportunistically # pull BMC facts when the registry is non-empty. diff --git a/tests/test_dashboard_chat.py b/tests/test_dashboard_chat.py index f4f1d85..b57e6d7 100644 --- a/tests/test_dashboard_chat.py +++ b/tests/test_dashboard_chat.py @@ -175,11 +175,11 @@ def test_conv_create_and_list(app_and_client): r = client.post( "/app/api/conversations", headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, - content=json.dumps({"model": "gemini-3-flash-preview", "effort": "medium"}), + content=json.dumps({"model": "gemini-3.5-flash-lite", "effort": "medium"}), ) assert r.status_code == 201 conv = r.json()["conversation"] - assert conv["model"] == "gemini-3-flash-preview" + assert conv["model"] == "gemini-3.5-flash-lite" assert conv["thinking_effort"] == "medium" r = client.get("/app/api/conversations") @@ -848,7 +848,8 @@ def test_chat_page_renders_after_login(app_and_client): r = client.get("/app/chat") assert r.status_code == 200 assert "chat-root" in r.text - assert "gemini-3-flash-preview" in r.text + assert "gemini-3.6-flash" in r.text + assert "gemini-3.5-flash-lite" in r.text assert "gemini-3.1-pro-preview" in r.text @@ -899,3 +900,70 @@ def test_needs_confirmation_includes_run_tools(): assert "ssh_exec_get_result" not in _NEEDS_CONFIRMATION assert not _tool_call_requires_confirmation("proxmox_list_nodes", {}) assert not _tool_call_requires_confirmation("cluster_overview", {}) + + +def test_self_update_needs_confirmation_when_applied(): + """Applying an update is the most consequential call this server has. + + ``beaconmcp_self_update(confirm=True)`` pulls new code, reinstalls + dependencies and restarts the service. Nothing stops an injected + instruction from asking for it, so it must reach the modal -- while the + ``confirm=False`` preview shape stays a plain read. + """ + from beaconmcp.dashboard.chat import _tool_call_requires_confirmation + + assert _tool_call_requires_confirmation("beaconmcp_self_update", {"confirm": True}) + assert not _tool_call_requires_confirmation("beaconmcp_self_update", {"confirm": False}) + assert not _tool_call_requires_confirmation("beaconmcp_self_update", {}) + # Checking is read-only and must not raise a modal. + assert not _tool_call_requires_confirmation("beaconmcp_check_update", {}) + + +def test_every_registered_tool_is_gated_or_deliberately_not(): + """Guard against a new tool quietly landing outside the gate. + + The list below is the reviewed set of tools that may run unattended: + reads, and calls that only add (create/clone/start/backup). A new tool + name showing up here means someone must decide which side it is on -- + which is exactly how ``beaconmcp_self_update`` slipped through when the + self-update tools were added. + """ + import pathlib + import re + + from beaconmcp.dashboard.chat import ( + _CONFIRM_WHEN_ARG_PRESENT, + _NEEDS_CONFIRMATION, + ) + + src_root = pathlib.Path(__file__).parent.parent / "src" / "beaconmcp" + registered: set[str] = set() + for path in src_root.rglob("*.py"): + registered.update( + re.findall( + r"@mcp\.tool\((?:[^)]*|\s*\n(?:.*\n)*?\s*)\)\s*\n\s*(?:async )?def (\w+)", + path.read_text(), + ) + ) + assert "ssh_run" in registered, "tool discovery regex stopped matching" + + ungated_by_design = { + "beaconmcp_check_update", + "bmc_get_event_log", "bmc_health_status", "bmc_list_devices", + "bmc_power_on", "bmc_power_status", "bmc_server_info", + "cluster_health", "cluster_overview", "cluster_overview_interactive", + "proxmox_backup_create", "proxmox_backup_list", + "proxmox_get_logs", "proxmox_get_tasks", + "proxmox_list_nodes", "proxmox_list_transfers", "proxmox_list_vms", + "proxmox_logs_panel", "proxmox_network_config", "proxmox_node_status", + "proxmox_read_file", "proxmox_snapshot_create", "proxmox_snapshot_list", + "proxmox_storage_status", "proxmox_vm_clone", "proxmox_vm_create", + "proxmox_vm_panel", "proxmox_vm_start", "proxmox_vm_status", + "security_end_session", "ssh_list_sessions", "vm_find", + } + gated = set(_NEEDS_CONFIRMATION) | set(_CONFIRM_WHEN_ARG_PRESENT) + unclassified = registered - gated - ungated_by_design + assert not unclassified, ( + "new tool(s) outside the confirmation gate; decide and list them: " + f"{sorted(unclassified)}" + ) diff --git a/tests/test_dashboard_mcp_apps.py b/tests/test_dashboard_mcp_apps.py new file mode 100644 index 0000000..86722d9 --- /dev/null +++ b/tests/test_dashboard_mcp_apps.py @@ -0,0 +1,560 @@ +"""MCP Apps support in the dashboard chat (#35). + +The dashboard is both the MCP client that negotiates the extension and the +host that renders the ``ui://`` documents. These tests pin the parts a +refactor could quietly break without any visible symptom: the capability on +the wire, the headers that decide whether a panel can be framed at all and +what it may reach from inside the frame, and the allow-list that says which +tool calls a panel may make on its own. +""" + +from __future__ import annotations + +import json +import os +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from beaconmcp.dashboard import mcp_bridge +from beaconmcp.dashboard.app import DashboardDeps, build_dashboard_routes +from beaconmcp.dashboard.chat import ( + FakeChatEngine, + FakeScript, + ToolCallEnd, + ToolCallStart, + assemble_assistant_message, + format_app_context, + panel_call_allowed, +) +from beaconmcp.dashboard.confirmations import ConfirmationStore +from beaconmcp.dashboard.conversations import ConversationStore +from beaconmcp.dashboard.csrf import CSRF_COOKIE +from beaconmcp.dashboard.db import Database +from beaconmcp.dashboard.session import SessionStore + +from test_dashboard_chat import FakeClientStore, FakeTokenStore # noqa: E402 + + +PANEL_URI = "ui://beaconmcp/vm-panel.html" +PANEL_HTML = "vm" + + +# --------------------------------------------------------------------------- +# Client half: capability negotiation and _meta.ui plumbing +# --------------------------------------------------------------------------- + +@pytest.mark.anyio +async def test_initialize_declares_the_apps_extension(monkeypatch): + """The extension rides on ``ClientCapabilities`` even on mcp 1.x. + + ``extensions`` only becomes a typed field in mcp 2.0, which is what made + this look blocked behind the ``<2`` pin. It is not: the model allows + extras, so the field serialises under the name the spec gives it. If a + future SDK bump makes ``ClientCapabilities`` strict, this fails here + rather than silently dropping the capability at runtime. + """ + from mcp import types + from mcp.client.session import ClientSession + + captured: dict = {} + + async def fake_send_request(self, request, result_type, **kwargs): + captured["request"] = request + return None + + monkeypatch.setattr(ClientSession, "send_request", fake_send_request) + + session = object.__new__(mcp_bridge.AppsClientSession) + request = types.ClientRequest( + types.InitializeRequest( + params=types.InitializeRequestParams( + protocolVersion=types.LATEST_PROTOCOL_VERSION, + capabilities=types.ClientCapabilities(), + clientInfo=types.Implementation(name="t", version="1"), + ), + ) + ) + await session.send_request(request, types.InitializeResult) + + wire = captured["request"].model_dump(by_alias=True, exclude_none=True) + extensions = wire["params"]["capabilities"]["extensions"] + assert extensions == { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"], + } + } + + +@pytest.mark.anyio +async def test_non_initialize_requests_pass_through_untouched(monkeypatch): + """Only the handshake is rewritten; every other request goes as-is.""" + from mcp import types + from mcp.client.session import ClientSession + + captured: dict = {} + + async def fake_send_request(self, request, result_type, **kwargs): + captured["request"] = request + return None + + monkeypatch.setattr(ClientSession, "send_request", fake_send_request) + + session = object.__new__(mcp_bridge.AppsClientSession) + original = types.ClientRequest(types.ListToolsRequest(method="tools/list")) + await session.send_request(original, types.ListToolsResult) + + assert captured["request"] is original + assert captured["request"].model_dump(by_alias=True, exclude_none=True) == { + "method": "tools/list", + } + + +def test_ui_resource_uri_extraction(): + assert mcp_bridge.ui_resource_uri({"ui": {"resourceUri": PANEL_URI}}) == PANEL_URI + assert mcp_bridge.ui_resource_uri(None) is None + assert mcp_bridge.ui_resource_uri({"ui": {}}) is None + # A non-ui:// target is not a panel. Honouring one would let a tool + # point the host's iframe at an arbitrary URL. + assert mcp_bridge.ui_resource_uri({"ui": {"resourceUri": "https://evil/x"}}) is None + assert mcp_bridge.ui_resource_uri({"ui": {"resourceUri": "file:///etc/passwd"}}) is None + + +def test_ui_resource_uris_by_tool(): + class _Tool: + def __init__(self, name, meta): + self.name = name + self.meta = meta + + mapping = mcp_bridge.ui_resource_uris_by_tool([ + _Tool("proxmox_vm_panel", {"ui": {"resourceUri": PANEL_URI}}), + _Tool("proxmox_list_vms", None), + ]) + assert mapping == {"proxmox_vm_panel": PANEL_URI} + + +class _FakeResourceContent: + def __init__(self, text, mime): + self.text = text + self.mimeType = mime + + +class _FakeReadResult: + def __init__(self, contents): + self.contents = contents + + +class _FakeSession: + def __init__(self, contents): + self._contents = contents + + async def read_resource(self, uri): + return _FakeReadResult(self._contents) + + +@pytest.mark.anyio +async def test_read_ui_resource_accepts_an_app_document(): + session = _FakeSession([_FakeResourceContent(PANEL_HTML, "text/html;profile=mcp-app")]) + assert await mcp_bridge.read_ui_resource(session, PANEL_URI) == PANEL_HTML + + +@pytest.mark.anyio +async def test_read_ui_resource_refuses_a_plain_resource(): + """The MIME check is what stops this being a generic resource proxy.""" + session = _FakeSession([_FakeResourceContent("nodes: []", "text/plain")]) + with pytest.raises(mcp_bridge.UiResourceError): + await mcp_bridge.read_ui_resource(session, PANEL_URI) + + +@pytest.mark.anyio +async def test_read_ui_resource_refuses_a_non_ui_scheme(): + session = _FakeSession([_FakeResourceContent("x", "text/html;profile=mcp-app")]) + with pytest.raises(mcp_bridge.UiResourceError): + await mcp_bridge.read_ui_resource(session, "beaconmcp://infrastructure") + + +# --------------------------------------------------------------------------- +# The panel allow-list -- the decision #35 asked to be made explicitly +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("name,args", [ + ("proxmox_vm_panel", {"node": "pve1", "vmid": 104}), + ("cluster_overview_interactive", {}), + ("proxmox_vm_start", {"node": "pve1", "vmid": 104}), + ("proxmox_vm_stop", {"node": "pve1", "vmid": 104}), + ("proxmox_vm_restart", {"node": "pve1", "vmid": 104}), + ("proxmox_vm_config", {"node": "pve1", "vmid": 104, "updates": {"cores": 4}}), +]) +def test_panel_may_drive_guest_lifecycle(name, args): + """A labelled button on one guest is the click; a modal would restate it.""" + assert panel_call_allowed(name, args) is True + + +@pytest.mark.parametrize("name,args", [ + ("ssh_run", {"host": "pve1", "command": "rm -rf /"}), + ("proxmox_run", {"node": "pve1", "vmid": 104, "command": "id"}), + ("proxmox_write_file", {"node": "pve1", "vmid": 104, "path": "/root/.ssh/authorized_keys"}), + ("vm_bulk_action", {"vmids": [1, 2, 3], "action": "stop"}), + ("proxmox_snapshot_rollback", {"node": "pve1", "vmid": 104, "snapname": "s"}), + ("proxmox_backup_restore", {"node": "pve1", "vmid": 104}), + ("bmc_power_off", {"device_id": "rack1"}), + # updates is an open-ended guest config. Exempting the tool wholesale + # would exempt hookscript, raw QEMU args and device passthrough with it. + ("proxmox_vm_config", {"node": "pve1", "vmid": 104, + "updates": {"hookscript": "local:snippets/x.sh"}}), + ("proxmox_vm_config", {"node": "pve1", "vmid": 104, + "updates": {"cores": 4, "args": "-device x"}}), + # Pulls new code, reinstalls dependencies and restarts the service. + ("beaconmcp_self_update", {"confirm": True}), +]) +def test_panel_may_not_reach_the_gated_tools(name, args): + """The exemption is a closed list, not "iframe calls skip the gate". + + A ui:// document is HTML the server wrote. Letting one through the gate + by virtue of being in a frame would hand every connected MCP server a + way around the approval it is documented to be subject to. + """ + assert panel_call_allowed(name, args) is False + + +# --------------------------------------------------------------------------- +# Model context from panels +# --------------------------------------------------------------------------- + +def test_format_app_context_labels_the_source(): + block = format_app_context([ + {"tool": "proxmox_vm_panel", "text": "VM 104 is now stopped.", "structured": {"status": "stopped"}}, + ]) + assert "proxmox_vm_panel" in block + assert "VM 104 is now stopped." in block + assert '"status": "stopped"' in block + # The model has to know this did not come from the operator's keyboard. + assert "did not go through you" in block + + +def test_format_app_context_empty(): + assert format_app_context([]) == "" + assert format_app_context(None) == "" + assert format_app_context([{"tool": "x"}]) == "" + + +def test_format_app_context_truncates(): + block = format_app_context([ + {"tool": "cluster", "text": "x" * 10_000, "structured": None}, + ]) + assert len(block) < 5_000 + + +def test_assemble_persists_only_the_panel_uri(): + """The snapshot is not stored -- reopening refetches instead.""" + content, tool_calls, _ = assemble_assistant_message([ + ToolCallStart(id="1", name="proxmox_vm_panel", args={"node": "pve1", "vmid": 104}), + ToolCallEnd( + id="1", status="ok", preview="{}", duration_ms=12, + ui={"resourceUri": PANEL_URI, "result": {"structuredContent": {"vmid": 104}}}, + ), + ]) + assert tool_calls[0].ui_resource_uri == PANEL_URI + assert "result" not in tool_calls[0].to_json() + + +# --------------------------------------------------------------------------- +# Host routes +# --------------------------------------------------------------------------- + +@pytest.fixture() +def engine(): + return FakeChatEngine(FakeScript(events=[ + ToolCallStart(id="fc_0", name="proxmox_vm_panel", args={"node": "pve1", "vmid": 104}), + ToolCallEnd( + id="fc_0", status="ok", preview='{"vmid": 104}', duration_ms=7, + ui={ + "resourceUri": PANEL_URI, + "result": {"content": [], "isError": False, + "structuredContent": {"vmid": 104, "status": "running"}}, + }, + ), + ])) + + +@pytest.fixture() +def deps(tmp_path, engine): + db = Database(tmp_path / "dashboard.db") + return DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + conversations=ConversationStore(db), + engine=engine, + confirmations=ConfirmationStore(), + ) + + +@pytest.fixture() +def client(deps): + mcp_bridge.cache_clear() + app = Starlette(routes=build_dashboard_routes(deps)) + return TestClient(app, follow_redirects=False) + + +def _login(client): + r = client.get("/app/login") + csrf = r.cookies.get(CSRF_COOKIE) + r = client.post("/app/login", data={ + "csrf_token": csrf, "client_id": "c", + "client_secret": "s", "totp": "123456", "remember": "on", + }) + assert r.status_code == 303 + return client.cookies.get(CSRF_COOKIE) + + +class _StubSession: + """Stands in for a live MCP session in the two host routes.""" + + def __init__(self): + self.calls: list[tuple[str, dict]] = [] + + async def call_tool(self, name, args): + self.calls.append((name, args)) + + class _Result: + content = [] + isError = False + structuredContent = {"ok": True} + + return _Result() + + +@pytest.fixture() +def stub_mcp(monkeypatch): + stub = _StubSession() + + @asynccontextmanager + async def fake_open_session(url, bearer, **kwargs): + yield stub + + async def fake_read(session, uri): + if uri != PANEL_URI: + raise mcp_bridge.UiResourceError("unknown resource") + return PANEL_HTML + + monkeypatch.setattr(mcp_bridge, "open_session", fake_open_session) + monkeypatch.setattr(mcp_bridge, "read_ui_resource", fake_read) + return stub + + +# --- panel document --------------------------------------------------------- + +def test_panel_route_requires_a_session(client, stub_mcp): + r = client.get(f"/app/api/mcp/panel?uri={PANEL_URI}") + assert r.status_code == 401 + + +def test_panel_route_rejects_a_non_ui_uri(client, stub_mcp): + _login(client) + r = client.get("/app/api/mcp/panel?uri=beaconmcp://infrastructure") + assert r.status_code == 400 + assert r.json()["error"] == "invalid_uri" + + +def test_panel_route_serves_the_document_with_its_own_csp(client, stub_mcp): + """Framable by us, inline script allowed, and no way out of the frame. + + The default dashboard headers are the opposite on both counts -- + ``X-Frame-Options: DENY`` would stop the panel rendering at all and + ``script-src 'self'`` would kill the inline bridge -- so this route + setting its own is load-bearing, not tidiness. + """ + _login(client) + r = client.get(f"/app/api/mcp/panel?uri={PANEL_URI}") + assert r.status_code == 200 + assert r.text == PANEL_HTML + + csp = r.headers["content-security-policy"] + assert "frame-ancestors 'self'" in csp + assert "script-src 'unsafe-inline'" in csp + # The panel reaches the cluster through its parent, never directly. + assert "connect-src 'none'" in csp + assert "default-src 'none'" in csp + assert r.headers["x-frame-options"] == "SAMEORIGIN" + assert "DENY" not in r.headers["x-frame-options"] + + +def test_panel_route_reports_an_unreadable_resource(client, stub_mcp): + _login(client) + r = client.get("/app/api/mcp/panel?uri=ui://beaconmcp/nope.html") + assert r.status_code == 502 + assert r.json()["error"] == "resource_unavailable" + + +def test_panel_documents_are_cached(client, stub_mcp, monkeypatch): + _login(client) + reads: list[str] = [] + + async def counting_read(session, uri): + reads.append(uri) + return PANEL_HTML + + monkeypatch.setattr(mcp_bridge, "read_ui_resource", counting_read) + for _ in range(3): + assert client.get(f"/app/api/mcp/panel?uri={PANEL_URI}").status_code == 200 + assert len(reads) == 1 + + +# --- tools/call relay ------------------------------------------------------- + +def test_relay_requires_a_session(client, stub_mcp): + r = client.post( + "/app/api/mcp/call", + headers={"Content-Type": "application/json"}, + content=json.dumps({"name": "proxmox_vm_stop", "arguments": {}}), + ) + assert r.status_code == 401 + + +def test_relay_requires_csrf(client, stub_mcp): + _login(client) + r = client.post( + "/app/api/mcp/call", + headers={"Content-Type": "application/json"}, + content=json.dumps({"name": "proxmox_vm_stop", "arguments": {}}), + ) + assert r.status_code == 403 + assert r.json()["error"] == "csrf" + + +def test_relay_runs_an_allowed_tool(client, stub_mcp): + csrf = _login(client) + r = client.post( + "/app/api/mcp/call", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({ + "name": "proxmox_vm_stop", "arguments": {"node": "pve1", "vmid": 104}, + }), + ) + assert r.status_code == 200 + assert r.json()["result"]["structuredContent"] == {"ok": True} + assert stub_mcp.calls == [("proxmox_vm_stop", {"node": "pve1", "vmid": 104})] + + +def test_relay_refuses_a_gated_tool_without_calling_it(client, stub_mcp): + csrf = _login(client) + r = client.post( + "/app/api/mcp/call", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({ + "name": "ssh_run", "arguments": {"host": "pve1", "command": "id"}, + }), + ) + assert r.status_code == 403 + assert r.json()["error"] == "confirmation_required" + assert stub_mcp.calls == [] + + +def test_relay_rejects_a_malformed_body(client, stub_mcp): + csrf = _login(client) + r = client.post( + "/app/api/mcp/call", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"name": "", "arguments": {}}), + ) + assert r.status_code == 400 + + +# --- turn plumbing ---------------------------------------------------------- + +def _parse_sse(text): + events = [] + for frame in text.strip().split("\n\n"): + ev, data = None, [] + for line in frame.split("\n"): + if line.startswith("event:"): + ev = line[6:].strip() + elif line.startswith("data:"): + data.append(line[5:].strip()) + events.append((ev, json.loads("\n".join(data)) if data else {})) + return events + + +def test_tool_result_frame_carries_the_panel(client, deps): + """Without this the browser never learns there is a frame to mount.""" + csrf = _login(client) + r = client.post( + "/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}", + ) + conv_id = r.json()["conversation"]["id"] + + r = client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({"conversation_id": conv_id, "content": "open vm 104"}), + ) + results = [d for e, d in _parse_sse(r.text) if e == "tool_result"] + assert results and results[0]["ui"]["resourceUri"] == PANEL_URI + assert results[0]["ui"]["result"]["structuredContent"]["status"] == "running" + + stored = deps.conversations.list_messages(conv_id)[1].tool_calls[0] + assert stored.ui_resource_uri == PANEL_URI + + +def test_app_context_reaches_the_turn(client, engine): + csrf = _login(client) + r = client.post( + "/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}", + ) + conv_id = r.json()["conversation"]["id"] + + client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({ + "conversation_id": conv_id, + "content": "et maintenant ?", + "app_context": [ + {"tool": "proxmox_vm_panel", "text": "VM 104 is now stopped.", + "structured": {"status": "stopped"}}, + "junk", + {"tool": "x"}, + ], + }), + ) + assert engine.calls[0].app_context == [ + {"tool": "proxmox_vm_panel", "text": "VM 104 is now stopped.", + "structured": {"status": "stopped"}}, + ] + + +def test_app_context_entry_count_is_capped(client, engine): + csrf = _login(client) + r = client.post( + "/app/api/conversations", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content="{}", + ) + conv_id = r.json()["conversation"]["id"] + client.post( + "/app/api/chat/stream", + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + content=json.dumps({ + "conversation_id": conv_id, + "content": "ping", + "app_context": [ + {"tool": f"p{i}", "text": "x"} for i in range(50) + ], + }), + ) + assert len(engine.calls[0].app_context) == 8 diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index 5acd498..13f9908 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -261,7 +261,8 @@ def test_classify_error_preview_model_permission_denied(): ) code, msg = _classify_error(err, "gemini-3-flash-preview") assert code == "model_access_denied" - assert "gemini-2.5" in msg + # The way out must name models the picker still offers. + assert "gemini-3.6-flash" in msg assert "gemini-3-flash-preview" in msg @@ -693,13 +694,17 @@ def test_migration_v1_to_v2_renames_gemini_models(tmp_path): conn.commit() conn.close() - # Opening via Database() should run migration v2. + # Opening via Database() runs every pending migration, so c1 is first + # renamed to gemini-3-flash-preview by v2 and then moved forward to the + # current Flash by v6. db = Database(path) rows = db.conn().execute( "SELECT id, model FROM conversations ORDER BY id" ).fetchall() - assert dict(rows[0]) == {"id": "c1", "model": "gemini-3-flash-preview"} + assert dict(rows[0]) == {"id": "c1", "model": "gemini-3.6-flash"} assert dict(rows[1]) == {"id": "c2", "model": "gemini-3.1-pro-preview"} + # v2 renamed the same model; v6 substitutes a different one, so the + # message keeps naming whichever model actually wrote the reply. msg = db.conn().execute("SELECT model FROM messages WHERE id='m1'").fetchone() assert msg["model"] == "gemini-3-flash-preview" @@ -710,6 +715,64 @@ def test_migration_v1_to_v2_renames_gemini_models(tmp_path): assert ver == _LATEST_VERSION +def test_migration_v6_moves_retired_models_forward(tmp_path): + """A conversation left on a retired model must still be usable. + + ``conversations.model`` is what the next turn runs on. Leaving a + retired id there would fail ``VALID_MODELS`` and silently fall back, + which reads as the picker forgetting the operator's choice. + """ + from beaconmcp.dashboard.conversations import VALID_MODELS + + db = Database(tmp_path / "d.db") + conn = db.conn() + for cid, model in ( + ("c1", "gemini-2.5-flash"), + ("c2", "gemini-2.5-pro"), + ("c3", "gemini-3-flash-preview"), + ("c4", "gemini-3.1-pro-preview"), + ): + conn.execute( + "INSERT INTO conversations (id, client_id, title, model, " + "thinking_effort, created_at, updated_at) VALUES (?,?,?,?,?,?,?)", + (cid, "cli", None, model, "low", 0, 0), + ) + conn.execute("PRAGMA user_version = 5") + conn.commit() + + from beaconmcp.dashboard.db import _migrate + + _migrate(conn) + rows = conn.execute( + "SELECT id, model FROM conversations ORDER BY id" + ).fetchall() + assert [r["model"] for r in rows] == [ + "gemini-3.6-flash", + "gemini-3.1-pro-preview", + "gemini-3.6-flash", + "gemini-3.1-pro-preview", + ] + assert all(r["model"] in VALID_MODELS for r in rows) + + +def test_every_offered_model_has_a_price(): + """A model in the picker with no rate would bill at the fallback's.""" + from beaconmcp.dashboard.conversations import DEFAULT_MODEL, VALID_MODELS + from beaconmcp.dashboard.usage import _PRICING + + assert DEFAULT_MODEL in VALID_MODELS + for model in VALID_MODELS: + assert model in _PRICING, model + + +def test_retired_models_keep_their_rates(): + """Old turns must not be re-priced at the current model's rate.""" + from beaconmcp.dashboard.usage import _PRICING + + for model in ("gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-flash-preview"): + assert model in _PRICING, model + + def test_short_ciphertext_decryption_returns_none(store): s = store.create( client_id="c", client_secret="sk", mcp_bearer="b", diff --git a/tests/test_dashboard_usage.py b/tests/test_dashboard_usage.py index dfa02c3..acbf1b0 100644 --- a/tests/test_dashboard_usage.py +++ b/tests/test_dashboard_usage.py @@ -113,10 +113,30 @@ def test_cost_unknown_model_falls_back_to_flash(): "gemini-unknown-9", prompt_tokens=1_000_000, cached_tokens=0, output_tokens=0, ) - # Same rate as gemini-2.5-flash input. + # Same rate as gemini-3.6-flash input, the current default. + assert cost == pytest.approx(1.50) + + +def test_cost_retired_model_keeps_its_own_rate(): + """A turn billed on 2.5 Flash must not be re-priced at 3.6 Flash.""" + cost = UsageMeter.cost_usd( + "gemini-2.5-flash", + prompt_tokens=1_000_000, cached_tokens=0, output_tokens=0, + ) assert cost == pytest.approx(0.30) +def test_cost_new_flash_models(): + assert UsageMeter.cost_usd( + "gemini-3.6-flash", + prompt_tokens=1_000_000, cached_tokens=0, output_tokens=1_000_000, + ) == pytest.approx(1.50 + 7.50) + assert UsageMeter.cost_usd( + "gemini-3.5-flash-lite", + prompt_tokens=1_000_000, cached_tokens=0, output_tokens=1_000_000, + ) == pytest.approx(0.30 + 2.50) + + def test_cost_cached_over_prompt_is_clamped(): # Defensive: if Gemini ever reports more cached tokens than prompt # tokens, the billable_input floor is 0 (not negative). diff --git a/tests/test_mcp_apps_panel.py b/tests/test_mcp_apps_panel.py new file mode 100644 index 0000000..ee9b699 --- /dev/null +++ b/tests/test_mcp_apps_panel.py @@ -0,0 +1,247 @@ +"""Tests for the MCP Apps panels. + +The value here is the wire format: a host only renders a panel if the tool +carries `_meta.ui.resourceUri` and the resource comes back under the mcp-app +MIME type, so these assert what goes out on the wire rather than the Python +objects behind it. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import pytest +from mcp.server.fastmcp import FastMCP + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from beaconmcp.proxmox.panel import ( + APP_MIME_TYPE, + CLUSTER_PANEL_URI, + LOGS_PANEL_URI, + VM_PANEL_URI, + _syslog_level, + _vm_snapshot, + register_panel_tools, +) + +_NOT_FOUND = {"error": "Configuration file 'nodes/pve1/qemu/100.conf' does not exist"} + +_QEMU_STATUS = { + "status": "running", + "name": "web-101", + "cpu": 0.1234, + "cpus": 4, + "mem": 2 * 1048576 * 1024, + "maxmem": 4 * 1048576 * 1024, + # QEMU reports 0 unless the guest agent answers. + "disk": 0, + "maxdisk": 32 * 1073741824, + "uptime": 7200, +} + +_PANELS = [ + ("proxmox_vm_panel", VM_PANEL_URI), + ("proxmox_logs_panel", LOGS_PANEL_URI), + ("cluster_overview_interactive", CLUSTER_PANEL_URI), +] + + +class _Client: + """Minimal ProxmoxClient stand-in keyed on the path suffix.""" + + def __init__(self, responses: dict[str, Any] | None = None) -> None: + self.responses = responses or {} + self.calls: list[str] = [] + self.kwargs: list[dict[str, Any]] = [] + self.configured_nodes = ["pve1"] + + def get(self, node: str, path: str, **kwargs: Any) -> Any: + self.calls.append(path) + self.kwargs.append(kwargs) + for suffix, value in self.responses.items(): + if path.endswith(suffix): + return value + return _NOT_FOUND + + +def _server(client: Any) -> FastMCP: + mcp = FastMCP("test") + register_panel_tools(mcp, client) + return mcp + + +async def _tool(mcp: FastMCP, name: str) -> Any: + return next(t for t in await mcp.list_tools() if t.name == name) + + +# --- wire format ------------------------------------------------------------ + + +@pytest.mark.parametrize("name,uri", _PANELS) +async def test_tool_points_at_its_panel(name: str, uri: str) -> None: + tool = await _tool(_server(_Client()), name) + assert tool.meta == {"ui": {"resourceUri": uri, "visibility": ["model", "app"]}} + + +@pytest.mark.parametrize("name,uri", _PANELS) +async def test_advertised_resource_resolves_as_an_app(name: str, uri: str) -> None: + """A resourceUri that 404s on resources/read renders as a blank frame.""" + mcp = _server(_Client()) + listed = next(r for r in await mcp.list_resources() if str(r.uri) == uri) + assert listed.mimeType == APP_MIME_TYPE + + contents = list(await mcp.read_resource(uri)) + assert contents, f"{uri} is advertised but reads back empty" + + +@pytest.mark.parametrize("name,uri", _PANELS) +async def test_shared_runtime_is_spliced_in(name: str, uri: str) -> None: + """The marker must be gone and both halves of the runtime present. + + A panel that ships with the literal still in it has no + bridge and no styles: it loads, does nothing, and never speaks to the host. + """ + html = list(await _server(_Client()).read_resource(uri))[0].content + + assert "" not in html + assert "const MCPApp" in html, "bridge.js missing" + assert "--font-sans" in html, "panel.css missing" + + +async def test_handshake_params_are_flat() -> None: + """ui/initialize takes appInfo / appCapabilities / protocolVersion, flat. + + Nesting them under `capabilities`, or sending `clientInfo` instead of + `appInfo`, fails the host's schema check -- and a rejected handshake is + silent: the host just never replies, so the app never sends `initialized` + and never receives the tool result. The panel then sits on "Loading..." + with nothing in the console to explain it. + """ + html = list(await _server(_Client()).read_resource(VM_PANEL_URI))[0].content + params = html.split('request("ui/initialize", {', 1)[1].split("})", 1)[0] + + assert "appInfo:" in params + assert "appCapabilities:" in params + assert "protocolVersion:" in params + assert "clientInfo" not in params + assert "capabilities: {" not in params + + +# --- VM snapshot ------------------------------------------------------------ + + +def test_qemu_disk_usage_is_null_not_zero() -> None: + """0 from QEMU means "no guest agent", which must not render as an empty bar.""" + client = _Client({"qemu/100/status/current": _QEMU_STATUS, "qemu/100/config": {"cores": 4, "memory": 4096}}) + + snap = _vm_snapshot(client, "pve1", 100) + + assert snap["disk_used_gb"] is None + assert snap["disk_max_gb"] == 32.0 + + +def test_qemu_snapshot_maps_status_and_config() -> None: + client = _Client({"qemu/100/status/current": _QEMU_STATUS, "qemu/100/config": {"cores": 4, "memory": 4096}}) + + assert _vm_snapshot(client, "pve1", 100) == { + "node": "pve1", "vmid": 100, "type": "qemu", "name": "web-101", + "status": "running", "cpu_pct": 12.3, "cpus": 4, + "mem_used_mb": 2048, "mem_max_mb": 4096, + "disk_used_gb": None, "disk_max_gb": 32.0, "uptime_h": 2.0, + "cores": 4, "memory_mb": 4096, + } + + +def test_falls_through_to_lxc_when_no_qemu_guest() -> None: + client = _Client({ + "lxc/200/status/current": { + "status": "running", "name": "ct", "cpu": 0, "cpus": 1, + "mem": 0, "maxmem": 536870912, + "disk": 5 * 1073741824, "maxdisk": 10 * 1073741824, "uptime": 0, + }, + "lxc/200/config": {"cores": 1, "memory": 512}, + }) + + snap = _vm_snapshot(client, "pve1", 200) + + assert snap["type"] == "lxc" + assert snap["disk_used_gb"] == 5.0 + + +def test_missing_guest_reports_an_error() -> None: + assert "not found" in _vm_snapshot(_Client(), "pve1", 999)["error"] + + +def test_unreadable_config_still_renders_live_state() -> None: + """A config read can fail on its own; the panel should degrade, not blank out.""" + client = _Client({ + "qemu/100/status/current": _QEMU_STATUS, + "qemu/100/config": {"error": "connection refused"}, + }) + + snap = _vm_snapshot(client, "pve1", 100) + + assert snap["status"] == "running" + assert snap["cores"] is None + + +def test_transport_error_is_not_mistaken_for_a_missing_guest() -> None: + """Only a "does not exist" error means "try the other guest type".""" + client = _Client({"qemu/100/status/current": {"error": "connection refused"}}) + + snap = _vm_snapshot(client, "pve1", 100) + + assert snap["error"] == "connection refused" + assert not any("lxc" in call for call in client.calls) + + +# --- logs panel ------------------------------------------------------------- + + +@pytest.mark.parametrize( + "line,expected", + [ + ("kernel: EXT4-fs error (device sda1)", "error"), + ("corosync: connection refused to node pve2", "error"), + ("pvedaemon: deprecated option 'foo'", "warn"), + ("systemd[1]: Started Daily apt upgrade.", "info"), + ], +) +def test_syslog_lines_are_classified(line: str, expected: str) -> None: + assert _syslog_level(line) == expected + + +async def test_logs_panel_labels_syslog_lines() -> None: + client = _Client({"syslog": [{"t": "kernel: I/O error"}, {"t": "systemd: Started thing"}]}) + tool = await _server(client).call_tool("proxmox_logs_panel", {"node": "pve1"}) + + entries = tool[1]["entries"] + assert [e["level"] for e in entries] == ["error", "info"] + assert entries[0]["text"] == "kernel: I/O error" + + +async def test_logs_panel_flags_failed_tasks() -> None: + """Proxmox writes "OK" on success; anything else is the failure message.""" + client = _Client({"tasks": [ + {"upid": "UPID:1", "type": "vzdump", "status": "OK", "user": "root@pam"}, + {"upid": "UPID:2", "type": "qmstart", "status": "unable to start VM", "user": "root@pam"}, + {"upid": "UPID:3", "type": "qmigrate", "status": None, "user": "root@pam"}, + ]}) + + result = await _server(client).call_tool( + "proxmox_logs_panel", {"node": "pve1", "source": "tasks"}, + ) + + # A running task has no status yet and must not be painted as a failure. + assert [e["level"] for e in result[1]["entries"]] == ["info", "error", "info"] + + +async def test_logs_panel_caps_the_line_count() -> None: + """The Proxmox API rejects anything above 500, and the panel's box has no max.""" + client = _Client({"syslog": []}) + await _server(client).call_tool("proxmox_logs_panel", {"node": "pve1", "limit": 9000}) + + assert client.kwargs[0]["limit"] == 500 diff --git a/tests/test_updates.py b/tests/test_updates.py index 4ebc9a2..655217a 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -789,10 +789,16 @@ def test_fingerprint_changes_when_an_asset_changes(tmp_path, monkeypatch): from beaconmcp.dashboard import app as dashboard_app before = dashboard_app._compute_asset_version() - target = dashboard_app._DASHBOARD_DIR / "static" / "app.css" + static_dir = dashboard_app._DASHBOARD_DIR / "static" + target = static_dir / "app.css" original = target.stat() + # Push past the newest file in the directory, not just past this one: + # the fingerprint is the directory maximum, so bumping a file that is + # not currently the newest changes nothing and the assertion below + # would fail for a reason that has nothing to do with fingerprinting. + newest = max(p.stat().st_mtime for p in static_dir.iterdir() if p.is_file()) try: - os.utime(target, (original.st_atime, original.st_mtime + 120)) + os.utime(target, (original.st_atime, newest + 120)) assert dashboard_app._compute_asset_version() != before finally: os.utime(target, (original.st_atime, original.st_mtime)) From 6b531dfa4de787f221e039cb27069494669acb2c Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:43:20 +0200 Subject: [PATCH 153/155] fix(auth): gate X-Forwarded-Host on trusted_proxies X-Forwarded-Host is attacker-controlled on a direct request, but the issuer builder, the dashboard host resolution, the tokens/connectors pages and the mint flow all trusted it unconditionally -- mirroring the X-Forwarded-For handling that client_ip() already gates on trusted_proxies. Add ratelimit.forwarded_host(), which honours X-Forwarded-Host only when the direct peer is a declared trusted proxy and otherwise falls back to the request's own Host header. Route every host-building call site through it. Scheme handling (X-Forwarded-Proto) is deliberately left untouched: a TLS-terminating edge (Cloudflare tunnel, nginx) legitimately needs it to report https even with trusted_proxies unset, and gating it would silently downgrade the Secure-cookie flag and the OAuth issuer to http. --- src/beaconmcp/__main__.py | 8 +++-- src/beaconmcp/dashboard/app.py | 13 +++---- src/beaconmcp/ratelimit.py | 42 ++++++++++++++++++++++ tests/test_ratelimit.py | 66 +++++++++++++++++++++++++++++++++- 4 files changed, 116 insertions(+), 13 deletions(-) diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index f711726..f446d19 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -1088,7 +1088,7 @@ def _run_http(mcp, host: str, port: int): TotpResult, current_bearer_token, ) - from .ratelimit import RateLimiter, client_ip + from .ratelimit import RateLimiter, client_ip, forwarded_host from .server import config from .metrics import REGISTRY, auth_events, http_requests @@ -1206,8 +1206,10 @@ def totp_record_success(client_id: str) -> None: def _issuer(request: Request) -> str: scheme = request.headers.get("x-forwarded-proto", request.url.scheme) - host_header = request.headers.get( - "x-forwarded-host", request.headers.get("host", "localhost") + # X-Forwarded-Host is only trusted from a declared proxy; otherwise the + # request's own Host header wins (see ratelimit.forwarded_host). + host_header = forwarded_host( + request, tuple(config.server.trusted_proxies), ) return f"{scheme}://{host_header}" diff --git a/src/beaconmcp/dashboard/app.py b/src/beaconmcp/dashboard/app.py index 699c01d..f6b0a14 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -29,6 +29,7 @@ from .. import audit from ..auth import TOTP_REPLAY_MESSAGE, TotpResult +from ..ratelimit import forwarded_host from . import csrf as csrf from .chat import ( ChatEngine, @@ -867,9 +868,7 @@ async def connectors_mint(request: Request) -> Response: row = store.mint(owner_client_id=session.client_id, label=label) scheme = request.headers.get("x-forwarded-proto", request.url.scheme) - host_hdr = request.headers.get( - "x-forwarded-host", request.headers.get("host", "localhost"), - ) + host_hdr = forwarded_host(request, deps.trusted_proxies) url = f"{scheme}://{host_hdr}/mcp/c/{row.slug}" return _render_connectors_page( request, session, @@ -1871,9 +1870,7 @@ def _expires_label(expires_at: float) -> str: mcp_url = deps.mcp_public_url.rstrip("/") + "/mcp" else: scheme = request.headers.get("x-forwarded-proto", request.url.scheme) - host = request.headers.get( - "x-forwarded-host", request.headers.get("host", "localhost"), - ) + host = forwarded_host(request, deps.trusted_proxies) mcp_url = f"{scheme}://{host}/mcp" return _render( @@ -1929,9 +1926,7 @@ def _resolve_mcp_url(request: Request, deps: DashboardDeps) -> str: if deps.mcp_public_url: return deps.mcp_public_url.rstrip("/") + "/mcp" scheme = request.headers.get("x-forwarded-proto", request.url.scheme) - host = request.headers.get( - "x-forwarded-host", request.headers.get("host", "localhost"), - ) + host = forwarded_host(request, deps.trusted_proxies) return f"{scheme}://{host}/mcp" import os as _os port = _os.environ.get("BEACONMCP_PORT", "8420") diff --git a/src/beaconmcp/ratelimit.py b/src/beaconmcp/ratelimit.py index b72824d..f24a4d4 100644 --- a/src/beaconmcp/ratelimit.py +++ b/src/beaconmcp/ratelimit.py @@ -167,3 +167,45 @@ def client_ip(request: object, trusted_proxies: tuple[str, ...] = ()) -> str: if direct_peer_raw: return direct_peer_raw return "unknown" + + +def forwarded_host( + request: object, + trusted_proxies: tuple[str, ...] = (), + *, + default: str = "localhost", +) -> str: + """Client-facing Host for a Starlette ``Request``. + + Mirrors :func:`client_ip`'s trust model for the *host* dimension: the + ``X-Forwarded-Host`` header is attacker-controlled on a direct request, so + it is honored only when the direct peer is a declared trusted proxy. + Otherwise the request's own ``Host`` header is used (then ``default``). + + Kept deliberately narrow -- it does NOT touch the scheme. ``X-Forwarded- + Proto`` is still read directly by the callers, because a TLS-terminating + edge (Cloudflare tunnel, nginx) legitimately needs it to report https even + when ``trusted_proxies`` is unset; gating it would silently downgrade the + Secure-cookie flag and the OAuth issuer to http. + """ + headers = getattr(request, "headers", None) + has_get = headers is not None and hasattr(headers, "get") + + def _hdr(name: str) -> str | None: + return headers.get(name) if has_get else None + + host_header = _hdr("host") or default + if not trusted_proxies: + return host_header + + client = getattr(request, "client", None) + direct_peer = getattr(client, "host", None) if client is not None else None + direct_ip = _coerce_ip(str(direct_peer)) if direct_peer is not None else None + if direct_ip and _is_trusted_proxy(direct_ip, trusted_proxies): + fwd = _hdr("x-forwarded-host") + if fwd: + # A proxy chain may append entries; the first is the client-facing host. + first = fwd.split(",")[0].strip() + if first: + return first + return host_header diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py index fb7cdf5..6cf3ccd 100644 --- a/tests/test_ratelimit.py +++ b/tests/test_ratelimit.py @@ -4,7 +4,7 @@ import time -from beaconmcp.ratelimit import RateLimiter, client_ip +from beaconmcp.ratelimit import RateLimiter, client_ip, forwarded_host def test_allows_up_to_limit_then_blocks() -> None: @@ -101,3 +101,67 @@ def __init__(self, fwd: str | None, *, peer: str = "10.0.0.1") -> None: # Direct peer with no trust config -> use peer IP. assert client_ip(_Req(None, peer="203.0.113.10"), trusted_proxies=()) == "203.0.113.10" + + +def test_forwarded_host_only_trusts_declared_proxy() -> None: + class _H: + def __init__(self, host: str | None, xfh: str | None) -> None: + self._host = host + self._xfh = xfh + + def get(self, k: str) -> str | None: + k = k.lower() + if k == "host": + return self._host + if k == "x-forwarded-host": + return self._xfh + return None + + class _Client: + host = "10.0.0.1" + + class _Req: + def __init__( + self, host: str | None, xfh: str | None, *, peer: str = "10.0.0.1", + ) -> None: + self.headers = _H(host, xfh) + c = _Client() + c.host = peer + self.client = c + + # No trusted proxies configured -> X-Forwarded-Host is ignored, even when + # the peer looks internal. The request's own Host header wins. + assert ( + forwarded_host(_Req("real.example", "evil.attacker"), trusted_proxies=()) + == "real.example" + ) + + # Trusted direct proxy -> the forwarded host is believed. + assert ( + forwarded_host( + _Req("internal:8420", "public.example", peer="10.0.0.1"), + trusted_proxies=("10.0.0.1",), + ) + == "public.example" + ) + + # A spoofed X-Forwarded-Host from an UNtrusted peer is dropped; Host wins. + assert ( + forwarded_host( + _Req("real.example", "evil.attacker", peer="192.0.2.8"), + trusted_proxies=("10.0.0.0/8",), + ) + == "real.example" + ) + + # Proxy chain: first (client-facing) entry is returned. + assert ( + forwarded_host( + _Req("internal", "public.example, edge.internal", peer="10.0.0.1"), + trusted_proxies=("10.0.0.1",), + ) + == "public.example" + ) + + # Nothing usable -> default. + assert forwarded_host(_Req(None, None), trusted_proxies=()) == "localhost" From 29cf44b4a4b204226a9d78e8f717169802607302 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:26:42 +0200 Subject: [PATCH 154/155] fix(dashboard): gate vm-create-with-config behind the confirmation modal proxmox_vm_create forwards its `config` dict straight to the PVE API. A config can carry code-execution keys -- `hookscript` (a script PVE runs on VM lifecycle events) or raw QEMU `args` -- so an injected instruction in a chat turn could create+start a VM that runs code on the host, without ever hitting the approval modal that gates ssh_run / proxmox_run / proxmox_vm_config(updates=...). Add proxmox_vm_create to _CONFIRM_WHEN_ARG_PRESENT keyed on `config`, mirroring the proxmox_vm_config `updates` treatment: `config` is a declared parameter, so reading it is sound, and gating on its presence leaves the bare no-config create (a harmless empty shell) unattended. --- src/beaconmcp/dashboard/chat.py | 8 ++++++++ tests/test_dashboard_chat.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/beaconmcp/dashboard/chat.py b/src/beaconmcp/dashboard/chat.py index efac73b..ed3f77c 100644 --- a/src/beaconmcp/dashboard/chat.py +++ b/src/beaconmcp/dashboard/chat.py @@ -203,9 +203,17 @@ class UsageAccumulated: # ``confirm`` is a parameter the tool actually declares, so what we read is # what the tool will act on. The trap that note describes is an argument the # tool does *not* declare, which pydantic drops during validation. +# +# ``proxmox_vm_create`` is here for the same reason: a bare create is a +# harmless empty shell, but a ``config`` dict can carry code-execution keys +# -- ``hookscript`` (a script PVE runs on VM lifecycle events) or raw QEMU +# ``args`` -- which an injected instruction could set to run code on the PVE +# host. ``config`` is a declared parameter of the tool, so reading it is +# sound; gating only on its presence keeps the no-config create ungated. _CONFIRM_WHEN_ARG_PRESENT: dict[str, str] = { "proxmox_vm_config": "updates", "beaconmcp_self_update": "confirm", + "proxmox_vm_create": "config", } # Tools that actually implement a ``dry_run`` parameter and return a diff --git a/tests/test_dashboard_chat.py b/tests/test_dashboard_chat.py index b57e6d7..351374d 100644 --- a/tests/test_dashboard_chat.py +++ b/tests/test_dashboard_chat.py @@ -919,6 +919,35 @@ def test_self_update_needs_confirmation_when_applied(): assert not _tool_call_requires_confirmation("beaconmcp_check_update", {}) +def test_vm_create_with_config_needs_confirmation(): + """A ``config`` dict on vm-create can carry code-execution keys. + + ``proxmox_vm_create`` forwards its ``config`` straight to the PVE API, so + an injected instruction could smuggle a ``hookscript`` or raw QEMU + ``args`` that runs code on the host. The create-with-config shape must + reach the modal, while a bare create (an empty shell VM) stays a plain + unattended add like clone/start. + """ + from beaconmcp.dashboard.chat import _tool_call_requires_confirmation + + assert _tool_call_requires_confirmation( + "proxmox_vm_create", + {"node": "pve1", "vmid": 100, "config": {"hookscript": "local:snippets/x.sh"}}, + ) + # A benign config still gets the modal -- we gate on presence, not on + # inspecting keys, mirroring the proxmox_vm_config ``updates`` treatment. + assert _tool_call_requires_confirmation( + "proxmox_vm_create", {"node": "pve1", "vmid": 100, "config": {"cores": 2}}, + ) + # No config -> empty shell, nothing to smuggle, no modal. + assert not _tool_call_requires_confirmation( + "proxmox_vm_create", {"node": "pve1", "vmid": 100}, + ) + assert not _tool_call_requires_confirmation( + "proxmox_vm_create", {"node": "pve1", "vmid": 100, "config": None}, + ) + + def test_every_registered_tool_is_gated_or_deliberately_not(): """Guard against a new tool quietly landing outside the gate. @@ -957,7 +986,7 @@ def test_every_registered_tool_is_gated_or_deliberately_not(): "proxmox_list_nodes", "proxmox_list_transfers", "proxmox_list_vms", "proxmox_logs_panel", "proxmox_network_config", "proxmox_node_status", "proxmox_read_file", "proxmox_snapshot_create", "proxmox_snapshot_list", - "proxmox_storage_status", "proxmox_vm_clone", "proxmox_vm_create", + "proxmox_storage_status", "proxmox_vm_clone", "proxmox_vm_panel", "proxmox_vm_start", "proxmox_vm_status", "security_end_session", "ssh_list_sessions", "vm_find", } From 8ec9a584605a1bc7c22ecd59c555d3892710f222 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:35:39 +0200 Subject: [PATCH 155/155] fix(auth): correct the X-Forwarded-Host gate for uvicorn's proxy-headers The trusted-proxy branch added in 6b531df could never open in the running server. uvicorn.run defaults to proxy_headers=True / forwarded_allow_ips="127.0.0.1", so ProxyHeadersMiddleware rewrites scope["client"] to the X-Forwarded-For client before the app runs, and request.client.host is never the declared proxy. It failed closed, but "closed" meant _issuer() advertised http://127.0.0.1:8420 on any proxy that does not preserve Host -- an OAuth discovery regression, not hardening. The hand-built _Req tests missed it by bypassing the ASGI stack. - __main__: uvicorn.run(proxy_headers=False). The app already owns its forwarded-header trust (client_ip for XFF, forwarded_host for XFH, both on the real peer) and reads x-forwarded-proto directly, so uvicorn must not pre-rewrite the peer. - ratelimit.forwarded_host: take the last X-Forwarded-Host entry, not the first, so a proxy that appends cannot hand back a client-supplied prefix -- symmetric with client_ip's right-to-left walk. - tests: drive forwarded_host through a real Starlette Request so the hand-built _Req cases cannot drift from runtime again. - docs: trusted_proxies now governs the advertised host too, not just XFF (beaconmcp.yaml.example, configuration.md, cloudflare.md). - chat.py: tighten the vm-create gate comment -- the check is bool(config), so an empty {} is ungated like a bare create. --- beaconmcp.yaml.example | 6 ++++- docs/cloudflare.md | 6 +++-- docs/configuration.md | 2 +- src/beaconmcp/__main__.py | 11 +++++++- src/beaconmcp/dashboard/chat.py | 4 ++- src/beaconmcp/ratelimit.py | 20 +++++++++++--- tests/test_ratelimit.py | 48 +++++++++++++++++++++++++++++++-- 7 files changed, 85 insertions(+), 12 deletions(-) diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index 3ceda3b..4943986 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -41,7 +41,11 @@ server: - https://www.perplexity.ai - https://gemini.google.com - # Trust X-Forwarded-For only from direct peers you operate. + # Direct peers you operate whose forwarded headers are trusted. Governs both + # X-Forwarded-For (auth rate-limit client IP) and X-Forwarded-Host (the host + # advertised in the OAuth issuer and the "paste this MCP URL" strings). When + # empty, both fall back to the request's own peer / Host and forwarded values + # from any peer are ignored. # IPs and CIDRs are accepted. # - Keep loopback entries when your reverse proxy runs on the same host. # - Add `cloudflare` to auto-expand to Cloudflare's published proxy CIDRs. diff --git a/docs/cloudflare.md b/docs/cloudflare.md index 5af5167..0c8ace2 100644 --- a/docs/cloudflare.md +++ b/docs/cloudflare.md @@ -124,8 +124,10 @@ server: # SDK rejects requests with 421 Misdirected Request (DNS-rebinding guard). allowed_hosts: - mcp.example.com - # Trust Cloudflare's edge so X-Forwarded-For is honoured for auth rate - # limiting. The literal "cloudflare" auto-expands to Cloudflare's IP ranges. + # Trust Cloudflare's edge so its forwarded headers are honoured: X-Forwarded- + # For for the auth rate-limit client IP, and X-Forwarded-Host for the host in + # the OAuth issuer and token MCP URLs. The literal "cloudflare" auto-expands + # to Cloudflare's IP ranges. trusted_proxies: - cloudflare ``` diff --git a/docs/configuration.md b/docs/configuration.md index 9f1b14b..7de278e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -36,7 +36,7 @@ Tailscale IP, a VPN address, a bastion. |-----|-------| | `server.allowed_hosts` | DNS-rebinding allowlist. **Must** include the public FQDN behind your reverse proxy, or requests come back `421 Misdirected Request`. | | `server.allowed_origins` | Web-origin allowlist, used for browser CORS preflights and for OAuth HTTPS redirect URIs. Desktop and CLI callbacks (`vscode://`, `cursor://`, loopback) are handled separately. | -| `server.trusted_proxies` | Direct peers allowed to supply `X-Forwarded-For`, as IPs or CIDRs. The value `cloudflare` auto-expands to Cloudflare's edge ranges. | +| `server.trusted_proxies` | Direct peers whose forwarded headers are trusted, as IPs or CIDRs. Governs `X-Forwarded-For` (the auth rate-limit client IP) **and** `X-Forwarded-Host` (the host advertised in the OAuth issuer and the token/connector MCP URLs). When empty, both fall back to the request's own peer / `Host` and forwarded values from any peer are ignored. The value `cloudflare` auto-expands to Cloudflare's edge ranges. | | `server.tokens_db` | SQLite file persisting *named* API tokens (the `/app/tokens` page) across restarts. Created owner-only (0600). Defaults to `tokens.db` next to `clients_file`. Env override: `BEACONMCP_TOKENS_DB`. | | `server.named_token_ttl` | Lifetime of named API tokens, in seconds. Default `2592000` (30 days); `0` means never expires, revoke-only. Internal OAuth and session bearers keep their fixed 24 h TTL either way. Env override: `BEACONMCP_NAMED_TOKEN_TTL`. | | `server.audit_log` | JSON-lines audit log covering tool calls, dashboard logins, OAuth authorize and client revokes. Created owner-only (0600). Default `/opt/beaconmcp/audit.log`; `-` keeps stderr only. Env override: `BEACONMCP_AUDIT_LOG`. | diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index f446d19..06f5e1c 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -2243,7 +2243,16 @@ async def lifespan(_app): print(f"Passkeys: disabled - {passkey_reason}") if n_clients == 0: print("\nNo clients registered. Create one with: beaconmcp auth create --name 'My Client'") - uvicorn.run(app, host=host, port=port, log_level="info") + # proxy_headers=False: the app owns its own forwarded-header trust model + # end to end -- client_ip walks X-Forwarded-For against trusted_proxies and + # forwarded_host does the same for X-Forwarded-Host, both keyed on the real + # TCP peer. uvicorn's default ProxyHeadersMiddleware (proxy_headers=True, + # forwarded_allow_ips="127.0.0.1") rewrites scope["client"] to the XFF + # client before the app runs, which would hide the real peer from both + # helpers -- their trusted-proxy branch could never open. Every scheme read + # goes through the x-forwarded-proto header directly, so nothing else needs + # uvicorn to interpret the forwarded headers for us. + uvicorn.run(app, host=host, port=port, log_level="info", proxy_headers=False) def _build_dashboard_routes(client_store, token_store, totp_locked, diff --git a/src/beaconmcp/dashboard/chat.py b/src/beaconmcp/dashboard/chat.py index ed3f77c..7d06d2d 100644 --- a/src/beaconmcp/dashboard/chat.py +++ b/src/beaconmcp/dashboard/chat.py @@ -209,7 +209,9 @@ class UsageAccumulated: # -- ``hookscript`` (a script PVE runs on VM lifecycle events) or raw QEMU # ``args`` -- which an injected instruction could set to run code on the PVE # host. ``config`` is a declared parameter of the tool, so reading it is -# sound; gating only on its presence keeps the no-config create ungated. +# sound; the gate is on a *non-empty* ``config`` (the check below is +# ``bool(args.get(...))``), so a bare create -- no ``config`` or an empty +# ``{}`` -- carries no code-execution keys and stays ungated. _CONFIRM_WHEN_ARG_PRESENT: dict[str, str] = { "proxmox_vm_config": "updates", "beaconmcp_self_update": "confirm", diff --git a/src/beaconmcp/ratelimit.py b/src/beaconmcp/ratelimit.py index f24a4d4..9e48640 100644 --- a/src/beaconmcp/ratelimit.py +++ b/src/beaconmcp/ratelimit.py @@ -182,6 +182,12 @@ def forwarded_host( it is honored only when the direct peer is a declared trusted proxy. Otherwise the request's own ``Host`` header is used (then ``default``). + Like :func:`client_ip`, this keys on ``request.client.host`` being the real + TCP peer, so the server must run with uvicorn ``proxy_headers=False`` (see + ``__main__``): the default ``ProxyHeadersMiddleware`` would rewrite the peer + to the ``X-Forwarded-For`` client and the trusted-proxy branch below could + never open. + Kept deliberately narrow -- it does NOT touch the scheme. ``X-Forwarded- Proto`` is still read directly by the callers, because a TLS-terminating edge (Cloudflare tunnel, nginx) legitimately needs it to report https even @@ -204,8 +210,14 @@ def _hdr(name: str) -> str | None: if direct_ip and _is_trusted_proxy(direct_ip, trusted_proxies): fwd = _hdr("x-forwarded-host") if fwd: - # A proxy chain may append entries; the first is the client-facing host. - first = fwd.split(",")[0].strip() - if first: - return first + # Take the last entry, not the first. A proxy that appends rather + # than overwrites puts its own value last, so the last entry is the + # one the nearest trusted proxy wrote; returning the first would + # hand back a client-supplied prefix. Symmetric with client_ip's + # right-to-left walk. Proxies that overwrite (the common case: + # nginx ``proxy_set_header X-Forwarded-Host $host``) leave a single + # entry, so first and last coincide. + parts = [p.strip() for p in fwd.split(",") if p.strip()] + if parts: + return parts[-1] return host_header diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py index 6cf3ccd..41daf84 100644 --- a/tests/test_ratelimit.py +++ b/tests/test_ratelimit.py @@ -154,10 +154,12 @@ def __init__( == "real.example" ) - # Proxy chain: first (client-facing) entry is returned. + # Proxy chain: a client-supplied prefix must not win. A proxy that appends + # its own value puts it last, so the last entry is returned -- symmetric + # with client_ip's right-to-left walk. assert ( forwarded_host( - _Req("internal", "public.example, edge.internal", peer="10.0.0.1"), + _Req("internal", "evil.attacker, public.example", peer="10.0.0.1"), trusted_proxies=("10.0.0.1",), ) == "public.example" @@ -165,3 +167,45 @@ def __init__( # Nothing usable -> default. assert forwarded_host(_Req(None, None), trusted_proxies=()) == "localhost" + + +def test_forwarded_host_through_a_real_starlette_request() -> None: + # Guards against the hand-built _Req above drifting from runtime: build an + # actual Starlette Request from an ASGI scope and confirm the trusted-proxy + # branch opens. This is the shape the app sees once uvicorn is told not to + # rewrite scope["client"] (proxy_headers=False), so request.client.host is + # the real TCP peer -- the proxy -- not the X-Forwarded-For client. + from starlette.requests import Request + + def _req(host: str, xfh: str | None, peer: str) -> Request: + headers = [(b"host", host.encode())] + if xfh is not None: + headers.append((b"x-forwarded-host", xfh.encode())) + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": headers, + "client": (peer, 44444), + "scheme": "http", + "server": ("app", 80), + } + return Request(scope) + + # Proxy peer is trusted -> the forwarded host is believed. + assert ( + forwarded_host( + _req("127.0.0.1:8420", "beacon.example.com", "127.0.0.1"), + trusted_proxies=("127.0.0.1",), + ) + == "beacon.example.com" + ) + + # Direct (untrusted) peer -> forwarded host dropped, own Host wins. + assert ( + forwarded_host( + _req("real.example", "evil.attacker", "203.0.113.5"), + trusted_proxies=("127.0.0.1",), + ) + == "real.example" + )