From 23f6146b74dd49fe3751bfe3863c1ee640fe8131 Mon Sep 17 00:00:00 2001 From: Nathan Oyler Date: Thu, 7 May 2026 08:46:33 -0700 Subject: [PATCH 1/3] feat: expand read API surface with 10 new tools Add high-priority read operations identified by API coverage audit. Brings the server from 55 to 65 tools across 18 services, completing the debugging workflow surface for AI agents. New tools: - neutron_list_routers: Router topology for connectivity tracing - neutron_list_floating_ips: External access mapping - octavia_list_members: Backend servers in LB pools - octavia_list_healthmonitors: Health check configuration - cinder_list_snapshots: Volume snapshot inventory - cinder_get_snapshot: Snapshot detail by UUID - cinder_list_volume_types: Available storage tiers - nova_list_keypairs: SSH key inventory for access debugging - manila_list_access_rules: Share mount permissions - ironic_list_node_ports: Physical NIC details (MAC, PXE) - keppel_get_vulnerability_report: Per-manifest CVE details All tools are read-only with proper annotations, UUID validation, and response sanitization. --- internal/tools/cinder/cinder.go | 130 ++++++++++++++++++++++++++++++ internal/tools/ironic/ironic.go | 56 +++++++++++++ internal/tools/keppel/keppel.go | 55 +++++++++++++ internal/tools/manila/manila.go | 47 +++++++++++ internal/tools/neutron/neutron.go | 119 +++++++++++++++++++++++++++ internal/tools/nova/nova.go | 42 ++++++++++ internal/tools/octavia/octavia.go | 129 +++++++++++++++++++++++++++++ 7 files changed, 578 insertions(+) diff --git a/internal/tools/cinder/cinder.go b/internal/tools/cinder/cinder.go index 6ff264a..fa140e0 100644 --- a/internal/tools/cinder/cinder.go +++ b/internal/tools/cinder/cinder.go @@ -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" @@ -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", @@ -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() @@ -110,3 +134,109 @@ 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"), + VolumeID: shared.StringParam(request, "volume_id"), + } + + 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 + } +} diff --git a/internal/tools/ironic/ironic.go b/internal/tools/ironic/ironic.go index 8e7e828..74d8172 100644 --- a/internal/tools/ironic/ironic.go +++ b/internal/tools/ironic/ironic.go @@ -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" @@ -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", @@ -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 + } +} diff --git a/internal/tools/keppel/keppel.go b/internal/tools/keppel/keppel.go index 908017f..40f2c28 100644 --- a/internal/tools/keppel/keppel.go +++ b/internal/tools/keppel/keppel.go @@ -9,6 +9,7 @@ import ( "context" "encoding/json" "net/http" + "regexp" "github.com/gophercloud/gophercloud/v2" "github.com/mark3labs/mcp-go/mcp" @@ -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", @@ -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: 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 + } +} diff --git a/internal/tools/manila/manila.go b/internal/tools/manila/manila.go index 7b3fd69..cc49d72 100644 --- a/internal/tools/manila/manila.go +++ b/internal/tools/manila/manila.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" + "github.com/gophercloud/gophercloud/v2/openstack/sharedfilesystems/v2/shareaccessrules" "github.com/gophercloud/gophercloud/v2/openstack/sharedfilesystems/v2/shares" "github.com/gophercloud/gophercloud/v2/pagination" "github.com/mark3labs/mcp-go/mcp" @@ -21,6 +22,7 @@ import ( func Register(s *mcpserver.MCPServer, provider *auth.Provider) { s.AddTool(listSharesTool, listSharesHandler(provider)) s.AddTool(getShareTool, getShareHandler(provider)) + s.AddTool(listAccessRulesTool, listAccessRulesHandler(provider)) } var listSharesTool = mcp.NewTool("manila_list_shares", @@ -119,3 +121,48 @@ func getShareHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolResult(string(out)), nil } } + +var listAccessRulesTool = mcp.NewTool("manila_list_access_rules", + mcp.WithDescription("List access rules for a shared file system share. Returns rule ID, access type, access to, access level, and state."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("share_id", mcp.Required(), mcp.Description("The UUID of the share to list access rules for")), +) + +func listAccessRulesHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.SharedFileSystemClient() + if err != nil { + return shared.ToolError("failed to get shared file system client: %v", err), nil + } + + shareID := shared.StringParam(request, "share_id") + if shareID == "" { + return shared.ToolError("share_id is required"), nil + } + if errResult := shared.ValidateUUID(shareID, "share_id"); errResult != nil { + return errResult, nil + } + + accessList, err := shareaccessrules.List(ctx, client, shareID).Extract() + if err != nil { + return shared.ToolError("failed to list access rules for share %s: %v", shareID, err), nil + } + + var result []map[string]any + for _, rule := range accessList { + result = append(result, map[string]any{ + "id": rule.ID, + "access_type": rule.AccessType, + "access_to": rule.AccessTo, + "access_level": rule.AccessLevel, + "state": rule.State, + }) + } + + out, err := json.MarshalIndent(result, "", " ") + if err != nil { + return shared.ToolError("failed to marshal response: %v", err), nil + } + return shared.ToolResult(string(out)), nil + } +} diff --git a/internal/tools/neutron/neutron.go b/internal/tools/neutron/neutron.go index daa374b..9b42443 100644 --- a/internal/tools/neutron/neutron.go +++ b/internal/tools/neutron/neutron.go @@ -8,6 +8,8 @@ import ( "context" "encoding/json" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/routers" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/security/groups" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/networks" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/ports" @@ -26,6 +28,8 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { s.AddTool(listSubnetsTool, listSubnetsHandler(provider)) s.AddTool(listPortsTool, listPortsHandler(provider)) s.AddTool(listSecGroupsTool, listSecGroupsHandler(provider)) + s.AddTool(listRoutersTool, listRoutersHandler(provider)) + s.AddTool(listFloatingIPsTool, listFloatingIPsHandler(provider)) } var listNetworksTool = mcp.NewTool("neutron_list_networks", @@ -248,3 +252,118 @@ func listSecGroupsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolResult(string(out)), nil } } + +var listRoutersTool = mcp.NewTool("neutron_list_routers", + mcp.WithDescription("List routers in the current project. Returns router ID, name, status, external gateway info, and admin state."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("name", mcp.Description("Filter by router name")), + mcp.WithString("status", mcp.Description("Filter by router status (ACTIVE, DOWN, BUILD, ERROR)")), + mcp.WithNumber("limit", mcp.Description("Maximum number of routers to return")), +) + +func listRoutersHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.NetworkClient() + if err != nil { + return shared.ToolError("failed to get network client: %v", err), nil + } + + opts := routers.ListOpts{ + Name: shared.StringParam(request, "name"), + Status: shared.StringParam(request, "status"), + } + if limit := shared.NumberParam(request, "limit"); limit > 0 { + opts.Limit = int(limit) + } + + var result []map[string]any + err = routers.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + rs, err := routers.ExtractRouters(page) + if err != nil { + return false, err + } + for _, r := range rs { + result = append(result, map[string]any{ + "id": r.ID, + "name": r.Name, + "status": r.Status, + "admin_state_up": r.AdminStateUp, + "external_gateway_info": r.GatewayInfo, + "distributed": r.Distributed, + }) + if len(result) >= 200 { + return false, nil + } + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list routers: %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 + } +} + +var listFloatingIPsTool = mcp.NewTool("neutron_list_floating_ips", + mcp.WithDescription("List floating IPs in the current project. Returns ID, floating IP address, fixed IP, port ID, router ID, and status."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("floating_ip_address", mcp.Description("Filter by floating IP address")), + mcp.WithString("port_id", mcp.Description("Filter by port ID")), + mcp.WithString("status", mcp.Description("Filter by floating IP status (ACTIVE, DOWN, ERROR)")), + mcp.WithNumber("limit", mcp.Description("Maximum number of floating IPs to return")), +) + +func listFloatingIPsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.NetworkClient() + if err != nil { + return shared.ToolError("failed to get network client: %v", err), nil + } + + opts := floatingips.ListOpts{ + FloatingIP: shared.StringParam(request, "floating_ip_address"), + PortID: shared.StringParam(request, "port_id"), + Status: shared.StringParam(request, "status"), + } + if limit := shared.NumberParam(request, "limit"); limit > 0 { + opts.Limit = int(limit) + } + + var result []map[string]any + err = floatingips.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + fips, err := floatingips.ExtractFloatingIPs(page) + if err != nil { + return false, err + } + for _, fip := range fips { + result = append(result, map[string]any{ + "id": fip.ID, + "floating_ip_address": fip.FloatingIP, + "fixed_ip_address": fip.FixedIP, + "port_id": fip.PortID, + "router_id": fip.RouterID, + "status": fip.Status, + "floating_network_id": fip.FloatingNetworkID, + }) + if len(result) >= 200 { + return false, nil + } + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list floating IPs: %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 + } +} diff --git a/internal/tools/nova/nova.go b/internal/tools/nova/nova.go index 4dd7a44..68ecab7 100644 --- a/internal/tools/nova/nova.go +++ b/internal/tools/nova/nova.go @@ -10,6 +10,7 @@ import ( "fmt" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/flavors" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/keypairs" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" "github.com/gophercloud/gophercloud/v2/pagination" "github.com/mark3labs/mcp-go/mcp" @@ -25,6 +26,7 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { s.AddTool(listServersTool, listServersHandler(provider)) s.AddTool(getServerTool, getServerHandler(provider)) s.AddTool(listFlavorsTool, listFlavorsHandler(provider)) + s.AddTool(listKeypairsTool, listKeypairsHandler(provider)) if !readOnly { s.AddTool(serverActionTool, serverActionHandler(provider)) } @@ -55,6 +57,11 @@ var listFlavorsTool = mcp.NewTool("nova_list_flavors", mcp.WithReadOnlyHintAnnotation(true), ) +var listKeypairsTool = mcp.NewTool("nova_list_keypairs", + mcp.WithDescription("List SSH keypairs available in the current project. Returns keypair name, fingerprint, public key, and type."), + mcp.WithReadOnlyHintAnnotation(true), +) + var serverActionTool = mcp.NewTool("nova_server_action", mcp.WithDescription("Perform an action on a compute instance: start, stop, reboot, pause, unpause, suspend, resume."), mcp.WithDestructiveHintAnnotation(true), @@ -211,6 +218,41 @@ func listFlavorsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { } } +func listKeypairsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.ComputeClient() + if err != nil { + return shared.ToolError("failed to get compute client: %v", err), nil + } + + var result []map[string]any + err = keypairs.List(client, nil).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + kps, err := keypairs.ExtractKeyPairs(page) + if err != nil { + return false, err + } + for _, kp := range kps { + result = append(result, map[string]any{ + "name": kp.Name, + "fingerprint": kp.Fingerprint, + "public_key": kp.PublicKey, + "type": kp.Type, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list keypairs: %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 serverActionHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { client, err := provider.ComputeClient() diff --git a/internal/tools/octavia/octavia.go b/internal/tools/octavia/octavia.go index f2454f5..c038667 100644 --- a/internal/tools/octavia/octavia.go +++ b/internal/tools/octavia/octavia.go @@ -10,6 +10,7 @@ import ( "github.com/gophercloud/gophercloud/v2/openstack/loadbalancer/v2/listeners" "github.com/gophercloud/gophercloud/v2/openstack/loadbalancer/v2/loadbalancers" + "github.com/gophercloud/gophercloud/v2/openstack/loadbalancer/v2/monitors" "github.com/gophercloud/gophercloud/v2/openstack/loadbalancer/v2/pools" "github.com/gophercloud/gophercloud/v2/pagination" "github.com/mark3labs/mcp-go/mcp" @@ -25,6 +26,8 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { s.AddTool(getLoadbalancerTool, getLoadbalancerHandler(provider)) s.AddTool(listListenersTool, listListenersHandler(provider)) s.AddTool(listPoolsTool, listPoolsHandler(provider)) + s.AddTool(listMembersTool, listMembersHandler(provider)) + s.AddTool(listHealthmonitorsTool, listHealthmonitorsHandler(provider)) } // --- Load Balancers --- @@ -266,3 +269,129 @@ func listPoolsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolResult(string(out)), nil } } + +// --- Members --- + +var listMembersTool = mcp.NewTool("octavia_list_members", + mcp.WithDescription("List members in a load balancer pool. Returns member ID, name, address, protocol port, weight, and operating status."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("pool_id", mcp.Required(), mcp.Description("The UUID of the pool to list members for")), + mcp.WithString("name", mcp.Description("Filter by member name")), + mcp.WithString("address", mcp.Description("Filter by member address")), +) + +func listMembersHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.LoadBalancerClient() + if err != nil { + return shared.ToolError("failed to get load balancer client: %v", err), nil + } + + poolID := shared.StringParam(request, "pool_id") + if poolID == "" { + return shared.ToolError("pool_id is required"), nil + } + if errResult := shared.ValidateUUID(poolID, "pool_id"); errResult != nil { + return errResult, nil + } + + opts := pools.ListMembersOpts{} + if v := shared.StringParam(request, "name"); v != "" { + opts.Name = v + } + if v := shared.StringParam(request, "address"); v != "" { + opts.Address = v + } + + var result []map[string]any + err = pools.ListMembers(client, poolID, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + allMembers, err := pools.ExtractMembers(page) + if err != nil { + return false, err + } + for _, m := range allMembers { + result = append(result, map[string]any{ + "id": m.ID, + "name": m.Name, + "address": m.Address, + "protocol_port": m.ProtocolPort, + "weight": m.Weight, + "operating_status": m.OperatingStatus, + "admin_state_up": m.AdminStateUp, + "subnet_id": m.SubnetID, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list members for pool %s: %v", poolID, 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 + } +} + +// --- Health Monitors --- + +var listHealthmonitorsTool = mcp.NewTool("octavia_list_healthmonitors", + mcp.WithDescription("List health monitors in the current project. Returns monitor ID, name, type, delay, timeout, max retries, pool ID, and operating status."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("pool_id", mcp.Description("Filter by pool UUID")), + mcp.WithString("type", mcp.Description("Filter by monitor type (HTTP, HTTPS, PING, TCP, TLS-HELLO, UDP-CONNECT)")), +) + +func listHealthmonitorsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.LoadBalancerClient() + if err != nil { + return shared.ToolError("failed to get load balancer client: %v", err), nil + } + + opts := monitors.ListOpts{} + if v := shared.StringParam(request, "pool_id"); v != "" { + opts.PoolID = v + } + if v := shared.StringParam(request, "type"); v != "" { + opts.Type = v + } + + var result []map[string]any + err = monitors.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + allMonitors, err := monitors.ExtractMonitors(page) + if err != nil { + return false, err + } + for _, m := range allMonitors { + poolIDs := make([]string, len(m.Pools)) + for i, p := range m.Pools { + poolIDs[i] = p.ID + } + result = append(result, map[string]any{ + "id": m.ID, + "name": m.Name, + "type": m.Type, + "delay": m.Delay, + "timeout": m.Timeout, + "max_retries": m.MaxRetries, + "pools": poolIDs, + "operating_status": m.OperatingStatus, + "admin_state_up": m.AdminStateUp, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list health monitors: %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 + } +} From ec2a8c002394e2dc85a5572cc195b7de17765565 Mon Sep 17 00:00:00 2001 From: Nathan Oyler Date: Thu, 7 May 2026 08:47:53 -0700 Subject: [PATCH 2/3] docs: update README with 66 tools and new service capabilities --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7bbfcb3..8f9184c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | @@ -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. From 75e71e9349e4f67e5cb5ccb88122e291c8ea7c76 Mon Sep 17 00:00:00 2001 From: Nathan Oyler Date: Thu, 7 May 2026 08:56:28 -0700 Subject: [PATCH 3/3] security: fix path traversal in ValidatePathSegment and add UUID validation - Add explicit strings.Contains(value, "..") check to ValidatePathSegment to block embedded traversal (e.g., "x/../../accounts/victim") that the regex alone did not catch - Add test case for embedded path traversal attack vector - Validate volume_id filter in cinder_list_snapshots - Validate pool_id filter in octavia_list_healthmonitors - Validate port_id filter in neutron_list_floating_ips - Remove inconsistent 200-result hard cap from neutron_list_routers and neutron_list_floating_ips (use limit parameter instead, matching other list handlers) --- internal/tools/cinder/cinder.go | 11 ++++++++--- internal/tools/neutron/neutron.go | 13 ++++++------- internal/tools/octavia/octavia.go | 3 +++ internal/tools/shared/helpers.go | 4 ++++ internal/tools/shared/helpers_test.go | 1 + 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/internal/tools/cinder/cinder.go b/internal/tools/cinder/cinder.go index fa140e0..154ec14 100644 --- a/internal/tools/cinder/cinder.go +++ b/internal/tools/cinder/cinder.go @@ -143,9 +143,14 @@ func listSnapshotsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { } opts := snapshots.ListOpts{ - Name: shared.StringParam(request, "name"), - Status: shared.StringParam(request, "status"), - VolumeID: shared.StringParam(request, "volume_id"), + 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 diff --git a/internal/tools/neutron/neutron.go b/internal/tools/neutron/neutron.go index 9b42443..9aad360 100644 --- a/internal/tools/neutron/neutron.go +++ b/internal/tools/neutron/neutron.go @@ -291,9 +291,6 @@ func listRoutersHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { "external_gateway_info": r.GatewayInfo, "distributed": r.Distributed, }) - if len(result) >= 200 { - return false, nil - } } return true, nil }) @@ -327,9 +324,14 @@ func listFloatingIPsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { opts := floatingips.ListOpts{ FloatingIP: shared.StringParam(request, "floating_ip_address"), - PortID: shared.StringParam(request, "port_id"), Status: shared.StringParam(request, "status"), } + if v := shared.StringParam(request, "port_id"); v != "" { + if errResult := shared.ValidateUUID(v, "port_id"); errResult != nil { + return errResult, nil + } + opts.PortID = v + } if limit := shared.NumberParam(request, "limit"); limit > 0 { opts.Limit = int(limit) } @@ -350,9 +352,6 @@ func listFloatingIPsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { "status": fip.Status, "floating_network_id": fip.FloatingNetworkID, }) - if len(result) >= 200 { - return false, nil - } } return true, nil }) diff --git a/internal/tools/octavia/octavia.go b/internal/tools/octavia/octavia.go index c038667..9574f34 100644 --- a/internal/tools/octavia/octavia.go +++ b/internal/tools/octavia/octavia.go @@ -353,6 +353,9 @@ func listHealthmonitorsHandler(provider *auth.Provider) mcpserver.ToolHandlerFun opts := monitors.ListOpts{} if v := shared.StringParam(request, "pool_id"); v != "" { + if errResult := shared.ValidateUUID(v, "pool_id"); errResult != nil { + return errResult, nil + } opts.PoolID = v } if v := shared.StringParam(request, "type"); v != "" { diff --git a/internal/tools/shared/helpers.go b/internal/tools/shared/helpers.go index 909ed26..87b1db3 100644 --- a/internal/tools/shared/helpers.go +++ b/internal/tools/shared/helpers.go @@ -8,6 +8,7 @@ import ( "fmt" "net/url" "regexp" + "strings" "github.com/mark3labs/mcp-go/mcp" ) @@ -37,6 +38,9 @@ func ValidatePathSegment(value, paramName string) *mcp.CallToolResult { if value == "" { return ToolError("%s is required", paramName) } + if strings.Contains(value, "..") { + return ToolError("%s must not contain path traversal sequences (got: %q)", paramName, value) + } if !safePathSegmentPattern.MatchString(value) { return ToolError("%s contains invalid characters (got: %q)", paramName, value) } diff --git a/internal/tools/shared/helpers_test.go b/internal/tools/shared/helpers_test.go index b868845..fe90d1a 100644 --- a/internal/tools/shared/helpers_test.go +++ b/internal/tools/shared/helpers_test.go @@ -75,6 +75,7 @@ func TestValidatePathSegment_Invalid(t *testing.T) { }{ {"path traversal", "../../../etc/passwd"}, {"double dot prefix", "..secret"}, + {"embedded path traversal", "x/../../accounts/victim"}, {"query string", "account?admin=true"}, {"fragment", "account#fragment"}, {"empty", ""},