Skip to content

Commit c19ea97

Browse files
authored
feat: expand read API surface with 11 new tools and security hardening
Adds 11 new read-only tools (55→66 total) across 7 services: **New tools:** - neutron: list_routers, list_floating_ips - octavia: list_members, list_healthmonitors - nova: list_keypairs - cinder: list_snapshots, get_snapshot, list_volume_types - manila: list_access_rules - ironic: list_node_ports - keppel: get_vulnerability_report **Security hardening (from review):** - Fix path traversal in ValidatePathSegment (embedded ".." sequences) - Add UUID validation for filter parameters (volume_id, pool_id, port_id) - Add test case for embedded path traversal attack vector - Remove inconsistent 200-result hard cap from neutron list handlers
1 parent 554c36e commit c19ea97

10 files changed

Lines changed: 599 additions & 9 deletions

File tree

README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ SPDX-License-Identifier: Apache-2.0
55

66
# openstack-mcp-server
77

8-
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.
8+
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.
99

1010
## Quick Start
1111

@@ -37,24 +37,24 @@ claude mcp add openstack openstack-mcp-server \
3737
### Standard OpenStack
3838
| Service | Tools | Description |
3939
|---------|-------|-------------|
40-
| **Nova** (Compute) | `nova_list_servers`, `nova_get_server`, `nova_list_flavors`, `nova_server_action`* | Servers, flavors, actions |
41-
| **Neutron** (Networking) | `neutron_list_networks`, `neutron_list_subnets`, `neutron_list_ports`, `neutron_list_security_groups` | Networks, subnets, ports, security groups |
42-
| **Cinder** (Block Storage) | `cinder_list_volumes`, `cinder_get_volume` | Volumes |
40+
| **Nova** (Compute) | `nova_list_servers`, `nova_get_server`, `nova_list_flavors`, `nova_list_keypairs`, `nova_server_action`* | Servers, flavors, keypairs, actions |
41+
| **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 |
42+
| **Cinder** (Block Storage) | `cinder_list_volumes`, `cinder_get_volume`, `cinder_list_snapshots`, `cinder_get_snapshot`, `cinder_list_volume_types` | Volumes, snapshots, volume types |
4343
| **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 |
4444
| **Designate** (DNS) | `designate_list_zones`, `designate_get_zone`, `designate_list_recordsets` | DNS zones and records |
4545
| **Barbican** (Key Manager) | `barbican_list_secrets`, `barbican_get_secret` | Secrets metadata (no payloads) |
4646
| **Swift** (Object Storage) | `swift_list_containers`, `swift_list_objects`, `swift_get_object_metadata` | Containers and objects |
47-
| **Manila** (Shared Filesystems) | `manila_list_shares`, `manila_get_share` | File shares |
48-
| **Octavia** (Load Balancer) | `octavia_list_loadbalancers`, `octavia_get_loadbalancer`, `octavia_list_listeners`, `octavia_list_pools` | Load balancers, listeners, pools |
47+
| **Manila** (Shared Filesystems) | `manila_list_shares`, `manila_get_share`, `manila_list_access_rules` | File shares, access rules |
48+
| **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 |
4949
| **Glance** (Image) | `glance_list_images`, `glance_get_image` | Images |
50-
| **Ironic** (Bare Metal) | `ironic_list_nodes`, `ironic_get_node` | Baremetal nodes |
50+
| **Ironic** (Bare Metal) | `ironic_list_nodes`, `ironic_get_node`, `ironic_list_node_ports` | Baremetal nodes, ports |
5151

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

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

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

155155
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.

internal/tools/cinder/cinder.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
88
"context"
99
"encoding/json"
1010

