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.
+[](https://www.python.org/downloads/)
+[](https://modelcontextprotocol.io/)
+[](https://www.proxmox.com/)
+[](https://www.hpe.com/us/en/servers/integrated-lights-out-ilo.html)
+[](LICENSE)
+[](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).
+
+---
+
+
Authentification \u00e0 deux facteurs pour {html.escape(client_name)}.
+{banner}
+
+
+"""
+ 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 `
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
-
+
+
+
+
+
+
+
Limites d'usage
+
+
+
+
+
+
Session en cours
+ —
+
+
—
+
+
+
+
+
+
+
+
Fenêtre hebdomadaire
+ —
+
+
Fenêtre glissante sur 7 jours
+
+
+
+
+
+
+
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 %}
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 @@
---
-### 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)
-
-
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 `
- {% 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.
- 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.
-
+ 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
+
+
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.
+
+
In Claude: Settings → Integrations → Add custom connector.
+
URL: {{ mcp_url }}. Paste client_id and client_secret.
+
Claude redirects to this server's authorization page — type your TOTP code there, not in the CLI.
+ 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).
+
+ 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 %}
+
{{ 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.
+
+ Claude's custom connector accepts a user-provided
+ client_id and client_secret and runs a
+ full OAuth authorization code + PKCE flow against BeaconMCP.
+
+ 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.
+
+ 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.
+
{# ============================================================ #}
- {# VIEW: BY PLATFORM (nested tabs, one card per platform) #}
+ {# VIEW: BY PLATFORM — with nested sub-tabs for variants #}
{# ============================================================ #}
- 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.
+ 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'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.
+ 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.
+
+ 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):
+ 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.
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.
+
+ 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 @@
- Tokens API
- Brancher Gemini, ChatGPT, Claude Desktop…
+ Accès API
+ Brancher Claude, ChatGPT, Gemini, Cursor…
- 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.
- 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.
+ 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 %}
+
+
Mint a connector URL from OAuth connectors (single-use, 15 min).
+
In Perplexity: Settings → Connectors → Add custom. Tick "I understand custom connectors can introduce risks".
+
Fill in:
+
+
Name: BeaconMCP
+
Description: (optional)
+
MCP Server URL: paste the /mcp/c/<slug> URL
+
+
+
Perplexity detects OAuth, runs DCR, then redirects you to BeaconMCP's authorization page. Type your TOTP.
+
+
+ Requires Perplexity Pro, Max, or Enterprise — custom connectors aren't on the free tier.
+
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
+ MistralPerplexity
+ OpenCode
@@ -440,6 +451,7 @@
Gemini
Mistral
+ OAuth + DCRBearer
@@ -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.
Paste the /mcp/c/<slug> URL as the connection server.
+
Le Chat detects OAuth, runs DCR, and redirects you to BeaconMCP's authorization page — type your TOTP.
+
+
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:
+ 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
- MistralPerplexityOpenCode
@@ -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é.
+
Paste the /mcp/c/<slug> URL as the connection server.
-
Le Chat detects OAuth, runs DCR, and redirects you to BeaconMCP's authorization page — type your TOTP.
+
Name: BeaconMCP
+
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.
-
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 @@
- 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 %}
-
-
Mint a connector URL from OAuth connectors (single-use, 15 min).
-
In Perplexity: Settings → Connectors → Add custom. Tick "I understand custom connectors can introduce risks".
-
Fill in:
-
-
Name: BeaconMCP
-
Description: (optional)
-
MCP Server URL: paste the /mcp/c/<slug> URL
-
-
-
Perplexity detects OAuth, runs DCR, then redirects you to BeaconMCP's authorization page. Type your TOTP.
-
-
- Requires Perplexity Pro, Max, or Enterprise — custom connectors aren't on the free tier.
-
Every Gemini surface takes a static bearer header. One token covers all three.
-
+
-
+
+
OAuth + DCRBearer
+
+ 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).
+
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 + DCRBearer
-
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.
+
- 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 + DCRBearer
-
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.
+
- 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 %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+