Compare commits
2 Commits
d0aa37e9c3
...
cda49f2912
| Author | SHA1 | Date | |
|---|---|---|---|
| cda49f2912 | |||
| f843a8a161 |
50
.env.example
50
.env.example
@ -1,8 +1,8 @@
|
|||||||
# ESXi MCP Server Configuration
|
# mcvsphere Configuration
|
||||||
# Copy this file to .env and fill in your values
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
# Docker Compose project name (prevents environment clashes)
|
# Docker Compose project name (prevents environment clashes)
|
||||||
COMPOSE_PROJECT=esxi-mcp
|
COMPOSE_PROJECT=mcvsphere
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# VMware vCenter/ESXi Connection (Required)
|
# VMware vCenter/ESXi Connection (Required)
|
||||||
@ -35,10 +35,10 @@ VCENTER_INSECURE=true
|
|||||||
# API key for authentication (optional, but recommended for production)
|
# API key for authentication (optional, but recommended for production)
|
||||||
# MCP_API_KEY=your-secret-api-key
|
# MCP_API_KEY=your-secret-api-key
|
||||||
|
|
||||||
# Transport type: stdio (for Claude Desktop) or sse (for web/Docker)
|
# Transport type: stdio (Claude Desktop), sse (web/Docker), http (OAuth)
|
||||||
MCP_TRANSPORT=sse
|
MCP_TRANSPORT=sse
|
||||||
|
|
||||||
# Server binding (only used with SSE transport)
|
# Server binding (only used with SSE/HTTP transport)
|
||||||
MCP_HOST=0.0.0.0
|
MCP_HOST=0.0.0.0
|
||||||
MCP_PORT=8080
|
MCP_PORT=8080
|
||||||
|
|
||||||
@ -49,4 +49,44 @@ MCP_PORT=8080
|
|||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Log file path (logs to console if not specified)
|
# Log file path (logs to console if not specified)
|
||||||
# LOG_FILE=/app/logs/esxi-mcp.log
|
# LOG_FILE=/app/logs/mcvsphere.log
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# OAuth/OIDC Configuration (Optional - requires Authentik or other OIDC provider)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Enable OAuth authentication (requires MCP_TRANSPORT=http or sse)
|
||||||
|
OAUTH_ENABLED=false
|
||||||
|
|
||||||
|
# OIDC issuer URL (Authentik application URL)
|
||||||
|
# Example: https://auth.example.com/application/o/mcvsphere/
|
||||||
|
# OAUTH_ISSUER_URL=
|
||||||
|
|
||||||
|
# OAuth client credentials (from Authentik application)
|
||||||
|
# OAUTH_CLIENT_ID=
|
||||||
|
# OAUTH_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# OAuth scopes to request (comma-separated or JSON array)
|
||||||
|
# OAUTH_SCOPES=["openid", "profile", "email", "groups"]
|
||||||
|
|
||||||
|
# OAuth groups required for access (empty = any authenticated user)
|
||||||
|
# OAUTH_REQUIRED_GROUPS=["vsphere-readers"]
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Authentik Deployment (for docker-compose.oauth.yml)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Authentik secret key (generate with: openssl rand -base64 36)
|
||||||
|
# AUTHENTIK_SECRET_KEY=
|
||||||
|
|
||||||
|
# Authentik PostgreSQL password
|
||||||
|
# AUTHENTIK_DB_PASSWORD=
|
||||||
|
|
||||||
|
# Authentik bootstrap admin (first run only)
|
||||||
|
# AUTHENTIK_BOOTSTRAP_EMAIL=admin@localhost
|
||||||
|
# AUTHENTIK_BOOTSTRAP_PASSWORD=
|
||||||
|
|
||||||
|
# Authentik ports
|
||||||
|
# AUTHENTIK_PORT=9000
|
||||||
|
# AUTHENTIK_HTTPS_PORT=9443
|
||||||
|
|
||||||
|
# Authentik hostname (for Caddy reverse proxy)
|
||||||
|
# AUTHENTIK_HOST=auth.localhost
|
||||||
|
|||||||
381
OAUTH-ARCHITECTURE.md
Normal file
381
OAUTH-ARCHITECTURE.md
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
# OAuth Architecture for vSphere MCP Server
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
We need to add authentication to the MCP server so that:
|
||||||
|
1. Users authenticate via **OAuth 2.1 / OIDC** (using Authentik as IdP)
|
||||||
|
2. The MCP server knows WHO is making requests (for audit logging)
|
||||||
|
3. vCenter permissions are respected per-user
|
||||||
|
|
||||||
|
**Challenge:** vCenter 7.0.3 doesn't support OAuth token exchange (RFC 8693), so we can't pass OAuth tokens directly to vCenter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ MCP Client (Claude Code) │
|
||||||
|
└────────────────────────────┬────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 1. OAuth 2.1 + PKCE flow
|
||||||
|
│ (browser opens for login)
|
||||||
|
│
|
||||||
|
┌────────────────────────────▼────────────────────────────────────┐
|
||||||
|
│ Authentik │
|
||||||
|
│ (Self-hosted OIDC IdP) │
|
||||||
|
│ │
|
||||||
|
│ - Issues JWT access tokens │
|
||||||
|
│ - Validates user credentials │
|
||||||
|
│ - Includes user identity in token (sub, email, groups) │
|
||||||
|
└────────────────────────────┬────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 2. JWT Bearer token
|
||||||
|
│ Authorization: Bearer <jwt>
|
||||||
|
│
|
||||||
|
┌────────────────────────────▼────────────────────────────────────┐
|
||||||
|
│ vSphere MCP Server │
|
||||||
|
│ (FastMCP + pyvmomi) │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ OIDCProxy (FastMCP) │ │
|
||||||
|
│ │ - Validates JWT signature via Authentik JWKS │ │
|
||||||
|
│ │ - Extracts user identity (preferred_username) │ │
|
||||||
|
│ │ - Makes user available via ctx.request_context.user │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Credential Broker │ │
|
||||||
|
│ │ - Maps OAuth user → vCenter credentials │ │
|
||||||
|
│ │ - Caches pyvmomi connections per-user │ │
|
||||||
|
│ │ - Retrieves passwords from Vault / env vars │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Audit Logger │ │
|
||||||
|
│ │ - Logs all tool invocations with OAuth identity │ │
|
||||||
|
│ │ - "User ryan@example.com powered on VM web-server" │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└────────────────────────────┬────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 3. pyvmomi (as mapped user)
|
||||||
|
│
|
||||||
|
┌────────────────────────────▼────────────────────────────────────┐
|
||||||
|
│ vCenter 7.0.3 │
|
||||||
|
│ - Receives API calls as the actual user │
|
||||||
|
│ - Native audit logs show real user identity │
|
||||||
|
│ - vCenter permissions apply naturally │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Mapping Strategies
|
||||||
|
|
||||||
|
Since we can't exchange OAuth tokens for vCenter tokens, we need a "credential broker":
|
||||||
|
|
||||||
|
| Strategy | How it Works | Security | Use Case |
|
||||||
|
|----------|--------------|----------|----------|
|
||||||
|
| **Service Account** | All requests use one vCenter admin account | Medium | Simple/dev |
|
||||||
|
| **Per-User Mapping** | Map OAuth username → vCenter credentials from Vault | High | Production |
|
||||||
|
| **LDAP Sync** | Same username/password in Authentik and vCenter SSO | Medium | AD environments |
|
||||||
|
|
||||||
|
### Recommended: Per-User Mapping with Fallback
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CredentialBroker:
|
||||||
|
"""Maps OAuth users to vCenter credentials."""
|
||||||
|
|
||||||
|
def __init__(self, vcenter_host: str, fallback_user: str = None, fallback_password: str = None):
|
||||||
|
self.vcenter_host = vcenter_host
|
||||||
|
self.fallback_user = fallback_user # Service account fallback
|
||||||
|
self.fallback_password = fallback_password
|
||||||
|
self._connections: dict[str, ServiceInstance] = {}
|
||||||
|
|
||||||
|
def get_connection_for_user(self, oauth_user: dict) -> ServiceInstance:
|
||||||
|
"""Get pyvmomi connection for this OAuth user."""
|
||||||
|
username = oauth_user.get("preferred_username")
|
||||||
|
|
||||||
|
# Try per-user credentials first
|
||||||
|
try:
|
||||||
|
vcenter_creds = self._lookup_credentials(username)
|
||||||
|
return self._get_or_create_connection(
|
||||||
|
vcenter_creds["user"],
|
||||||
|
vcenter_creds["password"]
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
# Fall back to service account
|
||||||
|
if self.fallback_user:
|
||||||
|
return self._get_or_create_connection(
|
||||||
|
self.fallback_user,
|
||||||
|
self.fallback_password
|
||||||
|
)
|
||||||
|
raise ValueError(f"No vCenter credentials for user: {username}")
|
||||||
|
|
||||||
|
def _lookup_credentials(self, username: str) -> dict:
|
||||||
|
"""Look up vCenter credentials for OAuth user."""
|
||||||
|
# Option 1: Environment variable
|
||||||
|
env_key = f"VCENTER_PASSWORD_{username.upper().replace('@', '_').replace('.', '_')}"
|
||||||
|
if password := os.environ.get(env_key):
|
||||||
|
return {"user": f"{username}@vsphere.local", "password": password}
|
||||||
|
|
||||||
|
# Option 2: HashiCorp Vault (production)
|
||||||
|
# return vault_client.read(f"secret/vcenter/users/{username}")
|
||||||
|
|
||||||
|
raise KeyError(f"No credentials found for {username}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FastMCP OAuth Integration
|
||||||
|
|
||||||
|
### 1. Add OIDCProxy to server.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
from fastmcp.server.auth import OIDCProxy
|
||||||
|
|
||||||
|
# Configure OAuth with Authentik
|
||||||
|
auth = OIDCProxy(
|
||||||
|
# Authentik OIDC Discovery URL
|
||||||
|
config_url=os.environ["AUTHENTIK_OIDC_URL"],
|
||||||
|
# e.g., "https://auth.example.com/application/o/vsphere-mcp/.well-known/openid-configuration"
|
||||||
|
|
||||||
|
# Application credentials from Authentik
|
||||||
|
client_id=os.environ["AUTHENTIK_CLIENT_ID"],
|
||||||
|
client_secret=os.environ["AUTHENTIK_CLIENT_SECRET"],
|
||||||
|
|
||||||
|
# MCP Server base URL (for redirects)
|
||||||
|
base_url=os.environ.get("MCP_BASE_URL", "http://localhost:8000"),
|
||||||
|
|
||||||
|
# Token validation
|
||||||
|
required_scopes=["openid", "profile", "email"],
|
||||||
|
|
||||||
|
# Allow Claude Code localhost redirects
|
||||||
|
allowed_client_redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create MCP server with OAuth
|
||||||
|
mcp = FastMCP(
|
||||||
|
"vSphere MCP Server",
|
||||||
|
auth=auth,
|
||||||
|
# Use Streamable HTTP transport for OAuth
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Access User Identity in Tools
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastmcp import Context
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def power_on_vm(ctx: Context, vm_name: str) -> str:
|
||||||
|
"""Power on a virtual machine."""
|
||||||
|
# Get authenticated user from OAuth token
|
||||||
|
user = ctx.request_context.user
|
||||||
|
username = user.get("preferred_username", user.get("sub"))
|
||||||
|
|
||||||
|
# Get vCenter connection for this user
|
||||||
|
broker = get_credential_broker()
|
||||||
|
connection = broker.get_connection_for_user(user)
|
||||||
|
|
||||||
|
# Execute operation
|
||||||
|
content = connection.RetrieveContent()
|
||||||
|
vm = find_vm(content, vm_name)
|
||||||
|
vm.PowerOnVM_Task()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
logger.info(f"User {username} powered on VM {vm_name}")
|
||||||
|
|
||||||
|
return f"VM '{vm_name}' is powering on"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP Transport: Streamable HTTP
|
||||||
|
|
||||||
|
OAuth requires HTTP transport (not stdio). Use Streamable HTTP:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In server.py or via environment
|
||||||
|
mcp = FastMCP(
|
||||||
|
"vSphere MCP Server",
|
||||||
|
auth=auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Run with HTTP transport for OAuth support
|
||||||
|
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Transport characteristics:**
|
||||||
|
- Single HTTP endpoint (`/mcp`)
|
||||||
|
- POST requests with JSON-RPC body
|
||||||
|
- Server-Sent Events (SSE) for streaming responses
|
||||||
|
- `Authorization: Bearer <token>` header on every request
|
||||||
|
- `Mcp-Session-Id` header for session continuity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Authentik OIDC
|
||||||
|
AUTHENTIK_OIDC_URL=https://auth.example.com/application/o/vsphere-mcp/.well-known/openid-configuration
|
||||||
|
AUTHENTIK_CLIENT_ID=<from-authentik-application>
|
||||||
|
AUTHENTIK_CLIENT_SECRET=<from-authentik-application>
|
||||||
|
|
||||||
|
# MCP Server
|
||||||
|
MCP_BASE_URL=https://mcp.example.com # Public URL for OAuth redirects
|
||||||
|
MCP_TRANSPORT=streamable-http
|
||||||
|
|
||||||
|
# vCenter Connection (service account fallback)
|
||||||
|
VCENTER_HOST=vcenter.example.com
|
||||||
|
VCENTER_USER=svc-mcp@vsphere.local
|
||||||
|
VCENTER_PASSWORD=<service-account-password>
|
||||||
|
VCENTER_INSECURE=true
|
||||||
|
|
||||||
|
# Per-user credentials (optional, for testing)
|
||||||
|
VCENTER_PASSWORD_RYAN=<ryan's-vcenter-password>
|
||||||
|
VCENTER_PASSWORD_ALICE=<alice's-vcenter-password>
|
||||||
|
|
||||||
|
# User Mapping Mode
|
||||||
|
USER_MAPPING_MODE=service_account # or 'per_user', 'ldap_sync'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentik Setup (Quick Reference)
|
||||||
|
|
||||||
|
1. **Create OAuth2/OIDC Provider:**
|
||||||
|
- Name: `vsphere-mcp`
|
||||||
|
- Client Type: Confidential
|
||||||
|
- Redirect URIs:
|
||||||
|
- `http://localhost:*/callback`
|
||||||
|
- `https://mcp.example.com/auth/callback`
|
||||||
|
- Scopes: `openid`, `profile`, `email`
|
||||||
|
- Signing Key: Select or create RS256 key
|
||||||
|
|
||||||
|
2. **Create Application:**
|
||||||
|
- Name: `vSphere MCP Server`
|
||||||
|
- Slug: `vsphere-mcp`
|
||||||
|
- Provider: Select the provider above
|
||||||
|
- Note the **Client ID** and **Client Secret**
|
||||||
|
|
||||||
|
3. **Configure Groups (optional):**
|
||||||
|
- `vsphere-admins` - Full access
|
||||||
|
- `vsphere-operators` - Limited access
|
||||||
|
- Groups are included in JWT `groups` claim
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OAuth Flow (End-to-End)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Claude Code connects to MCP Server
|
||||||
|
→ GET /mcp
|
||||||
|
→ Server returns 401 Unauthorized
|
||||||
|
→ WWW-Authenticate header includes OAuth metadata URL
|
||||||
|
|
||||||
|
2. Claude Code fetches OAuth metadata
|
||||||
|
→ Discovers Authentik authorization URL
|
||||||
|
→ Discovers required scopes
|
||||||
|
|
||||||
|
3. Claude Code initiates OAuth flow
|
||||||
|
→ Opens browser to Authentik login page
|
||||||
|
→ User enters credentials
|
||||||
|
→ Authentik redirects back with authorization code
|
||||||
|
|
||||||
|
4. Claude Code exchanges code for tokens
|
||||||
|
→ POST to Authentik token endpoint
|
||||||
|
→ Receives JWT access token + refresh token
|
||||||
|
|
||||||
|
5. Claude Code reconnects with Bearer token
|
||||||
|
→ POST /mcp with Authorization: Bearer <jwt>
|
||||||
|
→ Server validates JWT via Authentik JWKS
|
||||||
|
→ Server extracts user identity
|
||||||
|
→ User can now invoke tools
|
||||||
|
|
||||||
|
6. Tool invocation
|
||||||
|
→ Client: "power on web-server VM"
|
||||||
|
→ Server: Validates token, maps user to vCenter creds
|
||||||
|
→ Server: Executes pyvmomi call
|
||||||
|
→ Server: Logs "User ryan@example.com powered on web-server"
|
||||||
|
→ Client: Receives success response
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
### Phase 1: Prepare Server
|
||||||
|
- [ ] Add `fastmcp[auth]` to dependencies
|
||||||
|
- [ ] Create `auth.py` with OIDCProxy configuration
|
||||||
|
- [ ] Create `credential_broker.py` for user mapping
|
||||||
|
- [ ] Add audit logging to all tools
|
||||||
|
- [ ] Update server.py to use HTTP transport
|
||||||
|
|
||||||
|
### Phase 2: Deploy Authentik
|
||||||
|
- [ ] Docker Compose for Authentik
|
||||||
|
- [ ] Create OIDC provider and application
|
||||||
|
- [ ] Configure redirect URIs
|
||||||
|
- [ ] Note client credentials
|
||||||
|
- [ ] Test OIDC flow manually with curl
|
||||||
|
|
||||||
|
### Phase 3: Integration
|
||||||
|
- [ ] Configure environment variables
|
||||||
|
- [ ] Test with `fastmcp dev` (OAuth mode)
|
||||||
|
- [ ] Test with Claude Code (`claude mcp add --auth oauth`)
|
||||||
|
- [ ] Verify audit logs show correct user identity
|
||||||
|
|
||||||
|
### Phase 4: Production
|
||||||
|
- [ ] HTTPS via Caddy reverse proxy
|
||||||
|
- [ ] Secrets in Docker secrets / Vault
|
||||||
|
- [ ] Service account with minimal vCenter permissions
|
||||||
|
- [ ] Log aggregation and monitoring
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
```
|
||||||
|
esxi-mcp-server/
|
||||||
|
├── src/esxi_mcp_server/
|
||||||
|
│ ├── auth.py # NEW: OIDCProxy configuration
|
||||||
|
│ ├── credential_broker.py # NEW: OAuth → vCenter credential mapping
|
||||||
|
│ ├── server.py # MODIFY: Add auth, HTTP transport
|
||||||
|
│ └── mixins/
|
||||||
|
│ └── *.py # MODIFY: Add ctx.request_context.user logging
|
||||||
|
├── .env.example # MODIFY: Add OAuth variables
|
||||||
|
└── docker-compose.yml # MODIFY: Add Authentik services
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Insight
|
||||||
|
|
||||||
|
The "middleman" role of the MCP server is critical:
|
||||||
|
|
||||||
|
```
|
||||||
|
OAuth Token (Authentik) ──┐
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ MCP Server │ ← Validates OAuth, maps to vCenter creds
|
||||||
|
│ (Middleman) │ ← Logs audit trail with OAuth identity
|
||||||
|
└─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
vCenter API (pyvmomi)
|
||||||
|
```
|
||||||
|
|
||||||
|
The MCP server doesn't pass OAuth tokens to vCenter. Instead, it:
|
||||||
|
1. **Authenticates** users via OAuth (trusts Authentik)
|
||||||
|
2. **Authorizes** by mapping OAuth identity to vCenter credentials
|
||||||
|
3. **Audits** by logging all actions with the OAuth user identity
|
||||||
|
4. **Executes** vCenter API calls using mapped credentials
|
||||||
|
|
||||||
|
This gives you SSO-like experience while working within vCenter 7.0.3's authentication limitations.
|
||||||
127
docker-compose.oauth.yml
Normal file
127
docker-compose.oauth.yml
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# OAuth-enabled deployment with Authentik
|
||||||
|
# Usage: docker compose -f docker-compose.yml -f docker-compose.oauth.yml up
|
||||||
|
#
|
||||||
|
# This overlay adds Authentik identity provider for OAuth authentication.
|
||||||
|
# Requires AUTHENTIK_* environment variables to be set.
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# PostgreSQL for Authentik
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
authentik-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: mcvsphere-authentik-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: authentik
|
||||||
|
POSTGRES_USER: authentik
|
||||||
|
POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASSWORD:?AUTHENTIK_DB_PASSWORD required}
|
||||||
|
volumes:
|
||||||
|
- authentik-db-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- authentik-internal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U authentik"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# Redis for Authentik
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
authentik-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: mcvsphere-authentik-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
|
||||||
|
volumes:
|
||||||
|
- authentik-redis-data:/data
|
||||||
|
networks:
|
||||||
|
- authentik-internal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# Authentik Server
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
authentik-server:
|
||||||
|
image: ghcr.io/goauthentik/server:2024.10.4
|
||||||
|
container_name: mcvsphere-authentik-server
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server
|
||||||
|
environment:
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?AUTHENTIK_SECRET_KEY required}
|
||||||
|
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
|
||||||
|
# Bootstrap admin user (first run only)
|
||||||
|
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-admin@localhost}
|
||||||
|
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-}
|
||||||
|
ports:
|
||||||
|
- "${AUTHENTIK_PORT:-9000}:9000"
|
||||||
|
- "${AUTHENTIK_HTTPS_PORT:-9443}:9443"
|
||||||
|
volumes:
|
||||||
|
- authentik-media:/media
|
||||||
|
- authentik-templates:/templates
|
||||||
|
depends_on:
|
||||||
|
authentik-db:
|
||||||
|
condition: service_healthy
|
||||||
|
authentik-redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- authentik-internal
|
||||||
|
- mcvsphere-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "ak", "healthcheck"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
labels:
|
||||||
|
# Caddy reverse proxy (if using caddy-docker-proxy)
|
||||||
|
caddy: ${AUTHENTIK_HOST:-auth.localhost}
|
||||||
|
caddy.reverse_proxy: "{{upstreams 9000}}"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# Authentik Worker (background tasks)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
authentik-worker:
|
||||||
|
image: ghcr.io/goauthentik/server:2024.10.4
|
||||||
|
container_name: mcvsphere-authentik-worker
|
||||||
|
restart: unless-stopped
|
||||||
|
command: worker
|
||||||
|
environment:
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||||
|
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- authentik-media:/media
|
||||||
|
- authentik-templates:/templates
|
||||||
|
depends_on:
|
||||||
|
authentik-db:
|
||||||
|
condition: service_healthy
|
||||||
|
authentik-redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- authentik-internal
|
||||||
|
|
||||||
|
networks:
|
||||||
|
authentik-internal:
|
||||||
|
driver: bridge
|
||||||
|
mcvsphere-network:
|
||||||
|
external: true
|
||||||
|
name: ${COMPOSE_PROJECT_NAME:-mcvsphere}_default
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
authentik-db-data:
|
||||||
|
authentik-redis-data:
|
||||||
|
authentik-media:
|
||||||
|
authentik-templates:
|
||||||
186
src/mcvsphere/audit.py
Normal file
186
src/mcvsphere/audit.py
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
"""Audit logging for OAuth-authenticated operations.
|
||||||
|
|
||||||
|
Provides centralized logging with OAuth user identity for all tool invocations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger("mcvsphere.audit")
|
||||||
|
|
||||||
|
# Context variable to store current user info for the request
|
||||||
|
_current_user: ContextVar[dict[str, Any] | None] = ContextVar("current_user", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def set_current_user(user_info: dict[str, Any] | None) -> None:
|
||||||
|
"""Set the current user for this request context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_info: User information extracted from OAuth token, or None for anonymous.
|
||||||
|
"""
|
||||||
|
_current_user.set(user_info)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user() -> dict[str, Any] | None:
|
||||||
|
"""Get the current user for this request context.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User information dict or None if no user is authenticated.
|
||||||
|
"""
|
||||||
|
return _current_user.get()
|
||||||
|
|
||||||
|
|
||||||
|
def get_username() -> str:
|
||||||
|
"""Get the username of the current user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Username string, or 'anonymous' if no user is authenticated.
|
||||||
|
"""
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return "anonymous"
|
||||||
|
|
||||||
|
# Try common OAuth claim names in order of preference
|
||||||
|
for claim in ("preferred_username", "email", "sub"):
|
||||||
|
if value := user.get(claim):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_groups() -> list[str]:
|
||||||
|
"""Get the groups of the current user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of group names, or empty list if none.
|
||||||
|
"""
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return []
|
||||||
|
return user.get("groups", [])
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_args(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Remove sensitive values from args for logging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: Tool arguments dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized args with sensitive values redacted.
|
||||||
|
"""
|
||||||
|
sensitive_patterns = {"password", "secret", "token", "credential", "key"}
|
||||||
|
|
||||||
|
def is_sensitive(key: str) -> bool:
|
||||||
|
key_lower = key.lower()
|
||||||
|
return any(pattern in key_lower for pattern in sensitive_patterns)
|
||||||
|
|
||||||
|
return {k: "***REDACTED***" if is_sensitive(k) else v for k, v in args.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(value: str | None, max_length: int = 200) -> str | None:
|
||||||
|
"""Truncate a string value for logging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: String to truncate.
|
||||||
|
max_length: Maximum length.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Truncated string or None.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if len(value) <= max_length:
|
||||||
|
return value
|
||||||
|
return value[:max_length] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def audit_log(
|
||||||
|
tool_name: str,
|
||||||
|
args: dict[str, Any],
|
||||||
|
result: str | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
duration_ms: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Log a tool invocation with OAuth user identity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Name of the MCP tool invoked.
|
||||||
|
args: Tool arguments (will be sanitized).
|
||||||
|
result: Tool result string (will be truncated).
|
||||||
|
error: Error message if tool failed.
|
||||||
|
duration_ms: Execution time in milliseconds.
|
||||||
|
"""
|
||||||
|
username = get_username()
|
||||||
|
groups = get_user_groups()
|
||||||
|
|
||||||
|
log_entry = {
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"user": username,
|
||||||
|
"groups": groups,
|
||||||
|
"tool": tool_name,
|
||||||
|
"args": _sanitize_args(args),
|
||||||
|
"duration_ms": round(duration_ms, 2) if duration_ms else None,
|
||||||
|
"result": _truncate(result),
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove None values for cleaner logs
|
||||||
|
log_entry = {k: v for k, v in log_entry.items() if v is not None}
|
||||||
|
|
||||||
|
if error:
|
||||||
|
logger.warning("AUDIT_FAIL: %s", log_entry)
|
||||||
|
else:
|
||||||
|
logger.info("AUDIT: %s", log_entry)
|
||||||
|
|
||||||
|
|
||||||
|
def audit_permission_denied(
|
||||||
|
tool_name: str,
|
||||||
|
args: dict[str, Any],
|
||||||
|
required_permission: str,
|
||||||
|
) -> None:
|
||||||
|
"""Log a permission denied event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Name of the MCP tool attempted.
|
||||||
|
args: Tool arguments (will be sanitized).
|
||||||
|
required_permission: The permission level that was required.
|
||||||
|
"""
|
||||||
|
username = get_username()
|
||||||
|
groups = get_user_groups()
|
||||||
|
|
||||||
|
log_entry = {
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"user": username,
|
||||||
|
"groups": groups,
|
||||||
|
"tool": tool_name,
|
||||||
|
"args": _sanitize_args(args),
|
||||||
|
"required_permission": required_permission,
|
||||||
|
"event": "PERMISSION_DENIED",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warning("AUDIT_DENIED: %s", log_entry)
|
||||||
|
|
||||||
|
|
||||||
|
def audit_auth_event(
|
||||||
|
event_type: str,
|
||||||
|
username: str | None = None,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Log an authentication event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_type: Type of auth event (login, logout, token_refresh, etc.)
|
||||||
|
username: Username if known.
|
||||||
|
details: Additional event details.
|
||||||
|
"""
|
||||||
|
log_entry = {
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"event": event_type,
|
||||||
|
"user": username or get_username(),
|
||||||
|
**(details or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("AUTH_EVENT: %s", log_entry)
|
||||||
63
src/mcvsphere/auth.py
Normal file
63
src/mcvsphere/auth.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
"""OAuth authentication configuration for mcvsphere.
|
||||||
|
|
||||||
|
Provides OIDCProxy configuration for Authentik or other OIDC providers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mcvsphere.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_auth_provider(settings: Settings):
|
||||||
|
"""Create OAuth provider if enabled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings: Application settings with OAuth configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OIDCProxy instance if OAuth is enabled, None otherwise.
|
||||||
|
"""
|
||||||
|
if not settings.oauth_enabled:
|
||||||
|
logger.debug("OAuth authentication disabled")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Import here to avoid loading auth dependencies when not needed
|
||||||
|
from fastmcp.server.auth import OIDCProxy
|
||||||
|
|
||||||
|
# Build the OIDC config URL from issuer URL
|
||||||
|
# Authentik format: https://auth.example.com/application/o/<app>/
|
||||||
|
# Discovery URL: https://auth.example.com/application/o/<app>/.well-known/openid-configuration
|
||||||
|
issuer_url = settings.oauth_issuer_url.rstrip("/")
|
||||||
|
if not issuer_url.endswith("/.well-known/openid-configuration"):
|
||||||
|
config_url = f"{issuer_url}/.well-known/openid-configuration"
|
||||||
|
else:
|
||||||
|
config_url = issuer_url
|
||||||
|
|
||||||
|
# Build base URL for the MCP server
|
||||||
|
base_url = f"http://{settings.mcp_host}:{settings.mcp_port}"
|
||||||
|
|
||||||
|
logger.info("Configuring OAuth with OIDC provider: %s", issuer_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
auth = OIDCProxy(
|
||||||
|
config_url=config_url,
|
||||||
|
client_id=settings.oauth_client_id,
|
||||||
|
client_secret=settings.oauth_client_secret.get_secret_value(),
|
||||||
|
base_url=base_url,
|
||||||
|
required_scopes=settings.oauth_scopes,
|
||||||
|
# Allow Claude Code localhost redirects
|
||||||
|
allowed_client_redirect_uris=[
|
||||||
|
"http://localhost:*",
|
||||||
|
"http://127.0.0.1:*",
|
||||||
|
],
|
||||||
|
# Skip consent screen for MCP clients (they're already trusted)
|
||||||
|
require_authorization_consent=False,
|
||||||
|
)
|
||||||
|
logger.info("OAuth authentication enabled via OIDC")
|
||||||
|
return auth
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to configure OAuth: %s", e)
|
||||||
|
raise ValueError(f"OAuth configuration failed: {e}") from e
|
||||||
@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import Field, SecretStr, field_validator
|
from pydantic import Field, SecretStr, field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@ -49,8 +49,31 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
mcp_host: str = Field(default="0.0.0.0", description="Server bind address")
|
mcp_host: str = Field(default="0.0.0.0", description="Server bind address")
|
||||||
mcp_port: int = Field(default=8080, description="Server port")
|
mcp_port: int = Field(default=8080, description="Server port")
|
||||||
mcp_transport: Literal["stdio", "sse"] = Field(
|
mcp_transport: Literal["stdio", "sse", "http"] = Field(
|
||||||
default="stdio", description="MCP transport type"
|
default="stdio", description="MCP transport type (http required for OAuth)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# OAuth/OIDC settings
|
||||||
|
oauth_enabled: bool = Field(
|
||||||
|
default=False, description="Enable OAuth authentication via OIDC"
|
||||||
|
)
|
||||||
|
oauth_issuer_url: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="OIDC issuer URL (e.g., https://auth.example.com/application/o/mcvsphere/)",
|
||||||
|
)
|
||||||
|
oauth_client_id: str | None = Field(
|
||||||
|
default=None, description="OAuth client ID from OIDC provider"
|
||||||
|
)
|
||||||
|
oauth_client_secret: SecretStr | None = Field(
|
||||||
|
default=None, description="OAuth client secret from OIDC provider"
|
||||||
|
)
|
||||||
|
oauth_scopes: list[str] = Field(
|
||||||
|
default_factory=lambda: ["openid", "profile", "email", "groups"],
|
||||||
|
description="OAuth scopes to request",
|
||||||
|
)
|
||||||
|
oauth_required_groups: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="OAuth groups required for access (empty = any authenticated user)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Logging settings
|
# Logging settings
|
||||||
@ -61,13 +84,35 @@ class Settings(BaseSettings):
|
|||||||
default=None, description="Log file path (logs to console if not specified)"
|
default=None, description="Log file path (logs to console if not specified)"
|
||||||
)
|
)
|
||||||
|
|
||||||
@field_validator("vcenter_insecure", mode="before")
|
@field_validator("vcenter_insecure", "oauth_enabled", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_bool(cls, v: str | bool) -> bool:
|
def parse_bool(cls, v: str | bool) -> bool:
|
||||||
if isinstance(v, bool):
|
if isinstance(v, bool):
|
||||||
return v
|
return v
|
||||||
return v.lower() in ("true", "1", "yes", "on")
|
return v.lower() in ("true", "1", "yes", "on")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_oauth_config(self) -> "Settings":
|
||||||
|
"""Validate OAuth configuration is complete when enabled."""
|
||||||
|
if self.oauth_enabled:
|
||||||
|
missing = []
|
||||||
|
if not self.oauth_issuer_url:
|
||||||
|
missing.append("oauth_issuer_url")
|
||||||
|
if not self.oauth_client_id:
|
||||||
|
missing.append("oauth_client_id")
|
||||||
|
if not self.oauth_client_secret:
|
||||||
|
missing.append("oauth_client_secret")
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"OAuth is enabled but missing required settings: {', '.join(missing)}"
|
||||||
|
)
|
||||||
|
# OAuth requires HTTP transport
|
||||||
|
if self.mcp_transport == "stdio":
|
||||||
|
raise ValueError(
|
||||||
|
"OAuth requires HTTP transport. Set mcp_transport='http' or 'sse'"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_yaml(cls, path: Path) -> "Settings":
|
def from_yaml(cls, path: Path) -> "Settings":
|
||||||
"""Load settings from a YAML file, with env vars taking precedence."""
|
"""Load settings from a YAML file, with env vars taking precedence."""
|
||||||
|
|||||||
181
src/mcvsphere/middleware.py
Normal file
181
src/mcvsphere/middleware.py
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
"""Middleware for permission checking and audit logging.
|
||||||
|
|
||||||
|
Provides decorators and hooks for wrapping tool execution with:
|
||||||
|
- OAuth permission validation
|
||||||
|
- Audit logging with user identity
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mcvsphere.audit import (
|
||||||
|
audit_log,
|
||||||
|
audit_permission_denied,
|
||||||
|
get_current_user,
|
||||||
|
get_user_groups,
|
||||||
|
set_current_user,
|
||||||
|
)
|
||||||
|
from mcvsphere.permissions import (
|
||||||
|
PermissionDeniedError,
|
||||||
|
PermissionLevel,
|
||||||
|
check_permission,
|
||||||
|
get_required_permission,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def with_permission_check(tool_name: str) -> Callable:
|
||||||
|
"""Decorator factory for permission checking and audit logging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Name of the MCP tool being wrapped.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decorator that wraps the tool function with permission checks and audit logging.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
@with_permission_check("power_on")
|
||||||
|
def power_on(self, name: str) -> str:
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs) -> Any:
|
||||||
|
# Get user groups from current context
|
||||||
|
groups = get_user_groups()
|
||||||
|
user = get_current_user()
|
||||||
|
username = "anonymous"
|
||||||
|
if user:
|
||||||
|
username = user.get(
|
||||||
|
"preferred_username", user.get("email", user.get("sub", "unknown"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
if not check_permission(tool_name, groups):
|
||||||
|
required = get_required_permission(tool_name)
|
||||||
|
audit_permission_denied(tool_name, kwargs, required.value)
|
||||||
|
raise PermissionDeniedError(username, tool_name, required)
|
||||||
|
|
||||||
|
# Execute tool with timing
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
try:
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
duration_ms = (time.perf_counter() - start_time) * 1000
|
||||||
|
audit_log(tool_name, kwargs, result=str(result), duration_ms=duration_ms)
|
||||||
|
return result
|
||||||
|
except PermissionDeniedError:
|
||||||
|
# Re-raise permission errors without additional logging
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
duration_ms = (time.perf_counter() - start_time) * 1000
|
||||||
|
audit_log(tool_name, kwargs, error=str(e), duration_ms=duration_ms)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def extract_user_from_context(ctx) -> dict[str, Any] | None:
|
||||||
|
"""Extract user information from FastMCP context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: FastMCP Context object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User info dict from OAuth token claims, or None if not authenticated.
|
||||||
|
"""
|
||||||
|
if ctx is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try to get access token from context
|
||||||
|
try:
|
||||||
|
# FastMCP stores the access token in request_context
|
||||||
|
if hasattr(ctx, "request_context") and ctx.request_context:
|
||||||
|
token = getattr(ctx.request_context, "access_token", None)
|
||||||
|
if token and hasattr(token, "claims"):
|
||||||
|
return token.claims
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def setup_user_context(ctx) -> None:
|
||||||
|
"""Set up user context from FastMCP context for the current request.
|
||||||
|
|
||||||
|
Call this at the start of request handling to make user info
|
||||||
|
available throughout the request via get_current_user().
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: FastMCP Context object.
|
||||||
|
"""
|
||||||
|
user_info = extract_user_from_context(ctx)
|
||||||
|
set_current_user(user_info)
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionMiddleware:
|
||||||
|
"""Middleware for adding permission checks to all tools.
|
||||||
|
|
||||||
|
This can be used to wrap mixin tool registration with permission checking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, oauth_enabled: bool = False):
|
||||||
|
"""Initialize middleware.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
oauth_enabled: Whether OAuth authentication is enabled.
|
||||||
|
"""
|
||||||
|
self.oauth_enabled = oauth_enabled
|
||||||
|
|
||||||
|
def wrap_tool(self, tool_name: str, func: Callable) -> Callable:
|
||||||
|
"""Wrap a tool function with permission checking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Name of the tool.
|
||||||
|
func: Original tool function.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Wrapped function with permission checks.
|
||||||
|
"""
|
||||||
|
if not self.oauth_enabled:
|
||||||
|
# No auth - just add basic audit logging
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs) -> Any:
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
try:
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
duration_ms = (time.perf_counter() - start_time) * 1000
|
||||||
|
audit_log(tool_name, kwargs, result=str(result), duration_ms=duration_ms)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
duration_ms = (time.perf_counter() - start_time) * 1000
|
||||||
|
audit_log(tool_name, kwargs, error=str(e), duration_ms=duration_ms)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
# With auth - add permission checking
|
||||||
|
return with_permission_check(tool_name)(func)
|
||||||
|
|
||||||
|
|
||||||
|
def get_permission_summary() -> dict[str, list[str]]:
|
||||||
|
"""Get a summary of tools grouped by permission level.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping permission level names to lists of tool names.
|
||||||
|
"""
|
||||||
|
from mcvsphere.permissions import TOOL_PERMISSIONS
|
||||||
|
|
||||||
|
summary: dict[str, list[str]] = {level.value: [] for level in PermissionLevel}
|
||||||
|
|
||||||
|
for tool_name, level in TOOL_PERMISSIONS.items():
|
||||||
|
summary[level.value].append(tool_name)
|
||||||
|
|
||||||
|
# Sort tool names within each level
|
||||||
|
for level in summary:
|
||||||
|
summary[level].sort()
|
||||||
|
|
||||||
|
return summary
|
||||||
238
src/mcvsphere/permissions.py
Normal file
238
src/mcvsphere/permissions.py
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
"""Permission escalation based on OAuth claims.
|
||||||
|
|
||||||
|
Defines permission levels and maps:
|
||||||
|
- Tools → Required permission level
|
||||||
|
- OAuth groups → Granted permission levels
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionLevel(Enum):
|
||||||
|
"""Permission levels for tool access, from least to most privileged."""
|
||||||
|
|
||||||
|
READ_ONLY = "read_only" # View-only operations
|
||||||
|
POWER_OPS = "power_ops" # Power on/off, snapshots
|
||||||
|
VM_LIFECYCLE = "vm_lifecycle" # Create/delete/modify VMs
|
||||||
|
HOST_ADMIN = "host_admin" # ESXi host operations
|
||||||
|
FULL_ADMIN = "full_admin" # Everything including guest ops, services
|
||||||
|
|
||||||
|
|
||||||
|
# Tool → Required Permission mapping
|
||||||
|
# Default is READ_ONLY if not listed
|
||||||
|
TOOL_PERMISSIONS: dict[str, PermissionLevel] = {
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# READ_ONLY - Safe viewing operations (36 tools)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
"list_vms": PermissionLevel.READ_ONLY,
|
||||||
|
"get_vm_info": PermissionLevel.READ_ONLY,
|
||||||
|
"list_snapshots": PermissionLevel.READ_ONLY,
|
||||||
|
"get_vm_stats": PermissionLevel.READ_ONLY,
|
||||||
|
"get_host_stats": PermissionLevel.READ_ONLY,
|
||||||
|
"list_hosts": PermissionLevel.READ_ONLY,
|
||||||
|
"get_recent_tasks": PermissionLevel.READ_ONLY,
|
||||||
|
"get_recent_events": PermissionLevel.READ_ONLY,
|
||||||
|
"get_alarms": PermissionLevel.READ_ONLY,
|
||||||
|
"browse_datastore": PermissionLevel.READ_ONLY,
|
||||||
|
"get_datastore_info": PermissionLevel.READ_ONLY,
|
||||||
|
"get_network_info": PermissionLevel.READ_ONLY,
|
||||||
|
"get_resource_pool_info": PermissionLevel.READ_ONLY,
|
||||||
|
"list_templates": PermissionLevel.READ_ONLY,
|
||||||
|
"get_vcenter_info": PermissionLevel.READ_ONLY,
|
||||||
|
"list_disks": PermissionLevel.READ_ONLY,
|
||||||
|
"list_nics": PermissionLevel.READ_ONLY,
|
||||||
|
"list_ovf_networks": PermissionLevel.READ_ONLY,
|
||||||
|
"get_host_info": PermissionLevel.READ_ONLY,
|
||||||
|
"list_services": PermissionLevel.READ_ONLY,
|
||||||
|
"get_ntp_config": PermissionLevel.READ_ONLY,
|
||||||
|
"get_host_hardware": PermissionLevel.READ_ONLY,
|
||||||
|
"get_host_networking": PermissionLevel.READ_ONLY,
|
||||||
|
"list_folders": PermissionLevel.READ_ONLY,
|
||||||
|
"list_recent_tasks": PermissionLevel.READ_ONLY,
|
||||||
|
"list_recent_events": PermissionLevel.READ_ONLY,
|
||||||
|
"list_clusters": PermissionLevel.READ_ONLY,
|
||||||
|
"get_drs_recommendations": PermissionLevel.READ_ONLY,
|
||||||
|
"get_serial_port": PermissionLevel.READ_ONLY,
|
||||||
|
"wait_for_vm_tools": PermissionLevel.READ_ONLY,
|
||||||
|
"get_vm_tools_status": PermissionLevel.READ_ONLY,
|
||||||
|
"vm_screenshot": PermissionLevel.READ_ONLY,
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# POWER_OPS - Power and snapshot operations (14 tools)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
"power_on": PermissionLevel.POWER_OPS,
|
||||||
|
"power_off": PermissionLevel.POWER_OPS,
|
||||||
|
"shutdown_guest": PermissionLevel.POWER_OPS,
|
||||||
|
"reboot_guest": PermissionLevel.POWER_OPS,
|
||||||
|
"reset_vm": PermissionLevel.POWER_OPS,
|
||||||
|
"suspend_vm": PermissionLevel.POWER_OPS,
|
||||||
|
"standby_guest": PermissionLevel.POWER_OPS,
|
||||||
|
"create_snapshot": PermissionLevel.POWER_OPS,
|
||||||
|
"revert_to_snapshot": PermissionLevel.POWER_OPS,
|
||||||
|
"revert_to_current_snapshot": PermissionLevel.POWER_OPS,
|
||||||
|
"delete_snapshot": PermissionLevel.POWER_OPS,
|
||||||
|
"delete_all_snapshots": PermissionLevel.POWER_OPS,
|
||||||
|
"rename_snapshot": PermissionLevel.POWER_OPS,
|
||||||
|
"connect_nic": PermissionLevel.POWER_OPS, # Connect/disconnect is power-level
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# VM_LIFECYCLE - Create/delete/modify VMs (28 tools)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
"create_vm": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"clone_vm": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"delete_vm": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"reconfigure_vm": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"rename_vm": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"add_disk": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"remove_disk": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"extend_disk": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"attach_iso": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"detach_iso": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"add_nic": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"remove_nic": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"change_nic_network": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"set_nic_mac": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"deploy_ovf": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"export_vm_ovf": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"convert_to_template": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"convert_to_vm": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"deploy_from_template": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"create_folder": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"move_vm_to_folder": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"storage_vmotion": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"move_vm_disk": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"setup_serial_port": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"connect_serial_port": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"clear_serial_port": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"remove_serial_port": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
# Datastore modifications
|
||||||
|
"download_from_datastore": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"upload_to_datastore": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"delete_datastore_file": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"create_datastore_folder": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"move_datastore_file": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
"copy_datastore_file": PermissionLevel.VM_LIFECYCLE,
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# HOST_ADMIN - ESXi host operations (6 tools)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
"enter_maintenance_mode": PermissionLevel.HOST_ADMIN,
|
||||||
|
"exit_maintenance_mode": PermissionLevel.HOST_ADMIN,
|
||||||
|
"reboot_host": PermissionLevel.HOST_ADMIN,
|
||||||
|
"shutdown_host": PermissionLevel.HOST_ADMIN,
|
||||||
|
"configure_ntp": PermissionLevel.HOST_ADMIN,
|
||||||
|
"set_service_policy": PermissionLevel.HOST_ADMIN,
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# FULL_ADMIN - Everything including guest OS and service control (10 tools)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
"start_service": PermissionLevel.FULL_ADMIN,
|
||||||
|
"stop_service": PermissionLevel.FULL_ADMIN,
|
||||||
|
# Guest OS operations (requires guest credentials, high privilege)
|
||||||
|
"run_command_in_guest": PermissionLevel.FULL_ADMIN,
|
||||||
|
"list_guest_processes": PermissionLevel.FULL_ADMIN,
|
||||||
|
"read_guest_file": PermissionLevel.FULL_ADMIN,
|
||||||
|
"write_guest_file": PermissionLevel.FULL_ADMIN,
|
||||||
|
"list_guest_directory": PermissionLevel.FULL_ADMIN,
|
||||||
|
"create_guest_directory": PermissionLevel.FULL_ADMIN,
|
||||||
|
"delete_guest_file": PermissionLevel.FULL_ADMIN,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# OAuth Group → Granted Permission Levels
|
||||||
|
# Users inherit all permissions from their groups (union of all group permissions)
|
||||||
|
GROUP_PERMISSIONS: dict[str, set[PermissionLevel]] = {
|
||||||
|
# View-only access
|
||||||
|
"vsphere-readers": {
|
||||||
|
PermissionLevel.READ_ONLY,
|
||||||
|
},
|
||||||
|
# Operators can power on/off, manage snapshots
|
||||||
|
"vsphere-operators": {
|
||||||
|
PermissionLevel.READ_ONLY,
|
||||||
|
PermissionLevel.POWER_OPS,
|
||||||
|
},
|
||||||
|
# Admins can create/delete/modify VMs
|
||||||
|
"vsphere-admins": {
|
||||||
|
PermissionLevel.READ_ONLY,
|
||||||
|
PermissionLevel.POWER_OPS,
|
||||||
|
PermissionLevel.VM_LIFECYCLE,
|
||||||
|
},
|
||||||
|
# Host admins can manage ESXi hosts
|
||||||
|
"vsphere-host-admins": {
|
||||||
|
PermissionLevel.READ_ONLY,
|
||||||
|
PermissionLevel.POWER_OPS,
|
||||||
|
PermissionLevel.VM_LIFECYCLE,
|
||||||
|
PermissionLevel.HOST_ADMIN,
|
||||||
|
},
|
||||||
|
# Super admins have full access
|
||||||
|
"vsphere-super-admins": {
|
||||||
|
PermissionLevel.READ_ONLY,
|
||||||
|
PermissionLevel.POWER_OPS,
|
||||||
|
PermissionLevel.VM_LIFECYCLE,
|
||||||
|
PermissionLevel.HOST_ADMIN,
|
||||||
|
PermissionLevel.FULL_ADMIN,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionDeniedError(Exception):
|
||||||
|
"""Raised when user lacks permission for an operation."""
|
||||||
|
|
||||||
|
def __init__(self, username: str, tool_name: str, required: PermissionLevel):
|
||||||
|
self.username = username
|
||||||
|
self.tool_name = tool_name
|
||||||
|
self.required = required
|
||||||
|
super().__init__(
|
||||||
|
f"Permission denied: {username} lacks '{required.value}' permission for '{tool_name}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_permissions(groups: list[str] | None) -> set[PermissionLevel]:
|
||||||
|
"""Extract permissions from OAuth groups.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
groups: List of OAuth group names from token claims.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of granted permission levels (union of all group permissions).
|
||||||
|
"""
|
||||||
|
if not groups:
|
||||||
|
return {PermissionLevel.READ_ONLY}
|
||||||
|
|
||||||
|
permissions: set[PermissionLevel] = set()
|
||||||
|
|
||||||
|
for group in groups:
|
||||||
|
if group in GROUP_PERMISSIONS:
|
||||||
|
permissions.update(GROUP_PERMISSIONS[group])
|
||||||
|
|
||||||
|
# Default to read-only if no recognized groups
|
||||||
|
if not permissions:
|
||||||
|
permissions.add(PermissionLevel.READ_ONLY)
|
||||||
|
|
||||||
|
return permissions
|
||||||
|
|
||||||
|
|
||||||
|
def get_required_permission(tool_name: str) -> PermissionLevel:
|
||||||
|
"""Get required permission level for a tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Name of the MCP tool.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Required permission level (defaults to READ_ONLY if not mapped).
|
||||||
|
"""
|
||||||
|
return TOOL_PERMISSIONS.get(tool_name, PermissionLevel.READ_ONLY)
|
||||||
|
|
||||||
|
|
||||||
|
def check_permission(
|
||||||
|
tool_name: str,
|
||||||
|
groups: list[str] | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if user has permission for a tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Name of the MCP tool to check.
|
||||||
|
groups: OAuth groups from token claims.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has required permission, False otherwise.
|
||||||
|
"""
|
||||||
|
required = get_required_permission(tool_name)
|
||||||
|
user_perms = get_user_permissions(groups)
|
||||||
|
return required in user_perms
|
||||||
@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from mcvsphere.auth import create_auth_provider
|
||||||
from mcvsphere.config import Settings, get_settings
|
from mcvsphere.config import Settings, get_settings
|
||||||
from mcvsphere.connection import VMwareConnection
|
from mcvsphere.connection import VMwareConnection
|
||||||
from mcvsphere.mixins import (
|
from mcvsphere.mixins import (
|
||||||
@ -53,6 +54,9 @@ def create_server(settings: Settings | None = None) -> FastMCP:
|
|||||||
stream=sys.stderr, # Explicitly use stderr
|
stream=sys.stderr, # Explicitly use stderr
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create auth provider if OAuth enabled
|
||||||
|
auth = create_auth_provider(settings)
|
||||||
|
|
||||||
# Create FastMCP server
|
# Create FastMCP server
|
||||||
mcp = FastMCP(
|
mcp = FastMCP(
|
||||||
name="mcvsphere",
|
name="mcvsphere",
|
||||||
@ -61,6 +65,7 @@ def create_server(settings: Settings | None = None) -> FastMCP:
|
|||||||
"Provides tools for VM lifecycle management, power operations, "
|
"Provides tools for VM lifecycle management, power operations, "
|
||||||
"snapshots, guest OS operations, monitoring, and infrastructure resources."
|
"snapshots, guest OS operations, monitoring, and infrastructure resources."
|
||||||
),
|
),
|
||||||
|
auth=auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create shared VMware connection
|
# Create shared VMware connection
|
||||||
@ -114,8 +119,8 @@ def run_server(config_path: Path | None = None) -> None:
|
|||||||
# Load settings
|
# Load settings
|
||||||
settings = Settings.from_yaml(config_path) if config_path else get_settings()
|
settings = Settings.from_yaml(config_path) if config_path else get_settings()
|
||||||
|
|
||||||
# Only print banner for SSE mode (stdio must stay clean for JSON-RPC)
|
# Only print banner for HTTP/SSE modes (stdio must stay clean for JSON-RPC)
|
||||||
if settings.mcp_transport == "sse":
|
if settings.mcp_transport in ("sse", "http"):
|
||||||
try:
|
try:
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
|
|
||||||
@ -125,15 +130,23 @@ def run_server(config_path: Path | None = None) -> None:
|
|||||||
|
|
||||||
print(f"mcvsphere v{package_version}", file=sys.stderr)
|
print(f"mcvsphere v{package_version}", file=sys.stderr)
|
||||||
print("─" * 40, file=sys.stderr)
|
print("─" * 40, file=sys.stderr)
|
||||||
|
transport_name = "HTTP" if settings.mcp_transport == "http" else "SSE"
|
||||||
print(
|
print(
|
||||||
f"Starting SSE transport on {settings.mcp_host}:{settings.mcp_port}",
|
f"Starting {transport_name} transport on {settings.mcp_host}:{settings.mcp_port}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
|
if settings.oauth_enabled:
|
||||||
|
print(f"OAuth: ENABLED via {settings.oauth_issuer_url}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print("OAuth: disabled", file=sys.stderr)
|
||||||
|
print("─" * 40, file=sys.stderr)
|
||||||
|
|
||||||
# Create and run server
|
# Create and run server
|
||||||
mcp = create_server(settings)
|
mcp = create_server(settings)
|
||||||
|
|
||||||
if settings.mcp_transport == "sse":
|
if settings.mcp_transport == "http":
|
||||||
|
mcp.run(transport="streamable-http", host=settings.mcp_host, port=settings.mcp_port)
|
||||||
|
elif settings.mcp_transport == "sse":
|
||||||
mcp.run(transport="sse", host=settings.mcp_host, port=settings.mcp_port)
|
mcp.run(transport="sse", host=settings.mcp_host, port=settings.mcp_port)
|
||||||
else:
|
else:
|
||||||
# stdio mode - suppress banner to keep stdout clean for JSON-RPC
|
# stdio mode - suppress banner to keep stdout clean for JSON-RPC
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user