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. diff --git a/internal/tools/cinder/cinder.go b/internal/tools/cinder/cinder.go index 6ff264a..154ec14 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,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 + } +} 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..9aad360 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,117 @@ 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, + }) + } + 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"), + 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) + } + + 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, + }) + } + 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..9574f34 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,132 @@ 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 != "" { + if errResult := shared.ValidateUUID(v, "pool_id"); errResult != nil { + return errResult, nil + } + 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 + } +} 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", ""},