11+
"github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/snapshots"
1112
"github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes"
13+
"github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumetypes"
1214
"github.com/gophercloud/gophercloud/v2/pagination"
1315
"github.com/mark3labs/mcp-go/mcp"
1416
mcpserver "github.com/mark3labs/mcp-go/server"
@@ -21,6 +23,9 @@ import (
2123
func Register(s *mcpserver.MCPServer, provider *auth.Provider) {
2224
s.AddTool(listVolumesTool, listVolumesHandler(provider))
2325
s.AddTool(getVolumeTool, getVolumeHandler(provider))
26+
s.AddTool(listSnapshotsTool, listSnapshotsHandler(provider))
27+
s.AddTool(getSnapshotTool, getSnapshotHandler(provider))
28+
s.AddTool(listVolumeTypesTool, listVolumeTypesHandler(provider))
2429
}
2530

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

45+
var listSnapshotsTool = mcp.NewTool("cinder_list_snapshots",
46+
mcp.WithDescription("List block storage snapshots in the current project. Returns snapshot ID, name, status, volume ID, size, and created_at."),
47+
mcp.WithReadOnlyHintAnnotation(true),
48+
mcp.WithString("name", mcp.Description("Filter by snapshot name")),
49+
mcp.WithString("status", mcp.Description("Filter by snapshot status (available, creating, deleting, error)")),
50+
mcp.WithString("volume_id", mcp.Description("Filter by volume ID")),
51+
)
52+
53+
var getSnapshotTool = mcp.NewTool("cinder_get_snapshot",
54+
mcp.WithDescription("Get detailed information about a specific block storage snapshot."),
55+
mcp.WithReadOnlyHintAnnotation(true),
56+
mcp.WithString("snapshot_id", mcp.Required(), mcp.Description("The UUID of the snapshot to retrieve")),
57+
)
58+
59+
var listVolumeTypesTool = mcp.NewTool("cinder_list_volume_types",
60+
mcp.WithDescription("List available block storage volume types. Returns type ID, name, description, and extra specs."),
61+
mcp.WithReadOnlyHintAnnotation(true),
62+
)
63+
4064
func listVolumesHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
4165
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
4266
client, err := provider.BlockStorageClient()
@@ -110,3 +134,114 @@ func getVolumeHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
110134
return shared.ToolResult(string(out)), nil
111135
}
112136
}
137+
138+
func listSnapshotsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
139+
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
140+
client, err := provider.BlockStorageClient()
141+
if err != nil {
142+
return shared.ToolError("failed to get block storage client: %v", err), nil
143+
}
144+
145+
opts := snapshots.ListOpts{
146+
Name: shared.StringParam(request, "name"),
147+
Status: shared.StringParam(request, "status"),
148+
}
149+
if v := shared.StringParam(request, "volume_id"); v != "" {
150+
if errResult := shared.ValidateUUID(v, "volume_id"); errResult != nil {
151+
return errResult, nil
152+
}
153+
opts.VolumeID = v
154+
}
155+
156+
var result []map[string]any
157+
err = snapshots.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) {
158+
snaps, err := snapshots.ExtractSnapshots(page)
159+
if err != nil {
160+
return false, err
161+
}
162+
for _, s := range snaps {
163+
result = append(result, map[string]any{
164+
"id": s.ID,
165+
"name": s.Name,
166+
"status": s.Status,
167+
"volume_id": s.VolumeID,
168+
"size": s.Size,
169+
"created_at": s.CreatedAt,
170+
})
171+
}
172+
return true, nil
173+
})
174+
if err != nil {
175+
return shared.ToolError("failed to list snapshots: %v", err), nil
176+
}
177+
178+
out, err := json.MarshalIndent(result, "", " ")
179+
if err != nil {
180+
return shared.ToolError("failed to marshal response: %v", err), nil
181+
}
182+
return shared.ToolResult(string(out)), nil
183+
}
184+
}
185+
186+
func getSnapshotHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
187+
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
188+
client, err := provider.BlockStorageClient()
189+
if err != nil {
190+
return shared.ToolError("failed to get block storage client: %v", err), nil
191+
}
192+
193+
snapshotID := shared.StringParam(request, "snapshot_id")
194+
if snapshotID == "" {
195+
return shared.ToolError("snapshot_id is required"), nil
196+
}
197+
if errResult := shared.ValidateUUID(snapshotID, "snapshot_id"); errResult != nil {
198+
return errResult, nil
199+
}
200+
201+
snap, err := snapshots.Get(ctx, client, snapshotID).Extract()
202+
if err != nil {
203+
return shared.ToolError("failed to get snapshot %s: %v", snapshotID, err), nil
204+
}
205+
206+
out, err := json.MarshalIndent(snap, "", " ")
207+
if err != nil {
208+
return shared.ToolError("failed to marshal response: %v", err), nil
209+
}
210+
return shared.ToolResult(string(out)), nil
211+
}
212+
}
213+
214+
func listVolumeTypesHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
215+
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
216+
client, err := provider.BlockStorageClient()
217+
if err != nil {
218+
return shared.ToolError("failed to get block storage client: %v", err), nil
219+
}
220+
221+
var result []map[string]any
222+
err = volumetypes.List(client, nil).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) {
223+
vts, err := volumetypes.ExtractVolumeTypes(page)
224+
if err != nil {
225+
return false, err
226+
}
227+
for _, vt := range vts {
228+
result = append(result, map[string]any{
229+
"id": vt.ID,
230+
"name": vt.Name,
231+
"description": vt.Description,
232+
"extra_specs": vt.ExtraSpecs,
233+
})
234+
}
235+
return true, nil
236+
})
237+
if err != nil {
238+
return shared.ToolError("failed to list volume types: %v", err), nil
239+
}
240+
241+
out, err := json.MarshalIndent(result, "", " ")
242+
if err != nil {
243+
return shared.ToolError("failed to marshal response: %v", err), nil
244+
}
245+
return shared.ToolResult(string(out)), nil
246+
}
247+
}

