Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 58 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ SPDX-License-Identifier: Apache-2.0

# openstack-mcp-server

MCP (Model Context Protocol) server for OpenStack and SAP Converged Cloud. Provides AI coding agents with typed, structured tools for querying infrastructure — 66 tools across 18 services.
MCP (Model Context Protocol) server for OpenStack and SAP Converged Cloud. Provides AI coding agents with typed, structured tools for managing infrastructure — 86 tools across 18 services (72 read + 14 write).

## Quick Start

Expand Down Expand Up @@ -37,17 +37,17 @@ claude mcp add openstack openstack-mcp-server \
### Standard OpenStack
| Service | Tools | Description |
|---------|-------|-------------|
| **Nova** (Compute) | `nova_list_servers`, `nova_get_server`, `nova_list_flavors`, `nova_list_keypairs`, `nova_server_action`* | Servers, flavors, keypairs, actions |
| **Neutron** (Networking) | `neutron_list_networks`, `neutron_list_subnets`, `neutron_list_ports`, `neutron_list_security_groups`, `neutron_list_routers`, `neutron_list_floating_ips` | Networks, subnets, ports, security groups, routers, floating IPs |
| **Cinder** (Block Storage) | `cinder_list_volumes`, `cinder_get_volume`, `cinder_list_snapshots`, `cinder_get_snapshot`, `cinder_list_volume_types` | Volumes, snapshots, volume types |
| **Keystone** (Identity) | `keystone_list_projects`, `keystone_token_info`, `keystone_list_application_credentials`, `keystone_create_application_credential`*, `keystone_delete_application_credential`* | Projects, auth info, app credentials |
| **Designate** (DNS) | `designate_list_zones`, `designate_get_zone`, `designate_list_recordsets` | DNS zones and records |
| **Nova** (Compute) | `nova_list_servers`, `nova_get_server`, `nova_list_flavors`, `nova_list_keypairs`, `nova_list_availability_zones`, `nova_get_quotas`, `nova_server_action`*, `nova_create_server`* | Servers, flavors, keypairs, AZs, quotas, actions |
| **Neutron** (Networking) | `neutron_list_networks`, `neutron_list_subnets`, `neutron_list_ports`, `neutron_list_security_groups`, `neutron_list_routers`, `neutron_list_floating_ips`, `neutron_create_security_group_rule`*, `neutron_delete_security_group_rule`* | Networks, subnets, ports, security groups, routers, floating IPs |
| **Cinder** (Block Storage) | `cinder_list_volumes`, `cinder_get_volume`, `cinder_list_snapshots`, `cinder_get_snapshot`, `cinder_list_volume_types`, `cinder_get_quotas`, `cinder_create_volume`*, `cinder_delete_volume`* | Volumes, snapshots, types, quotas |
| **Keystone** (Identity) | `keystone_list_projects`, `keystone_token_info`, `keystone_list_application_credentials`, `keystone_list_domains`, `keystone_list_users`, `keystone_list_roles`, `keystone_create_application_credential`*, `keystone_delete_application_credential`* | Projects, auth info, domains, users, roles, app credentials |
| **Designate** (DNS) | `designate_list_zones`, `designate_get_zone`, `designate_list_recordsets`, `designate_create_recordset`*, `designate_delete_recordset`* | DNS zones and records |
| **Barbican** (Key Manager) | `barbican_list_secrets`, `barbican_get_secret` | Secrets metadata (no payloads) |
| **Swift** (Object Storage) | `swift_list_containers`, `swift_list_objects`, `swift_get_object_metadata` | Containers and objects |
| **Manila** (Shared Filesystems) | `manila_list_shares`, `manila_get_share`, `manila_list_access_rules` | File shares, access rules |
| **Octavia** (Load Balancer) | `octavia_list_loadbalancers`, `octavia_get_loadbalancer`, `octavia_list_listeners`, `octavia_list_pools`, `octavia_list_members`, `octavia_list_healthmonitors` | Load balancers, listeners, pools, members, health monitors |
| **Swift** (Object Storage) | `swift_list_containers`, `swift_list_objects`, `swift_get_object_metadata`, `swift_upload_object`*, `swift_delete_object`* | Containers and objects |
| **Manila** (Shared Filesystems) | `manila_list_shares`, `manila_get_share`, `manila_list_access_rules`, `manila_list_share_networks` | File shares, access rules, share networks |
| **Octavia** (Load Balancer) | `octavia_list_loadbalancers`, `octavia_get_loadbalancer`, `octavia_list_listeners`, `octavia_list_pools`, `octavia_list_members`, `octavia_list_healthmonitors`, `octavia_list_l7policies`, `octavia_create_loadbalancer`*, `octavia_delete_loadbalancer`* | Load balancers, listeners, pools, members, health monitors, L7 policies |
| **Glance** (Image) | `glance_list_images`, `glance_get_image` | Images |
| **Ironic** (Bare Metal) | `ironic_list_nodes`, `ironic_get_node`, `ironic_list_node_ports` | Baremetal nodes, ports |
| **Ironic** (Bare Metal) | `ironic_list_nodes`, `ironic_get_node`, `ironic_list_node_ports`, `ironic_list_allocations` | Baremetal nodes, ports, allocations |

