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
18 changes: 9 additions & 9 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 — 55 tools across 18 services.
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.

## Quick Start

Expand Down Expand Up @@ -37,24 +37,24 @@ claude mcp add openstack openstack-mcp-server \
### Standard OpenStack
| Service | Tools | Description |
|---------|-------|-------------|
| **Nova** (Compute) | `nova_list_servers`, `nova_get_server`, `nova_list_flavors`, `nova_server_action`* | Servers, flavors, actions |
| **Neutron** (Networking) | `neutron_list_networks`, `neutron_list_subnets`, `neutron_list_ports`, `neutron_list_security_groups` | Networks, subnets, ports, security groups |
| **Cinder** (Block Storage) | `cinder_list_volumes`, `cinder_get_volume` | Volumes |
| **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 |
| **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` | File shares |
| **Octavia** (Load Balancer) | `octavia_list_loadbalancers`, `octavia_get_loadbalancer`, `octavia_list_listeners`, `octavia_list_pools` | Load balancers, listeners, pools |
| **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 |
| **Glance** (Image) | `glance_list_images`, `glance_get_image` | Images |
| **Ironic** (Bare Metal) | `ironic_list_nodes`, `ironic_get_node` | Baremetal nodes |
| **Ironic** (Bare Metal) | `ironic_list_nodes`, `ironic_get_node`, `ironic_list_node_ports` | Baremetal nodes, ports |

### SAP Converged Cloud
| Service | Tools | Description |
|---------|-------|-------------|
| **Hermes** (Audit) | `hermes_list_events`, `hermes_get_event`, `hermes_list_attributes` | CADF audit events |
| **Limes** (Quota/Usage) | `limes_get_project_quota`, `limes_get_domain_quota`, `limes_get_cluster_quota` | Quota and usage reports |
| **Keppel** (Container Registry) | `keppel_list_accounts`, `keppel_list_repositories`, `keppel_list_manifests` | Container image registry |
| **Keppel** (Container Registry) | `keppel_list_accounts`, `keppel_list_repositories`, `keppel_list_manifests`, `keppel_get_vulnerability_report` | Container image registry, vulnerability scanning |
| **Archer** (Endpoint Service) | `archer_list_services`, `archer_get_service`, `archer_list_endpoints`, `archer_get_endpoint` | Private endpoint connectivity |
| **Maia** (Prometheus) | `maia_query`, `maia_query_range`, `maia_label_values`, `maia_metric_names` | PromQL instant and range queries, metrics |
| **Castellum** (Autoscaling) | `castellum_get_project_resources`, `castellum_list_pending_operations`, `castellum_list_recently_failed_operations` | Resource autoscaling |
Expand Down Expand Up @@ -149,7 +149,7 @@ Set `MCP_READ_ONLY=false` only when you explicitly need write operations.

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

- **Read-only tools** (52 tools): Annotated with `readOnlyHint: true`. Clients may auto-approve these.
- **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.

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.
Expand Down
135 changes: 135 additions & 0 deletions internal/tools/cinder/cinder.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (
"context"
"encoding/json"

"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"
"github.com/gophercloud/gophercloud/v2/pagination"
"github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
Expand All @@ -21,6 +23,9 @@ import (
func Register(s *mcpserver.MCPServer, provider *auth.Provider) {
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))
}

var listVolumesTool = mcp.NewTool("cinder_list_volumes",
Expand All @@ -37,6 +42,25 @@ var getVolumeTool = mcp.NewTool("cinder_get_volume",
mcp.WithString("volume_id", mcp.Required(), mcp.Description("The UUID of the volume to retrieve")),
)

var listSnapshotsTool = mcp.NewTool("cinder_list_snapshots",
mcp.WithDescription("List block storage snapshots in the current project. Returns snapshot ID, name, status, volume ID, size, and created_at."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("name", mcp.Description("Filter by snapshot name")),
mcp.WithString("status", mcp.Description("Filter by snapshot status (available, creating, deleting, error)")),
mcp.WithString("volume_id", mcp.Description("Filter by volume ID")),
)

var getSnapshotTool = mcp.NewTool("cinder_get_snapshot",
mcp.WithDescription("Get detailed information about a specific block storage snapshot."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("snapshot_id", mcp.Required(), mcp.Description("The UUID of the snapshot to retrieve")),
)

var listVolumeTypesTool = mcp.NewTool("cinder_list_volume_types",
mcp.WithDescription("List available block storage volume types. Returns type ID, name, description, and extra specs."),
mcp.WithReadOnlyHintAnnotation(true),
)

func listVolumesHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
client, err := provider.BlockStorageClient()
Expand Down Expand Up @@ -110,3 +134,114 @@ func getVolumeHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
return shared.ToolResult(string(out)), nil
}
}

func listSnapshotsHandler(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
}

opts := snapshots.ListOpts{
Name: shared.StringParam(request, "name"),
Status: shared.StringParam(request, "status"),
}
if v := shared.StringParam(request, "volume_id"); v != "" {
if errResult := shared.ValidateUUID(v, "volume_id"); errResult != nil {
return errResult, nil
}
opts.VolumeID = v
}

var result []map[string]any
err = snapshots.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) {
snaps, err := snapshots.ExtractSnapshots(page)
if err != nil {
return false, err
}
for _, s := range snaps {
result = append(result, map[string]any{
"id": s.ID,
"name": s.Name,
"status": s.Status,
"volume_id": s.VolumeID,
"size": s.Size,
"created_at": s.CreatedAt,
})
}
return true, nil
})
if err != nil {
return shared.ToolError("failed to list snapshots: %v", err), nil
}

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

func getSnapshotHandler(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
}

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

snap, err := snapshots.Get(ctx, client, snapshotID).Extract()
if err != nil {
return shared.ToolError("failed to get snapshot %s: %v", snapshotID, err), nil
}

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

func listVolumeTypesHandler(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
}

var result []map[string]any
err = volumetypes.List(client, nil).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) {
vts, err := volumetypes.ExtractVolumeTypes(page)
if err != nil {
return false, err
}
for _, vt := range vts {
result = append(result, map[string]any{
"id": vt.ID,
"name": vt.Name,
"description": vt.Description,
"extra_specs": vt.ExtraSpecs,
})
}
return true, nil
})
if err != nil {
return shared.ToolError("failed to list volume types: %v", err), nil
}

out, err := json.MarshalIndent(result, "", " ")
if err != nil {
return shared.ToolError("failed to marshal response: %v", err), nil
}
return shared.ToolResult(string(out)), nil
}
}
56 changes: 56 additions & 0 deletions internal/tools/ironic/ironic.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"encoding/json"

"github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/nodes"
"github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/ports"
"github.com/gophercloud/gophercloud/v2/pagination"
"github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
Expand All @@ -21,6 +22,7 @@ import (
func Register(s *mcpserver.MCPServer, provider *auth.Provider) {
s.AddTool(listNodesTool, listNodesHandler(provider))
s.AddTool(getNodeTool, getNodeHandler(provider))
s.AddTool(listNodePortsTool, listNodePortsHandler(provider))
}

var listNodesTool = mcp.NewTool("ironic_list_nodes",
Expand Down Expand Up @@ -149,3 +151,57 @@ func getNodeHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
return shared.ToolResult(string(out)), nil
}
}

var listNodePortsTool = mcp.NewTool("ironic_list_node_ports",
mcp.WithDescription("List network ports (NICs) for a baremetal node. Returns port UUID, address (MAC), node UUID, PXE enabled, and physical network."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("node_id", mcp.Required(), mcp.Description("The UUID of the baremetal node")),
)

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

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

opts := ports.ListOpts{
NodeUUID: nodeID,
}

var result []map[string]any
err = ports.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) {
portList, err := ports.ExtractPorts(page)
if err != nil {
return false, err
}
for _, p := range portList {
result = append(result, map[string]any{
"uuid": p.UUID,
"address": p.Address,
"node_uuid": p.NodeUUID,
"pxe_enabled": p.PXEEnabled,
"physical_network": p.PhysicalNetwork,
})
}
return true, nil
})
if err != nil {
return shared.ToolError("failed to list ports for node %s: %v", nodeID, err), nil
}

out, err := json.MarshalIndent(result, "", " ")
if err != nil {
return shared.ToolError("failed to marshal response: %v", err), nil
}
return shared.ToolResult(string(out)), nil
}
}
55 changes: 55 additions & 0 deletions internal/tools/keppel/keppel.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"context"
"encoding/json"
"net/http"
"regexp"

"github.com/gophercloud/gophercloud/v2"
"github.com/mark3labs/mcp-go/mcp"
Expand All @@ -18,11 +19,15 @@ import (
"github.com/notque/openstack-mcp-server/internal/tools/shared"
)

// digestPattern validates that a string is a sha256 digest (sha256:<64 hex chars>).
var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)

// Register adds all Keppel tools to the MCP server.
func Register(s *mcpserver.MCPServer, provider *auth.Provider) {
s.AddTool(listAccountsTool, listAccountsHandler(provider))
s.AddTool(listReposTool, listReposHandler(provider))
s.AddTool(listManifestsTool, listManifestsHandler(provider))
s.AddTool(getVulnerabilityReportTool, getVulnerabilityReportHandler(provider))
}

var listAccountsTool = mcp.NewTool("keppel_list_accounts",
Expand Down Expand Up @@ -144,3 +149,53 @@ func listManifestsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
return shared.ToolResult(string(out)), nil
}
}

var getVulnerabilityReportTool = mcp.NewTool("keppel_get_vulnerability_report",
mcp.WithDescription("Get the vulnerability report for a specific container image manifest. Returns CVE details including severity, package, and fixed version."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("account", mcp.Required(), mcp.Description("The account name")),
mcp.WithString("repository", mcp.Required(), mcp.Description("The repository name within the account")),
mcp.WithString("digest", mcp.Required(), mcp.Description("The manifest digest in sha256:<hash> format")),
)

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

account := shared.StringParam(request, "account")
repo := shared.StringParam(request, "repository")
digest := shared.StringParam(request, "digest")
if account == "" || repo == "" || digest == "" {
return shared.ToolError("account, repository, and digest are required"), nil
}
if errResult := shared.ValidatePathSegment(account, "account"); errResult != nil {
return errResult, nil
}
if errResult := shared.ValidatePathSegment(repo, "repository"); errResult != nil {
return errResult, nil
}
if !digestPattern.MatchString(digest) {
return shared.ToolError("digest must be in sha256:<64 hex chars> format (got: %q)", digest), nil
}

url := client.Endpoint + "keppel/v1/accounts/" + account + "/repositories/" + repo + "/_manifests/" + digest + "/trivy_report"

var body any
//nolint:bodyclose
_, err = client.Get(ctx, url, &body, &gophercloud.RequestOpts{
OkCodes: []int{http.StatusOK},
})
if err != nil {
return shared.ToolError("failed to get vulnerability report for %s/%s@%s: %v", account, repo, digest, err), nil
}

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