internal/tools/ironic/ironic.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"encoding/json"
1010

1111
"github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/nodes"
12+
"github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/ports"
1213
"github.com/gophercloud/gophercloud/v2/pagination"
1314
"github.com/mark3labs/mcp-go/mcp"
1415
mcpserver "github.com/mark3labs/mcp-go/server"
@@ -21,6 +22,7 @@ import (
2122
func Register(s *mcpserver.MCPServer, provider *auth.Provider) {
2223
s.AddTool(listNodesTool, listNodesHandler(provider))
2324
s.AddTool(getNodeTool, getNodeHandler(provider))
25+
s.AddTool(listNodePortsTool, listNodePortsHandler(provider))
2426
}
2527

2628
var listNodesTool = mcp.NewTool("ironic_list_nodes",
@@ -149,3 +151,57 @@ func getNodeHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
149151
return shared.ToolResult(string(out)), nil
150152
}
151153
}
154+
155+
var listNodePortsTool = mcp.NewTool("ironic_list_node_ports",
156+
mcp.WithDescription("List network ports (NICs) for a baremetal node. Returns port UUID, address (MAC), node UUID, PXE enabled, and physical network."),
157+
mcp.WithReadOnlyHintAnnotation(true),
158+
mcp.WithString("node_id", mcp.Required(), mcp.Description("The UUID of the baremetal node")),
159+
)
160+
161+
func listNodePortsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
162+
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
163+
client, err := provider.BareMetalClient()
164+
if err != nil {
165+
return shared.ToolError("failed to get baremetal client: %v", err), nil
166+
}
167+
168+
nodeID := shared.StringParam(request, "node_id")
169+
if nodeID == "" {
170+
return shared.ToolError("node_id is required"), nil
171+
}
172+
if errResult := shared.ValidateUUID(nodeID, "node_id"); errResult != nil {
173+
return errResult, nil
174+
}
175+
176+
opts := ports.ListOpts{
177+
NodeUUID: nodeID,
178+
}
179+
180+
var result []map[string]any
181+
err = ports.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) {
182+
portList, err := ports.ExtractPorts(page)
183+
if err != nil {
184+
return false, err
185+
}
186+
for _, p := range portList {
187+
result = append(result, map[string]any{
188+
"uuid": p.UUID,
189+
"address": p.Address,
190+
"node_uuid": p.NodeUUID,
191+
"pxe_enabled": p.PXEEnabled,
192+
"physical_network": p.PhysicalNetwork,
193+
})
194+
}
195+
return true, nil
196+
})
197+
if err != nil {
198+
return shared.ToolError("failed to list ports for node %s: %v", nodeID, err), nil
199+
}
200+
201+
out, err := json.MarshalIndent(result, "", " ")
202+
if err != nil {
203+
return shared.ToolError("failed to marshal response: %v", err), nil
204+
}
205+
return shared.ToolResult(string(out)), nil
206+
}
207+
}