### SAP Converged Cloud
| Service | Tools | Description |
Expand All @@ -60,7 +60,7 @@ claude mcp add openstack openstack-mcp-server \
| **Castellum** (Autoscaling) | `castellum_get_project_resources`, `castellum_list_pending_operations`, `castellum_list_recently_failed_operations` | Resource autoscaling |
| **Cronus** (Email) | `cronus_get_usage`, `cronus_list_templates` | Email service usage and templates |

*\* Mutating tools — disabled in read-only mode (default). Set `MCP_READ_ONLY=false` to enable.*
*\* Mutating tools (14 total) — disabled in read-only mode (default). Set `MCP_READ_ONLY=false` to enable.*

## Configuration

Expand Down Expand Up @@ -128,31 +128,61 @@ Add to your MCP client's configuration file (e.g., `.cursor/mcp.json`):

## Security

### Three-Layer Safety Architecture
### Four-Layer Safety Architecture

| Layer | Mechanism | Effect |
|-------|-----------|--------|
| **1. Read-Only Mode** | `MCP_READ_ONLY=true` (default) | Mutating tools are not registered — invisible to the LLM |
| **2. Tool Annotations** | `DestructiveHint` / `ReadOnlyHint` | MCP client prompts user for confirmation on destructive actions |
| **3. Credential Isolation** | Secrets held in server memory only | Auth tokens and passwords never reach the LLM |
| **2. Confirmed Pattern** | Two-call execution | First call returns a preview of what will happen; second call with `confirmed=true` executes |
| **3. Semantic Guardrails** | Domain-specific validation | Rejects dangerous operations outright (e.g., 0.0.0.0/0 on SSH, deleting in-use volumes) |
| **4. Credential Isolation** | Secrets held in server memory only | Auth tokens and passwords never reach the LLM |

### Write Safety: The Confirmed Pattern

All 14 write tools implement a two-call safety pattern:

```
1st call (confirmed absent/false):
→ Returns PREVIEW: "Will DELETE volume 'db-backup' (abc123), 50GiB, status: available"

2nd call (confirmed=true):
→ Executes the operation
```

This gives the AI agent (and the human supervising it) a chance to review what will happen before any state changes.

### Semantic Guardrails

Write tools include domain-specific safety rules that reject dangerous operations before they reach the confirmation step:

| Service | Rule | Rationale |
|---------|------|-----------|
| **Neutron** | Rejects ingress 0.0.0.0/0 on ports 22, 3389, 3306, 5432 | Prevents accidental world-open SSH/RDP/DB access |
| **Cinder** | Rejects delete on status `in-use` | Prevents data loss from deleting attached volumes |
| **Designate** | Enforces CNAME singleton per name | DNS RFC compliance (CNAME can't coexist with other records) |
| **Swift** | `safe_write` option uses `If-None-Match:*` | Prevents accidental overwrites of existing objects |
| **Octavia** | Cascade delete requires explicit opt-in | Prevents accidental deletion of listeners, pools, and members |

### Read-Only Mode (Default)

By default, mutating tools are **disabled**:
- `nova_server_action` (start/stop/reboot servers)
- `keystone_create_application_credential`
- `keystone_delete_application_credential`
By default, all 14 mutating tools are **disabled** (`MCP_READ_ONLY=true`). Set `MCP_READ_ONLY=false` only when you explicitly need write operations. The write tools are:

Set `MCP_READ_ONLY=false` only when you explicitly need write operations.
- `nova_server_action`, `nova_create_server`
- `neutron_create_security_group_rule`, `neutron_delete_security_group_rule`
- `cinder_create_volume`, `cinder_delete_volume`
- `designate_create_recordset`, `designate_delete_recordset`
- `swift_upload_object`, `swift_delete_object`
- `octavia_create_loadbalancer`, `octavia_delete_loadbalancer`
- `keystone_create_application_credential`, `keystone_delete_application_credential`

### Tool Annotations (Human-in-the-Loop)

All tools declare their intent via [MCP tool annotations](https://modelcontextprotocol.io/specification/2025-03-26/server/tools#annotations):

- **Read-only tools** (63 tools): Annotated with `readOnlyHint: true`. Clients may auto-approve these.
- **Destructive tools** (3 tools): Annotated with `destructiveHint: true`. Clients **must prompt the user** before execution.
- **Read-only tools** (72 tools): Annotated with `readOnlyHint: true`. Clients may auto-approve these.
- **Destructive tools** (14 tools): Annotated with `destructiveHint: true`. Clients **must prompt the user** before execution.

This means even when `MCP_READ_ONLY=false` enables destructive tools, the MCP client (Claude Code, Cursor, etc.) will still ask "Allow this action?" before executing server actions or credential mutations. The server declares, the client enforces.
This means even when `MCP_READ_ONLY=false` enables destructive tools, the MCP client (Claude Code, Cursor, etc.) will still ask "Allow this action?" before executing. The server declares intent, the client enforces the gate.

### Credential Isolation Architecture

Expand Down Expand Up @@ -187,6 +217,12 @@ Try these after setup:
- "What load balancers exist and what pools do they have?"
- "Show me pending Castellum autoscaling operations"

With write tools enabled (`MCP_READ_ONLY=false`):
- "Create a 100GiB SSD volume named 'db-data' in AZ eu-de-1a"
- "Add an A record for api.example.com pointing to 10.0.1.50"
- "Create a security group rule allowing TCP 443 from 10.0.0.0/8"
- "Create a load balancer on subnet abc123 named 'web-lb'"

## Companion: Agent Toolkit

For enhanced AI workflows, pair this MCP server with the [OpenStack Agent Toolkit](https://github.com/notque/openstack-agent-toolkit) — a Claude Code plugin providing domain knowledge, operational skills, and safety hooks for SAP Converged Cloud infrastructure.
Expand Down
10 changes: 5 additions & 5 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,14 @@ func (s *Server) registerTools() {

// Standard OpenStack services
nova.Register(s.mcp, s.provider, readOnly)
neutron.Register(s.mcp, s.provider)
cinder.Register(s.mcp, s.provider)
neutron.Register(s.mcp, s.provider, readOnly)
cinder.Register(s.mcp, s.provider, readOnly)
keystone.Register(s.mcp, s.provider, readOnly)
designate.Register(s.mcp, s.provider)
designate.Register(s.mcp, s.provider, readOnly)
barbican.Register(s.mcp, s.provider)
swift.Register(s.mcp, s.provider)
swift.Register(s.mcp, s.provider, readOnly)
manila.Register(s.mcp, s.provider)
octavia.Register(s.mcp, s.provider)
octavia.Register(s.mcp, s.provider, readOnly)
glance.Register(s.mcp, s.provider)

// SAP CC-specific services
Expand Down
10 changes: 5 additions & 5 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@ func TestAllModulesRegisterWithoutPanic(t *testing.T) {
// Each Register call captures the provider pointer in closures.
// Passing nil is safe because we never invoke the handlers.
nova.Register(s, nil, false)
neutron.Register(s, nil)
cinder.Register(s, nil)
neutron.Register(s, nil, false)
cinder.Register(s, nil, false)
keystone.Register(s, nil, false)
designate.Register(s, nil)
designate.Register(s, nil, false)
barbican.Register(s, nil)
swift.Register(s, nil)
swift.Register(s, nil, false)
manila.Register(s, nil)
octavia.Register(s, nil)
octavia.Register(s, nil, false)
glance.Register(s, nil)
hermes.Register(s, nil)
limes.Register(s, nil)
Expand Down
179 changes: 178 additions & 1 deletion internal/tools/cinder/cinder.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ package cinder
import (
"context"
"encoding/json"
"fmt"

"github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/quotasets"
"github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/snapshots"
"github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes"
"github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumetypes"
Expand All @@ -20,12 +22,18 @@ import (
)

// Register adds all Cinder tools to the MCP server.
func Register(s *mcpserver.MCPServer, provider *auth.Provider) {
func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) {
s.AddTool(listVolumesTool, listVolumesHandler(provider))
s.AddTool(getVolumeTool, getVolumeHandler(provider))
s.AddTool(listSnapshotsTool, listSnapshotsHandler(provider))
s.AddTool(getSnapshotTool, getSnapshotHandler(provider))
s.AddTool(listVolumeTypesTool, listVolumeTypesHandler(provider))
s.AddTool(getQuotasTool, getQuotasHandler(provider))

if !readOnly {
s.AddTool(createVolumeTool, createVolumeHandler(provider))
s.AddTool(deleteVolumeTool, deleteVolumeHandler(provider))
}
}

var listVolumesTool = mcp.NewTool("cinder_list_volumes",
Expand Down Expand Up @@ -245,3 +253,172 @@ func listVolumeTypesHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
return shared.ToolResult(string(out)), nil
}
}

// --- Read tool: quotas ---

var getQuotasTool = mcp.NewTool("cinder_get_quotas",
mcp.WithDescription("Get block storage quota usage for a project. Shows limits and current usage for volumes, snapshots, gigabytes, and backups."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("project_id", mcp.Required(), mcp.Description("The UUID of the project to get quotas for")),
)

func getQuotasHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
client, err := provider.BlockStorageClient()
if err != nil {
return shared.ToolError("failed to get block storage client: %v", err), nil
}

projectID := shared.StringParam(request, "project_id")
if projectID == "" {
return shared.ToolError("project_id is required"), nil
}
if errResult := shared.ValidateUUID(projectID, "project_id"); errResult != nil {
return errResult, nil
}

usage, err := quotasets.GetUsage(ctx, client, projectID).Extract()
if err != nil {
return shared.ToolError("failed to get quotas for project %s: %v", projectID, err), nil
}

out, err := json.MarshalIndent(usage, "", " ")
if err != nil {
return shared.ToolError("failed to marshal response: %v", err), nil
}
return shared.ToolResult(string(out)), nil
}
}

// --- Write tools ---

var createVolumeTool = mcp.NewTool("cinder_create_volume",
mcp.WithDescription("Create a new block storage volume."),
mcp.WithDestructiveHintAnnotation(true),
mcp.WithString("name", mcp.Description("Name for the new volume")),
mcp.WithNumber("size", mcp.Required(), mcp.Description("Size of the volume in GiB (must be > 0)")),
mcp.WithString("volume_type", mcp.Description("Volume type (e.g., 'vmware_hdd', 'vmware_ssd')")),
mcp.WithString("availability_zone", mcp.Description("Availability zone for the volume")),
mcp.WithString("description", mcp.Description("Description of the volume")),
mcp.WithString("snapshot_id", mcp.Description("UUID of a snapshot to create the volume from")),
mcp.WithString("source_volume_id", mcp.Description("UUID of an existing volume to clone")),
mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")),
)

var deleteVolumeTool = mcp.NewTool("cinder_delete_volume",
mcp.WithDescription("Delete a block storage volume. Volume must not be attached to any server."),
mcp.WithDestructiveHintAnnotation(true),
mcp.WithString("volume_id", mcp.Required(), mcp.Description("The UUID of the volume to delete")),
mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")),
)

func createVolumeHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
client, err := provider.BlockStorageClient()
if err != nil {
return shared.ToolError("failed to get block storage client: %v", err), nil
}

size := int(shared.NumberParam(request, "size"))
if size <= 0 {
return shared.ToolError("size must be greater than 0"), nil
}

name := shared.StringParam(request, "name")
volumeType := shared.StringParam(request, "volume_type")
az := shared.StringParam(request, "availability_zone")
description := shared.StringParam(request, "description")
snapshotID := shared.StringParam(request, "snapshot_id")
sourceVolID := shared.StringParam(request, "source_volume_id")

if snapshotID != "" {
if errResult := shared.ValidateUUID(snapshotID, "snapshot_id"); errResult != nil {
return errResult, nil
}
}
if sourceVolID != "" {
if errResult := shared.ValidateUUID(sourceVolID, "source_volume_id"); errResult != nil {
return errResult, nil
}
}

nameDisplay := name
if nameDisplay == "" {
nameDisplay = "(unnamed)"
}
typeDisplay := volumeType
if typeDisplay == "" {
typeDisplay = "default"
}
azDisplay := az
if azDisplay == "" {
azDisplay = "default"
}
preview := fmt.Sprintf("Will CREATE volume '%s', %dGiB, type: %s, AZ: %s",
nameDisplay, size, typeDisplay, azDisplay)
if result := shared.RequireConfirmation(request, preview); result != nil {
return result, nil
}

createOpts := volumes.CreateOpts{
Name: name,
Size: size,
VolumeType: volumeType,
AvailabilityZone: az,
Description: description,
SnapshotID: snapshotID,
SourceVolID: sourceVolID,
}

vol, err := volumes.Create(ctx, client, createOpts, nil).Extract()
if err != nil {
return shared.ToolError("failed to create volume: %v", err), nil
}

out, err := json.MarshalIndent(vol, "", " ")
if err != nil {
return shared.ToolError("failed to marshal response: %v", err), nil
}
return shared.ToolResult(string(out)), nil
}
}

func deleteVolumeHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
client, err := provider.BlockStorageClient()
if err != nil {
return shared.ToolError("failed to get block storage client: %v", err), nil
}

volumeID := shared.StringParam(request, "volume_id")
if volumeID == "" {
return shared.ToolError("volume_id is required"), nil
}
if errResult := shared.ValidateUUID(volumeID, "volume_id"); errResult != nil {
return errResult, nil
}

// Fetch volume to check status and build preview
vol, err := volumes.Get(ctx, client, volumeID).Extract()
if err != nil {
return shared.ToolError("failed to get volume %s: %v", volumeID, err), nil
}

if vol.Status == "in-use" {
return shared.ToolError("cannot delete volume %s: currently attached to server(s) (status: in-use). Detach first.", volumeID), nil
}

preview := fmt.Sprintf("Will DELETE volume '%s' (%s), %dGiB, status: %s",
vol.Name, vol.ID, vol.Size, vol.Status)
if result := shared.RequireConfirmation(request, preview); result != nil {
return result, nil
}

err = volumes.Delete(ctx, client, volumeID, nil).ExtractErr()
if err != nil {
return shared.ToolError("failed to delete volume %s: %v", volumeID, err), nil
}

return shared.ToolResult("Successfully deleted volume " + volumeID), nil
}
}
Loading
Loading