internal/tools/keppel/keppel.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"context"
1010
"encoding/json"
1111
"net/http"
12+
"regexp"
1213

1314
"github.com/gophercloud/gophercloud/v2"
1415
"github.com/mark3labs/mcp-go/mcp"
@@ -18,11 +19,15 @@ import (
1819
"github.com/notque/openstack-mcp-server/internal/tools/shared"
1920
)
2021

22+
// digestPattern validates that a string is a sha256 digest (sha256:<64 hex chars>).
23+
var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
24+
2125
// Register adds all Keppel tools to the MCP server.
2226
func Register(s *mcpserver.MCPServer, provider *auth.Provider) {
2327
s.AddTool(listAccountsTool, listAccountsHandler(provider))
2428
s.AddTool(listReposTool, listReposHandler(provider))
2529
s.AddTool(listManifestsTool, listManifestsHandler(provider))
30+
s.AddTool(getVulnerabilityReportTool, getVulnerabilityReportHandler(provider))
2631
}
2732

2833
var listAccountsTool = mcp.NewTool("keppel_list_accounts",
@@ -144,3 +149,53 @@ func listManifestsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
144149
return shared.ToolResult(string(out)), nil
145150
}
146151
}
152+
153+
var getVulnerabilityReportTool = mcp.NewTool("keppel_get_vulnerability_report",
154+
mcp.WithDescription("Get the vulnerability report for a specific container image manifest. Returns CVE details including severity, package, and fixed version."),
155+
mcp.WithReadOnlyHintAnnotation(true),
156+
mcp.WithString("account", mcp.Required(), mcp.Description("The account name")),
157+
mcp.WithString("repository", mcp.Required(), mcp.Description("The repository name within the account")),
158+
mcp.WithString("digest", mcp.Required(), mcp.Description("The manifest digest in sha256:<hash> format")),
159+
)
160+
161+
func getVulnerabilityReportHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc {
162+
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
163+
client, err := provider.KeppelClient()
164+
if err != nil {
165+
return shared.ToolError("failed to get keppel client: %v", err), nil
166+
}
167+
168+
account := shared.StringParam(request, "account")
169+
repo := shared.StringParam(request, "repository")
170+
digest := shared.StringParam(request, "digest")
171+
if account == "" || repo == "" || digest == "" {
172+
return shared.ToolError("account, repository, and digest are required"), nil
173+
}
174+
if errResult := shared.ValidatePathSegment(account, "account"); errResult != nil {
175+
return errResult, nil
176+
}
177+
if errResult := shared.ValidatePathSegment(repo, "repository"); errResult != nil {
178+
return errResult, nil
179+
}
180+
if !digestPattern.MatchString(digest) {
181+
return shared.ToolError("digest must be in sha256:<64 hex chars> format (got: %q)", digest), nil
182+
}
183+
184+
url := client.Endpoint + "keppel/v1/accounts/" + account + "/repositories/" + repo + "/_manifests/" + digest + "/trivy_report"
185+
186+
var body any
187+
//nolint:bodyclose
188+
_, err = client.Get(ctx, url, &body, &gophercloud.RequestOpts{
189+
OkCodes: []int{http.StatusOK},
190+
})
191+
if err != nil {
192+
return shared.ToolError("failed to get vulnerability report for %s/%s@%s: %v", account, repo, digest, err), nil
193+
}
194+
195+
out, err := json.MarshalIndent(body, "", " ")
196+
if err != nil {
197+
return shared.ToolError("failed to marshal response: %v", err), nil
198+
}
199+
return shared.ToolResult(string(out)), nil
200+
}
201+
}

0 commit comments

Comments
 (0)