diff --git a/README.md b/README.md index cfb3659..ad381ed 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 managing infrastructure — 86 tools across 18 services (72 read + 14 write). +MCP (Model Context Protocol) server for OpenStack and SAP Converged Cloud. Provides AI coding agents with typed, structured tools for managing infrastructure — 119 tools across 18 services (91 read + 16 write + 12 admin). ## Quick Start @@ -37,17 +37,17 @@ claude mcp add openstack openstack-mcp-server \ ### Standard OpenStack | Service | Tools | Description | |---------|-------|-------------| -| **Nova** (Compute) | `nova_list_servers`, `nova_get_server`, `nova_list_flavors`, `nova_list_keypairs`, `nova_list_availability_zones`, `nova_get_quotas`, `nova_server_action`*, `nova_create_server`* | Servers, flavors, keypairs, AZs, quotas, actions | -| **Neutron** (Networking) | `neutron_list_networks`, `neutron_list_subnets`, `neutron_list_ports`, `neutron_list_security_groups`, `neutron_list_routers`, `neutron_list_floating_ips`, `neutron_create_security_group_rule`*, `neutron_delete_security_group_rule`* | Networks, subnets, ports, security groups, routers, floating IPs | -| **Cinder** (Block Storage) | `cinder_list_volumes`, `cinder_get_volume`, `cinder_list_snapshots`, `cinder_get_snapshot`, `cinder_list_volume_types`, `cinder_get_quotas`, `cinder_create_volume`*, `cinder_delete_volume`* | Volumes, snapshots, types, quotas | -| **Keystone** (Identity) | `keystone_list_projects`, `keystone_token_info`, `keystone_list_application_credentials`, `keystone_list_domains`, `keystone_list_users`, `keystone_list_roles`, `keystone_create_application_credential`*, `keystone_delete_application_credential`* | Projects, auth info, domains, users, roles, app credentials | -| **Designate** (DNS) | `designate_list_zones`, `designate_get_zone`, `designate_list_recordsets`, `designate_create_recordset`*, `designate_delete_recordset`* | DNS zones and records | -| **Barbican** (Key Manager) | `barbican_list_secrets`, `barbican_get_secret` | Secrets metadata (no payloads) | -| **Swift** (Object Storage) | `swift_list_containers`, `swift_list_objects`, `swift_get_object_metadata`, `swift_upload_object`*, `swift_delete_object`* | Containers and objects | -| **Manila** (Shared Filesystems) | `manila_list_shares`, `manila_get_share`, `manila_list_access_rules`, `manila_list_share_networks` | File shares, access rules, share networks | -| **Octavia** (Load Balancer) | `octavia_list_loadbalancers`, `octavia_get_loadbalancer`, `octavia_list_listeners`, `octavia_list_pools`, `octavia_list_members`, `octavia_list_healthmonitors`, `octavia_list_l7policies`, `octavia_create_loadbalancer`*, `octavia_delete_loadbalancer`* | Load balancers, listeners, pools, members, health monitors, L7 policies | -| **Glance** (Image) | `glance_list_images`, `glance_get_image` | Images | -| **Ironic** (Bare Metal) | `ironic_list_nodes`, `ironic_get_node`, `ironic_list_node_ports`, `ironic_list_allocations` | Baremetal nodes, ports, allocations | +| **Nova** (Compute) | `nova_list_servers`, `nova_get_server`, `nova_list_flavors`, `nova_list_keypairs`, `nova_list_availability_zones`, `nova_get_quotas`, `nova_list_instance_actions`, `nova_list_server_groups`, `nova_list_volume_attachments`, `nova_server_action`\*, `nova_create_server`\*, `nova_list_hypervisors`†, `nova_get_hypervisor`†, `nova_list_services`†, `nova_list_aggregates`† | Servers, flavors, keypairs, AZs, quotas, actions, hypervisors, aggregates | +| **Neutron** (Networking) | `neutron_list_networks`, `neutron_list_subnets`, `neutron_list_ports`, `neutron_list_security_groups`, `neutron_list_routers`, `neutron_list_floating_ips`, `neutron_list_trunks`, `neutron_list_network_ip_availabilities`, `neutron_list_bgpvpn_interconnections`, `neutron_create_security_group_rule`\*, `neutron_delete_security_group_rule`\*, `neutron_create_floating_ip`\*, `neutron_delete_floating_ip`\*, `neutron_list_agents`† | Networks, subnets, ports, SGs, routers, FIPs, trunks, agents | +| **Cinder** (Block Storage) | `cinder_list_volumes`, `cinder_get_volume`, `cinder_list_snapshots`, `cinder_get_snapshot`, `cinder_list_volume_types`, `cinder_get_quotas`, `cinder_list_backups`, `cinder_list_transfers`, `cinder_create_volume`\*, `cinder_delete_volume`\*, `cinder_list_services`† | Volumes, snapshots, types, quotas, backups, transfers | +| **Keystone** (Identity) | `keystone_list_projects`, `keystone_token_info`, `keystone_list_application_credentials`, `keystone_list_domains`, `keystone_list_users`, `keystone_list_roles`, `keystone_create_application_credential`\*, `keystone_delete_application_credential`\*, `keystone_list_role_assignments`†, `keystone_list_groups`† | Projects, auth, domains, users, roles, app credentials, groups | +| **Designate** (DNS) | `designate_list_zones`, `designate_get_zone`, `designate_list_recordsets`, `designate_list_zone_transfer_requests`, `designate_list_zone_transfer_accepts`, `designate_create_recordset`\*, `designate_delete_recordset`\* | DNS zones, records, zone transfers | +| **Barbican** (Key Manager) | `barbican_list_secrets`, `barbican_get_secret`, `barbican_list_containers`, `barbican_get_container`, `barbican_list_orders` | Secrets, containers, orders (metadata only) | +| **Swift** (Object Storage) | `swift_list_containers`, `swift_list_objects`, `swift_get_object_metadata`, `swift_upload_object`\*, `swift_delete_object`\* | Containers and objects | +| **Manila** (Shared Filesystems) | `manila_list_shares`, `manila_get_share`, `manila_list_access_rules`, `manila_list_share_networks`, `manila_list_snapshots`, `manila_list_security_services`, `manila_list_share_types` | Shares, access rules, networks, snapshots, security services | +| **Octavia** (Load Balancer) | `octavia_list_loadbalancers`, `octavia_get_loadbalancer`, `octavia_list_listeners`, `octavia_list_pools`, `octavia_list_members`, `octavia_list_healthmonitors`, `octavia_list_l7policies`, `octavia_list_l7rules`, `octavia_create_loadbalancer`\*, `octavia_delete_loadbalancer`\*, `octavia_list_amphorae`† | Load balancers, listeners, pools, members, L7, amphorae | +| **Glance** (Image) | `glance_list_images`, `glance_get_image`, `glance_list_image_members`, `glance_list_tasks`† | Images, members, import tasks | +| **Ironic** (Bare Metal) | `ironic_list_nodes`, `ironic_get_node`, `ironic_list_node_ports`, `ironic_list_allocations`, `ironic_list_portgroups`, `ironic_list_chassis`†, `ironic_node_power_state`†\* | Baremetal nodes, ports, allocations, portgroups, chassis | ### SAP Converged Cloud | Service | Tools | Description | @@ -60,7 +60,9 @@ claude mcp add openstack openstack-mcp-server \ | **Castellum** (Autoscaling) | `castellum_get_project_resources`, `castellum_list_pending_operations`, `castellum_list_recently_failed_operations` | Resource autoscaling | | **Cronus** (Email) | `cronus_get_usage`, `cronus_list_templates` | Email service usage and templates | -*\* Mutating tools (14 total) — disabled in read-only mode (default). Set `MCP_READ_ONLY=false` to enable.* +*\* Mutating tools (16 total) — disabled in read-only mode (default). Set `MCP_READ_ONLY=false` to enable.* + +*† Admin tools (12 total) — hidden by default. Set `MCP_ADMIN_TOOLS=true` to enable. Requires cloud admin role.* ## Configuration @@ -123,6 +125,7 @@ Add to your MCP client's configuration file (e.g., `.cursor/mcp.json`): | `OS_APPCRED_SECRET_CMD` | Shell command to retrieve app credential secret | — | | `MCP_TRANSPORT` | Transport: `stdio` or `sse` | `stdio` | | `MCP_READ_ONLY` | Set to `false` to enable mutating tools | `true` | +| `MCP_ADMIN_TOOLS` | Set to `true` to enable admin-only tools (requires cloud admin role) | `false` | | `MCP_DEBUG` | Enable debug logging | `false` | | `SAPCC_*_ENDPOINT` | Override SAP CC service endpoints | — | @@ -133,13 +136,14 @@ Add to your MCP client's configuration file (e.g., `.cursor/mcp.json`): | Layer | Mechanism | Effect | |-------|-----------|--------| | **1. Read-Only Mode** | `MCP_READ_ONLY=true` (default) | Mutating tools are not registered — invisible to the LLM | -| **2. Confirmed Pattern** | Two-call execution | First call returns a preview of what will happen; second call with `confirmed=true` executes | -| **3. Semantic Guardrails** | Domain-specific validation | Rejects dangerous operations outright (e.g., 0.0.0.0/0 on SSH, deleting in-use volumes) | -| **4. Credential Isolation** | Secrets held in server memory only | Auth tokens and passwords never reach the LLM | +| **2. Admin Gating** | `MCP_ADMIN_TOOLS=false` (default) | Admin tools are not registered unless explicitly enabled | +| **3. Confirmed Pattern** | Two-call execution | First call returns a preview of what will happen; second call with `confirmed=true` executes | +| **4. Semantic Guardrails** | Domain-specific validation | Rejects dangerous operations outright (e.g., 0.0.0.0/0 on SSH, deleting in-use volumes) | +| **5. Credential Isolation** | Secrets held in server memory only | Auth tokens and passwords never reach the LLM | ### Write Safety: The Confirmed Pattern -All 14 write tools implement a two-call safety pattern: +All 16 write tools implement a two-call safety pattern: ``` 1st call (confirmed absent/false): @@ -165,22 +169,38 @@ Write tools include domain-specific safety rules that reject dangerous operation ### Read-Only Mode (Default) -By default, all 14 mutating tools are **disabled** (`MCP_READ_ONLY=true`). Set `MCP_READ_ONLY=false` only when you explicitly need write operations. The write tools are: +By default, all 16 mutating tools are **disabled** (`MCP_READ_ONLY=true`). Set `MCP_READ_ONLY=false` only when you explicitly need write operations. The write tools are: - `nova_server_action`, `nova_create_server` -- `neutron_create_security_group_rule`, `neutron_delete_security_group_rule` +- `neutron_create_security_group_rule`, `neutron_delete_security_group_rule`, `neutron_create_floating_ip`, `neutron_delete_floating_ip` - `cinder_create_volume`, `cinder_delete_volume` - `designate_create_recordset`, `designate_delete_recordset` - `swift_upload_object`, `swift_delete_object` - `octavia_create_loadbalancer`, `octavia_delete_loadbalancer` - `keystone_create_application_credential`, `keystone_delete_application_credential` +Additionally, `ironic_node_power_state` requires **both** `MCP_READ_ONLY=false` and `MCP_ADMIN_TOOLS=true`. + +### Admin Tools + +Admin tools are hidden by default and require `MCP_ADMIN_TOOLS=true` to enable. These tools require cloud admin privileges and provide infrastructure-wide visibility: + +| Service | Admin Tools | +|---------|------------| +| **Nova** | `nova_list_hypervisors`, `nova_get_hypervisor`, `nova_list_services`, `nova_list_aggregates` | +| **Neutron** | `neutron_list_agents` | +| **Cinder** | `cinder_list_services` | +| **Octavia** | `octavia_list_amphorae` | +| **Glance** | `glance_list_tasks` | +| **Ironic** | `ironic_list_chassis`, `ironic_node_power_state`\* | +| **Keystone** | `keystone_list_role_assignments`, `keystone_list_groups` | + ### Tool Annotations (Human-in-the-Loop) All tools declare their intent via [MCP tool annotations](https://modelcontextprotocol.io/specification/2025-03-26/server/tools#annotations): -- **Read-only tools** (72 tools): Annotated with `readOnlyHint: true`. Clients may auto-approve these. -- **Destructive tools** (14 tools): Annotated with `destructiveHint: true`. Clients **must prompt the user** before execution. +- **Read-only tools** (91 tools): Annotated with `readOnlyHint: true`. Clients may auto-approve these. +- **Destructive tools** (16 tools + 1 admin write): 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. The server declares intent, the client enforces the gate. diff --git a/go.mod b/go.mod index 74dc072..bf3e490 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/gophercloud/utils/v2 v2.0.0-20260424064311-2eeed4ceb3e9 github.com/mark3labs/mcp-go v0.52.0 github.com/sapcc/go-bits v0.0.0-20260504092817-9df533508379 + github.com/sapcc/gophercloud-sapcc/v2 v2.0.5 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index f6d971e..4ed0825 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sapcc/go-bits v0.0.0-20260504092817-9df533508379 h1:NJ1gqb2S4CNrdsCk90iM3TTG9QXKrrgb/tG3sxuzxfw= github.com/sapcc/go-bits v0.0.0-20260504092817-9df533508379/go.mod h1:DxXen6GflUaHbQ9qQFwxJp4xQWzRKm3mzUgGrXbwn84= +github.com/sapcc/gophercloud-sapcc/v2 v2.0.5 h1:pWVIM0qP3YYAoZNx8jtfX+8nBSJ03ETDyrrdBmI/b6I= +github.com/sapcc/gophercloud-sapcc/v2 v2.0.5/go.mod h1:ylQgPjcEWI6Kw7GvG5e8iEfIP+p1oiFQgrAImGIGvac= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= diff --git a/internal/config/config.go b/internal/config/config.go index 98f0713..1f69e15 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,6 +37,11 @@ type Config struct { // Enabled by default for safety. Set MCP_READ_ONLY=false to allow mutations. ReadOnly bool `yaml:"read_only"` + // AdminTools enables admin-only tools (hypervisors, services, agents, etc.). + // Disabled by default. Set MCP_ADMIN_TOOLS=true to expose admin tools. + // These tools require OpenStack admin role to function. + AdminTools bool `yaml:"admin_tools"` + // SAPCC holds SAP Converged Cloud-specific configuration. SAPCC SAPCCConfig `yaml:"sapcc"` } @@ -91,6 +96,11 @@ func Load() (*Config, error) { cfg.ReadOnly = false } + // MCP_ADMIN_TOOLS defaults false; explicitly set to "true" to enable admin tools. + if os.Getenv("MCP_ADMIN_TOOLS") == "true" { + cfg.AdminTools = true + } + // SAP CC endpoint overrides cfg.SAPCC.KeppelEndpoint = osext.GetenvOrDefault("SAPCC_KEPPEL_ENDPOINT", cfg.SAPCC.KeppelEndpoint) cfg.SAPCC.ArcherEndpoint = osext.GetenvOrDefault("SAPCC_ARCHER_ENDPOINT", cfg.SAPCC.ArcherEndpoint) diff --git a/internal/server/server.go b/internal/server/server.go index 671bff8..2883248 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -76,18 +76,20 @@ func (s *Server) Run() error { // registerTools registers all OpenStack service tools with the MCP server. func (s *Server) registerTools() { readOnly := s.cfg.ReadOnly + admin := s.cfg.AdminTools // Standard OpenStack services - nova.Register(s.mcp, s.provider, readOnly) - neutron.Register(s.mcp, s.provider, readOnly) - cinder.Register(s.mcp, s.provider, readOnly) - keystone.Register(s.mcp, s.provider, readOnly) - designate.Register(s.mcp, s.provider, readOnly) - barbican.Register(s.mcp, s.provider) - swift.Register(s.mcp, s.provider, readOnly) - manila.Register(s.mcp, s.provider) - octavia.Register(s.mcp, s.provider, readOnly) - glance.Register(s.mcp, s.provider) + nova.Register(s.mcp, s.provider, readOnly, admin) + neutron.Register(s.mcp, s.provider, readOnly, admin) + cinder.Register(s.mcp, s.provider, readOnly, admin) + keystone.Register(s.mcp, s.provider, readOnly, admin) + designate.Register(s.mcp, s.provider, readOnly, admin) + barbican.Register(s.mcp, s.provider, admin) + swift.Register(s.mcp, s.provider, readOnly, admin) + manila.Register(s.mcp, s.provider, admin) + octavia.Register(s.mcp, s.provider, readOnly, admin) + glance.Register(s.mcp, s.provider, admin) + ironic.Register(s.mcp, s.provider, readOnly, admin) // SAP CC-specific services hermes.Register(s.mcp, s.provider) @@ -97,5 +99,4 @@ func (s *Server) registerTools() { maia.Register(s.mcp, s.provider) castellum.Register(s.mcp, s.provider) cronus.Register(s.mcp, s.provider) - ironic.Register(s.mcp, s.provider) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 9fbe3d7..3fbeb57 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -37,16 +37,16 @@ func TestAllModulesRegisterWithoutPanic(t *testing.T) { // Each Register call captures the provider pointer in closures. // Passing nil is safe because we never invoke the handlers. - nova.Register(s, nil, false) - neutron.Register(s, nil, false) - cinder.Register(s, nil, false) - keystone.Register(s, nil, false) - designate.Register(s, nil, false) - barbican.Register(s, nil) - swift.Register(s, nil, false) - manila.Register(s, nil) - octavia.Register(s, nil, false) - glance.Register(s, nil) + nova.Register(s, nil, false, false) + neutron.Register(s, nil, false, false) + cinder.Register(s, nil, false, false) + keystone.Register(s, nil, false, false) + designate.Register(s, nil, false, false) + barbican.Register(s, nil, false) + swift.Register(s, nil, false, false) + manila.Register(s, nil, false) + octavia.Register(s, nil, false, false) + glance.Register(s, nil, false) hermes.Register(s, nil) limes.Register(s, nil) keppel.Register(s, nil) @@ -54,8 +54,37 @@ func TestAllModulesRegisterWithoutPanic(t *testing.T) { maia.Register(s, nil) castellum.Register(s, nil) cronus.Register(s, nil) - ironic.Register(s, nil) + ironic.Register(s, nil, false, false) // If we reach here, all 18 modules registered without panic. t.Logf("All 18 service modules registered successfully") } + +// TestAllModulesRegisterWithAdmin exercises the admin=true and readOnly=false +// registration paths. This ensures admin tool definitions (hypervisors, agents, +// chassis, role_assignments, etc.) and admin write tools (node_power_state) are +// well-formed and do not panic during registration. +func TestAllModulesRegisterWithAdmin(t *testing.T) { + s := mcpserver.NewMCPServer("test", "0.0.1", mcpserver.WithToolCapabilities(true)) + + nova.Register(s, nil, false, true) + neutron.Register(s, nil, false, true) + cinder.Register(s, nil, false, true) + keystone.Register(s, nil, false, true) + designate.Register(s, nil, false, true) + barbican.Register(s, nil, true) + swift.Register(s, nil, false, true) + manila.Register(s, nil, true) + octavia.Register(s, nil, false, true) + glance.Register(s, nil, true) + hermes.Register(s, nil) + limes.Register(s, nil) + keppel.Register(s, nil) + archer.Register(s, nil) + maia.Register(s, nil) + castellum.Register(s, nil) + cronus.Register(s, nil) + ironic.Register(s, nil, false, true) + + t.Logf("All 18 service modules registered with admin=true successfully") +} diff --git a/internal/tools/barbican/barbican.go b/internal/tools/barbican/barbican.go index 1cba219..2e5b9fc 100644 --- a/internal/tools/barbican/barbican.go +++ b/internal/tools/barbican/barbican.go @@ -8,6 +8,8 @@ import ( "context" "encoding/json" + "github.com/gophercloud/gophercloud/v2/openstack/keymanager/v1/containers" + "github.com/gophercloud/gophercloud/v2/openstack/keymanager/v1/orders" "github.com/gophercloud/gophercloud/v2/openstack/keymanager/v1/secrets" "github.com/gophercloud/gophercloud/v2/pagination" "github.com/mark3labs/mcp-go/mcp" @@ -18,9 +20,13 @@ import ( ) // Register adds all Barbican tools to the MCP server. -func Register(s *mcpserver.MCPServer, provider *auth.Provider) { +// The admin parameter is accepted for interface consistency but currently unused. +func Register(s *mcpserver.MCPServer, provider *auth.Provider, _ bool) { s.AddTool(listSecretsTool, listSecretsHandler(provider)) s.AddTool(getSecretTool, getSecretHandler(provider)) + s.AddTool(listContainersTool, listContainersHandler(provider)) + s.AddTool(getContainerTool, getContainerHandler(provider)) + s.AddTool(listOrdersTool, listOrdersHandler(provider)) } var listSecretsTool = mcp.NewTool("barbican_list_secrets", @@ -121,3 +127,149 @@ func getSecretHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolResult(string(out)), nil } } + +// --- Containers --- + +var listContainersTool = mcp.NewTool("barbican_list_containers", + mcp.WithDescription("List secret containers (certificate bundles, RSA key pairs, etc.). Returns container_ref, name, type, status, secret_refs, created, and updated."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("name", mcp.Description("Filter by container name")), + mcp.WithString("type", mcp.Description("Filter by container type (generic, rsa, certificate)")), +) + +func listContainersHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.KeyManagerClient() + if err != nil { + return shared.ToolError("failed to get key manager client: %v", err), nil + } + + opts := containers.ListOpts{ + Name: shared.StringParam(request, "name"), + } + // NOTE: Type filtering is client-side; gophercloud containers.ListOpts does not support it. + typeFilter := shared.StringParam(request, "type") + + result := make([]map[string]any, 0) + err = containers.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + containerList, err := containers.ExtractContainers(page) + if err != nil { + return false, err + } + for _, c := range containerList { + if typeFilter != "" && c.Type != typeFilter { + continue + } + result = append(result, map[string]any{ + "container_ref": c.ContainerRef, + "name": c.Name, + "type": c.Type, + "status": c.Status, + "secret_refs": c.SecretRefs, + "created": c.Created, + "updated": c.Updated, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list containers: %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 getContainerTool = mcp.NewTool("barbican_get_container", + mcp.WithDescription("Get details of a specific secret container. Returns metadata only (container_ref, name, type, status, secret_refs, consumers, created, updated). Secret payloads are never exposed."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("container_id", mcp.Required(), mcp.Description("The UUID of the container to retrieve")), +) + +func getContainerHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.KeyManagerClient() + if err != nil { + return shared.ToolError("failed to get key manager client: %v", err), nil + } + + containerID := shared.StringParam(request, "container_id") + if containerID == "" { + return shared.ToolError("container_id is required"), nil + } + if errResult := shared.ValidateUUID(containerID, "container_id"); errResult != nil { + return errResult, nil + } + + container, err := containers.Get(ctx, client, containerID).Extract() + if err != nil { + return shared.ToolError("failed to get container %s: %v", containerID, err), nil + } + + // SECURITY: Only expose metadata fields — never the actual secrets. + metadata := map[string]any{ + "container_ref": container.ContainerRef, + "name": container.Name, + "type": container.Type, + "status": container.Status, + "secret_refs": container.SecretRefs, + "consumers": container.Consumers, + "created": container.Created, + "updated": container.Updated, + } + + out, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return shared.ToolError("failed to marshal response: %v", err), nil + } + return shared.ToolResult(string(out)), nil + } +} + +// --- Orders --- + +var listOrdersTool = mcp.NewTool("barbican_list_orders", + mcp.WithDescription("List secret generation orders. Returns order_ref, status, type, secret_ref, created, and updated."), + mcp.WithReadOnlyHintAnnotation(true), +) + +func listOrdersHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.KeyManagerClient() + if err != nil { + return shared.ToolError("failed to get key manager client: %v", err), nil + } + + result := make([]map[string]any, 0) + err = orders.List(client, nil).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + orderList, err := orders.ExtractOrders(page) + if err != nil { + return false, err + } + for _, o := range orderList { + result = append(result, map[string]any{ + "order_ref": o.OrderRef, + "status": o.Status, + "type": o.Type, + "secret_ref": o.SecretRef, + "created": o.Created, + "updated": o.Updated, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list orders: %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/cinder/cinder.go b/internal/tools/cinder/cinder.go index faee7d1..75dd90d 100644 --- a/internal/tools/cinder/cinder.go +++ b/internal/tools/cinder/cinder.go @@ -9,8 +9,11 @@ import ( "encoding/json" "fmt" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/backups" "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/quotasets" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/services" "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/snapshots" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/transfers" "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes" "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumetypes" "github.com/gophercloud/gophercloud/v2/pagination" @@ -22,13 +25,21 @@ import ( ) // Register adds all Cinder tools to the MCP server. -func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { +// When readOnly is true, mutating tools are not registered. +// When admin is true, admin-only tools (services) are registered. +func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly, admin bool) { s.AddTool(listVolumesTool, listVolumesHandler(provider)) s.AddTool(getVolumeTool, getVolumeHandler(provider)) s.AddTool(listSnapshotsTool, listSnapshotsHandler(provider)) s.AddTool(getSnapshotTool, getSnapshotHandler(provider)) s.AddTool(listVolumeTypesTool, listVolumeTypesHandler(provider)) s.AddTool(getQuotasTool, getQuotasHandler(provider)) + s.AddTool(listBackupsTool, listBackupsHandler(provider)) + s.AddTool(listTransfersTool, listTransfersHandler(provider)) + + if admin { + s.AddTool(listServicesTool, listServicesHandler(provider)) + } if !readOnly { s.AddTool(createVolumeTool, createVolumeHandler(provider)) @@ -290,6 +301,158 @@ func getQuotasHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { } } +// --- Backups --- + +var listBackupsTool = mcp.NewTool("cinder_list_backups", + mcp.WithDescription("List volume backups in the current project. Returns backup ID, name, status, volume ID, size, availability zone, and created_at."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("volume_id", mcp.Description("Filter by volume UUID")), + mcp.WithString("status", mcp.Description("Filter by backup status")), + mcp.WithString("name", mcp.Description("Filter by backup name")), +) + +func listBackupsHandler(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 := backups.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 + } + + result := make([]map[string]any, 0) + err = backups.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + allBackups, err := backups.ExtractBackups(page) + if err != nil { + return false, err + } + for _, b := range allBackups { + result = append(result, map[string]any{ + "id": b.ID, + "name": b.Name, + "status": b.Status, + "volume_id": b.VolumeID, + "size": b.Size, + "availability_zone": b.AvailabilityZone, + "created_at": b.CreatedAt, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list backups: %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 + } +} + +// --- Transfers --- + +var listTransfersTool = mcp.NewTool("cinder_list_transfers", + mcp.WithDescription("List volume transfer requests in the current project. Returns transfer ID, name, volume ID, and created_at."), + mcp.WithReadOnlyHintAnnotation(true), +) + +func listTransfersHandler(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 + } + + result := make([]map[string]any, 0) + err = transfers.List(client, nil).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + allTransfers, err := transfers.ExtractTransfers(page) + if err != nil { + return false, err + } + for _, t := range allTransfers { + result = append(result, map[string]any{ + "id": t.ID, + "name": t.Name, + "volume_id": t.VolumeID, + "created_at": t.CreatedAt, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list transfers: %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 + } +} + +// --- Admin tools --- + +var listServicesTool = mcp.NewTool("cinder_list_services", + mcp.WithDescription("[Admin] List block storage services. Requires admin role."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("binary", mcp.Description("Filter by service binary name (e.g., 'cinder-volume')")), + mcp.WithString("host", mcp.Description("Filter by host name")), +) + +func listServicesHandler(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 := services.ListOpts{ + Binary: shared.StringParam(request, "binary"), + Host: shared.StringParam(request, "host"), + } + + result := make([]map[string]any, 0) + err = services.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + allServices, err := services.ExtractServices(page) + if err != nil { + return false, err + } + for _, svc := range allServices { + result = append(result, map[string]any{ + "binary": svc.Binary, + "host": svc.Host, + "zone": svc.Zone, + "status": svc.Status, + "state": svc.State, + "updated_at": svc.UpdatedAt, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list services: %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 + } +} + // --- Write tools --- var createVolumeTool = mcp.NewTool("cinder_create_volume", diff --git a/internal/tools/designate/designate.go b/internal/tools/designate/designate.go index cee9021..6f7c11a 100644 --- a/internal/tools/designate/designate.go +++ b/internal/tools/designate/designate.go @@ -12,6 +12,8 @@ import ( "strings" "github.com/gophercloud/gophercloud/v2/openstack/dns/v2/recordsets" + transferaccept "github.com/gophercloud/gophercloud/v2/openstack/dns/v2/transfer/accept" + transferrequest "github.com/gophercloud/gophercloud/v2/openstack/dns/v2/transfer/request" "github.com/gophercloud/gophercloud/v2/openstack/dns/v2/zones" "github.com/gophercloud/gophercloud/v2/pagination" "github.com/mark3labs/mcp-go/mcp" @@ -22,10 +24,13 @@ import ( ) // Register adds all Designate tools to the MCP server. -func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { +// When readOnly is true, mutating tools are not registered. +func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly, _ bool) { s.AddTool(listZonesTool, listZonesHandler(provider)) s.AddTool(getZoneTool, getZoneHandler(provider)) s.AddTool(listRecordsetsTool, listRecordsetsHandler(provider)) + s.AddTool(listZoneTransferRequestsTool, listZoneTransferRequestsHandler(provider)) + s.AddTool(listZoneTransferAcceptsTool, listZoneTransferAcceptsHandler(provider)) if !readOnly { s.AddTool(createRecordsetTool, createRecordsetHandler(provider)) @@ -182,6 +187,112 @@ func listRecordsetsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { } } +// --- Zone Transfer Requests --- + +var listZoneTransferRequestsTool = mcp.NewTool("designate_list_zone_transfer_requests", + mcp.WithDescription("List outgoing zone transfer requests. Returns transfer request ID, zone ID, zone name, target project ID, status, key, and created_at."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("zone_id", mcp.Description("Filter by zone UUID")), + mcp.WithString("status", mcp.Description("Filter by transfer request status")), +) + +func listZoneTransferRequestsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.DNSClient() + if err != nil { + return shared.ToolError("failed to get DNS client: %v", err), nil + } + + opts := transferrequest.ListOpts{ + Status: shared.StringParam(request, "status"), + } + zoneIDFilter := shared.StringParam(request, "zone_id") + if zoneIDFilter != "" { + if errResult := shared.ValidateUUID(zoneIDFilter, "zone_id"); errResult != nil { + return errResult, nil + } + } + + result := make([]map[string]any, 0) + err = transferrequest.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + allRequests, err := transferrequest.ExtractTransferRequests(page) + if err != nil { + return false, err + } + for _, tr := range allRequests { + if zoneIDFilter != "" && tr.ZoneID != zoneIDFilter { + continue + } + // SECURITY: Do NOT include tr.Key — it is an out-of-band shared + // secret required to accept transfers. Exposing it to the LLM + // violates credential isolation. + result = append(result, map[string]any{ + "id": tr.ID, + "zone_id": tr.ZoneID, + "zone_name": tr.ZoneName, + "target_project_id": tr.TargetProjectID, + "status": tr.Status, + "created_at": tr.CreatedAt, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list zone transfer requests: %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 + } +} + +// --- Zone Transfer Accepts --- + +var listZoneTransferAcceptsTool = mcp.NewTool("designate_list_zone_transfer_accepts", + mcp.WithDescription("List accepted zone transfers. Returns transfer accept ID, zone ID, zone transfer request ID, project ID, status, and created_at."), + mcp.WithReadOnlyHintAnnotation(true), +) + +func listZoneTransferAcceptsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.DNSClient() + if err != nil { + return shared.ToolError("failed to get DNS client: %v", err), nil + } + + result := make([]map[string]any, 0) + err = transferaccept.List(client, nil).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + allAccepts, err := transferaccept.ExtractTransferAccepts(page) + if err != nil { + return false, err + } + for _, ta := range allAccepts { + result = append(result, map[string]any{ + "id": ta.ID, + "zone_id": ta.ZoneID, + "zone_transfer_request_id": ta.ZoneTransferRequestID, + "project_id": ta.ProjectID, + "status": ta.Status, + "created_at": ta.CreatedAt, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list zone transfer accepts: %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 + } +} + // --- Write tools --- var createRecordsetTool = mcp.NewTool("designate_create_recordset", diff --git a/internal/tools/glance/glance.go b/internal/tools/glance/glance.go index 3632519..f05135f 100644 --- a/internal/tools/glance/glance.go +++ b/internal/tools/glance/glance.go @@ -9,6 +9,8 @@ import ( "encoding/json" "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/members" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/tasks" "github.com/gophercloud/gophercloud/v2/pagination" "github.com/mark3labs/mcp-go/mcp" mcpserver "github.com/mark3labs/mcp-go/server" @@ -18,9 +20,14 @@ import ( ) // Register adds all Glance tools to the MCP server. -func Register(s *mcpserver.MCPServer, provider *auth.Provider) { +// When admin is true, admin-only tools (tasks) are registered. +func Register(s *mcpserver.MCPServer, provider *auth.Provider, admin bool) { s.AddTool(listImagesTool, listImagesHandler(provider)) s.AddTool(getImageTool, getImageHandler(provider)) + s.AddTool(listImageMembersTool, listImageMembersHandler(provider)) + if admin { + s.AddTool(listTasksTool, listTasksHandler(provider)) + } } var listImagesTool = mcp.NewTool("glance_list_images", @@ -120,3 +127,109 @@ func getImageHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolResult(string(out)), nil } } + +// --- Image Members --- + +var listImageMembersTool = mcp.NewTool("glance_list_image_members", + mcp.WithDescription("List members an image is shared with. Returns member ID, image ID, status, created_at, and updated_at."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("image_id", mcp.Required(), mcp.Description("The UUID of the image to list members for")), +) + +func listImageMembersHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.ImageClient() + if err != nil { + return shared.ToolError("failed to get image client: %v", err), nil + } + + imageID := shared.StringParam(request, "image_id") + if imageID == "" { + return shared.ToolError("image_id is required"), nil + } + if errResult := shared.ValidateUUID(imageID, "image_id"); errResult != nil { + return errResult, nil + } + + result := make([]map[string]any, 0) + err = members.List(client, imageID).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + memberList, err := members.ExtractMembers(page) + if err != nil { + return false, err + } + for _, m := range memberList { + result = append(result, map[string]any{ + "member_id": m.MemberID, + "image_id": m.ImageID, + "status": m.Status, + "created_at": m.CreatedAt, + "updated_at": m.UpdatedAt, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list image members for %s: %v", imageID, 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 + } +} + +// --- Tasks (Admin) --- + +var listTasksTool = mcp.NewTool("glance_list_tasks", + mcp.WithDescription("[Admin] List image import tasks. Requires admin role."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("status", mcp.Description("Filter by task status (pending, processing, success, failure)")), + mcp.WithString("type", mcp.Description("Filter by task type")), +) + +func listTasksHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.ImageClient() + if err != nil { + return shared.ToolError("failed to get image client: %v", err), nil + } + + opts := tasks.ListOpts{} + if v := shared.StringParam(request, "status"); v != "" { + opts.Status = tasks.TaskStatus(v) + } + if v := shared.StringParam(request, "type"); v != "" { + opts.Type = v + } + + result := make([]map[string]any, 0) + err = tasks.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + taskList, err := tasks.ExtractTasks(page) + if err != nil { + return false, err + } + for _, t := range taskList { + result = append(result, map[string]any{ + "id": t.ID, + "type": t.Type, + "status": t.Status, + "owner": t.Owner, + "created_at": t.CreatedAt, + "updated_at": t.UpdatedAt, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list tasks: %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 d19bb6c..1805876 100644 --- a/internal/tools/ironic/ironic.go +++ b/internal/tools/ironic/ironic.go @@ -7,7 +7,9 @@ package ironic import ( "context" "encoding/json" + "fmt" + "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/allocations" "github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/nodes" "github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/ports" @@ -20,11 +22,20 @@ import ( ) // Register adds all Ironic tools to the MCP server. -func Register(s *mcpserver.MCPServer, provider *auth.Provider) { +// When readOnly is true, mutating tools are not registered. +// When admin is true, admin-only tools (chassis, power state) are registered. +func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly, admin bool) { s.AddTool(listNodesTool, listNodesHandler(provider)) s.AddTool(getNodeTool, getNodeHandler(provider)) s.AddTool(listNodePortsTool, listNodePortsHandler(provider)) s.AddTool(listAllocationsTool, listAllocationsHandler(provider)) + s.AddTool(listPortgroupsTool, listPortgroupsHandler(provider)) + if admin { + s.AddTool(listChassisTool, listChassisHandler(provider)) + if !readOnly { + s.AddTool(nodeChangePowerStateTool, nodeChangePowerStateHandler(provider)) + } + } } var listNodesTool = mcp.NewTool("ironic_list_nodes", @@ -263,3 +274,171 @@ func listAllocationsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolResult(string(out)), nil } } + +// --- Portgroups --- +// NOTE: Uses raw client.Get because gophercloud v2 does not provide a portgroups package. + +var listPortgroupsTool = mcp.NewTool("ironic_list_portgroups", + mcp.WithDescription("List port groups for baremetal nodes. Returns UUID, name, MAC address, node UUID, and bonding mode."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("node_id", mcp.Description("Filter by node UUID")), +) + +func listPortgroupsHandler(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 + } + + url := client.ServiceURL("portgroups") + if v := shared.StringParam(request, "node_id"); v != "" { + if errResult := shared.ValidateUUID(v, "node_id"); errResult != nil { + return errResult, nil + } + url = client.ServiceURL("portgroups") + shared.SafeQueryParams(map[string]string{"node": v}) + } + + var response struct { + Portgroups []struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Address string `json:"address"` + NodeUUID string `json:"node_uuid"` + Mode string `json:"mode"` + } `json:"portgroups"` + } + + _, err = client.Get(ctx, url, &response, &gophercloud.RequestOpts{ + OkCodes: []int{200}, + }) + if err != nil { + return shared.ToolError("failed to list portgroups: %v", err), nil + } + + result := make([]map[string]any, 0, len(response.Portgroups)) + for _, pg := range response.Portgroups { + result = append(result, map[string]any{ + "uuid": pg.UUID, + "name": pg.Name, + "address": pg.Address, + "node_uuid": pg.NodeUUID, + "mode": pg.Mode, + }) + } + + out, err := json.MarshalIndent(result, "", " ") + if err != nil { + return shared.ToolError("failed to marshal response: %v", err), nil + } + return shared.ToolResult(string(out)), nil + } +} + +// --- Chassis (Admin) --- +// NOTE: Uses raw client.Get because gophercloud v2 does not provide a chassis package. + +var listChassisTool = mcp.NewTool("ironic_list_chassis", + mcp.WithDescription("[Admin] List baremetal chassis. Requires admin role."), + mcp.WithReadOnlyHintAnnotation(true), +) + +func listChassisHandler(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 + } + + var response struct { + Chassis []struct { + UUID string `json:"uuid"` + Description string `json:"description"` + Extra map[string]any `json:"extra"` + } `json:"chassis"` + } + + _, err = client.Get(ctx, client.ServiceURL("chassis"), &response, &gophercloud.RequestOpts{ + OkCodes: []int{200}, + }) + if err != nil { + return shared.ToolError("failed to list chassis: %v", err), nil + } + + result := make([]map[string]any, 0, len(response.Chassis)) + for _, c := range response.Chassis { + result = append(result, map[string]any{ + "uuid": c.UUID, + "description": c.Description, + "extra": c.Extra, + }) + } + + out, err := json.MarshalIndent(result, "", " ") + if err != nil { + return shared.ToolError("failed to marshal response: %v", err), nil + } + return shared.ToolResult(string(out)), nil + } +} + +// --- Power State (Admin Write) --- + +var nodeChangePowerStateTool = mcp.NewTool("ironic_node_power_state", + mcp.WithDescription("[Admin] Change the power state of a baremetal node. Requires admin role."), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("node_id", mcp.Required(), mcp.Description("The UUID or name of the baremetal node")), + mcp.WithString("target", mcp.Required(), mcp.Description("Target power state: 'power on', 'power off', or 'rebooting'")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +func nodeChangePowerStateHandler(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.ValidatePathSegment(nodeID, "node_id"); errResult != nil { + return errResult, nil + } + + target := shared.StringParam(request, "target") + if target == "" { + return shared.ToolError("target is required"), nil + } + + // Validate the target power state. + var powerTarget nodes.TargetPowerState + switch target { + case "power on": + powerTarget = nodes.PowerOn + case "power off": + powerTarget = nodes.PowerOff + case "rebooting": + powerTarget = nodes.Rebooting + default: + return shared.ToolError("target must be 'power on', 'power off', or 'rebooting' (got: %q)", target), nil + } + + preview := fmt.Sprintf("Will change power state of node %s to %q", nodeID, target) + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + + opts := nodes.PowerStateOpts{ + Target: powerTarget, + } + + err = nodes.ChangePowerState(ctx, client, nodeID, opts).ExtractErr() + if err != nil { + return shared.ToolError("failed to change power state of node %s: %v", nodeID, err), nil + } + + return shared.ToolResult(fmt.Sprintf("Successfully initiated power state change of node %s to %q.", nodeID, target)), nil + } +} diff --git a/internal/tools/keystone/keystone.go b/internal/tools/keystone/keystone.go index c4c9711..f68b106 100644 --- a/internal/tools/keystone/keystone.go +++ b/internal/tools/keystone/keystone.go @@ -13,6 +13,7 @@ import ( "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/applicationcredentials" "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/domains" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/groups" "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects" "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens" @@ -27,7 +28,8 @@ import ( // Register adds all Keystone tools to the MCP server. // When readOnly is true, mutating tools (create/delete credentials) are not registered. -func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { +// When admin is true, admin-only tools (role assignments, groups) are registered. +func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly, admin bool) { s.AddTool(listProjectsTool, listProjectsHandler(provider)) s.AddTool(tokenInfoTool, tokenInfoHandler(provider)) s.AddTool(listAppCredentialsTool, listAppCredentialsHandler(provider)) @@ -38,6 +40,10 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { s.AddTool(createAppCredentialTool, createAppCredentialHandler(provider)) s.AddTool(deleteAppCredentialTool, deleteAppCredentialHandler(provider)) } + if admin { + s.AddTool(listRoleAssignmentsTool, listRoleAssignmentsHandler(provider)) + s.AddTool(listGroupsTool, listGroupsHandler(provider)) + } } // --- Tool Definitions --- @@ -488,3 +494,144 @@ func listRolesHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolResult(string(out)), nil } } + +// --- Role Assignments (Admin) --- + +var listRoleAssignmentsTool = mcp.NewTool("keystone_list_role_assignments", + mcp.WithDescription("[Admin] List role assignments. Requires admin role."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("project_id", mcp.Description("Filter by project UUID")), + mcp.WithString("user_id", mcp.Description("Filter by user UUID")), + mcp.WithString("role_id", mcp.Description("Filter by role UUID")), +) + +func listRoleAssignmentsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.IdentityClient() + if err != nil { + return shared.ToolError("failed to get identity client: %v", err), nil + } + + opts := roles.ListAssignmentsOpts{} + if v := shared.StringParam(request, "project_id"); v != "" { + if errResult := shared.ValidateUUID(v, "project_id"); errResult != nil { + return errResult, nil + } + opts.ScopeProjectID = v + } + if v := shared.StringParam(request, "user_id"); v != "" { + if errResult := shared.ValidateUUID(v, "user_id"); errResult != nil { + return errResult, nil + } + opts.UserID = v + } + if v := shared.StringParam(request, "role_id"); v != "" { + if errResult := shared.ValidateUUID(v, "role_id"); errResult != nil { + return errResult, nil + } + opts.RoleID = v + } + + result := make([]map[string]any, 0) + err = roles.ListAssignments(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + assignmentList, err := roles.ExtractRoleAssignments(page) + if err != nil { + return false, err + } + for _, a := range assignmentList { + entry := map[string]any{ + "role": map[string]any{ + "id": a.Role.ID, + }, + } + if a.User.ID != "" { + entry["user"] = map[string]any{ + "id": a.User.ID, + } + } + if a.Group.ID != "" { + entry["group"] = map[string]any{ + "id": a.Group.ID, + } + } + scope := map[string]any{} + if a.Scope.Project.ID != "" { + scope["project"] = map[string]any{ + "id": a.Scope.Project.ID, + } + } + if a.Scope.Domain.ID != "" { + scope["domain"] = map[string]any{ + "id": a.Scope.Domain.ID, + } + } + entry["scope"] = scope + result = append(result, entry) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list role assignments: %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 + } +} + +// --- Groups (Admin) --- + +var listGroupsTool = mcp.NewTool("keystone_list_groups", + mcp.WithDescription("[Admin] List user groups. Requires admin role."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("domain_id", mcp.Description("Filter by domain UUID")), + mcp.WithString("name", mcp.Description("Filter by group name")), +) + +func listGroupsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.IdentityClient() + if err != nil { + return shared.ToolError("failed to get identity client: %v", err), nil + } + + opts := groups.ListOpts{ + Name: shared.StringParam(request, "name"), + } + if v := shared.StringParam(request, "domain_id"); v != "" { + if errResult := shared.ValidateUUID(v, "domain_id"); errResult != nil { + return errResult, nil + } + opts.DomainID = v + } + + result := make([]map[string]any, 0) + err = groups.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + groupList, err := groups.ExtractGroups(page) + if err != nil { + return false, err + } + for _, g := range groupList { + result = append(result, map[string]any{ + "id": g.ID, + "name": g.Name, + "domain_id": g.DomainID, + "description": g.Description, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list groups: %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/manila/manila.go b/internal/tools/manila/manila.go index 4e56ae5..c0816b8 100644 --- a/internal/tools/manila/manila.go +++ b/internal/tools/manila/manila.go @@ -8,9 +8,12 @@ import ( "context" "encoding/json" + "github.com/gophercloud/gophercloud/v2/openstack/sharedfilesystems/v2/securityservices" "github.com/gophercloud/gophercloud/v2/openstack/sharedfilesystems/v2/shareaccessrules" "github.com/gophercloud/gophercloud/v2/openstack/sharedfilesystems/v2/sharenetworks" "github.com/gophercloud/gophercloud/v2/openstack/sharedfilesystems/v2/shares" + "github.com/gophercloud/gophercloud/v2/openstack/sharedfilesystems/v2/sharetypes" + "github.com/gophercloud/gophercloud/v2/openstack/sharedfilesystems/v2/snapshots" "github.com/gophercloud/gophercloud/v2/pagination" "github.com/mark3labs/mcp-go/mcp" mcpserver "github.com/mark3labs/mcp-go/server" @@ -20,11 +23,15 @@ import ( ) // Register adds all Manila tools to the MCP server. -func Register(s *mcpserver.MCPServer, provider *auth.Provider) { +// The admin parameter is accepted for interface consistency but currently unused. +func Register(s *mcpserver.MCPServer, provider *auth.Provider, _ bool) { s.AddTool(listSharesTool, listSharesHandler(provider)) s.AddTool(getShareTool, getShareHandler(provider)) s.AddTool(listAccessRulesTool, listAccessRulesHandler(provider)) s.AddTool(listShareNetworksTool, listShareNetworksHandler(provider)) + s.AddTool(listSnapshotsTool, listSnapshotsHandler(provider)) + s.AddTool(listSecurityServicesTool, listSecurityServicesHandler(provider)) + s.AddTool(listShareTypesTool, listShareTypesHandler(provider)) } var listSharesTool = mcp.NewTool("manila_list_shares", @@ -217,3 +224,158 @@ func listShareNetworksHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc return shared.ToolResult(string(out)), nil } } + +// --- Snapshots --- + +var listSnapshotsTool = mcp.NewTool("manila_list_snapshots", + mcp.WithDescription("List share snapshots. Returns snapshot ID, name, status, share ID, share size, share protocol, and created_at."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("share_id", mcp.Description("Filter by share UUID")), + mcp.WithString("status", mcp.Description("Filter by snapshot status (available, error, creating, deleting)")), +) + +func listSnapshotsHandler(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 + } + + opts := snapshots.ListOpts{} + if v := shared.StringParam(request, "share_id"); v != "" { + if errResult := shared.ValidateUUID(v, "share_id"); errResult != nil { + return errResult, nil + } + opts.ShareID = v + } + if v := shared.StringParam(request, "status"); v != "" { + opts.Status = v + } + + result := make([]map[string]any, 0) + err = snapshots.ListDetail(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + snapshotList, err := snapshots.ExtractSnapshots(page) + if err != nil { + return false, err + } + for _, s := range snapshotList { + result = append(result, map[string]any{ + "id": s.ID, + "name": s.Name, + "status": s.Status, + "share_id": s.ShareID, + "share_size": s.ShareSize, + "share_proto": s.ShareProto, + "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 + } +} + +// --- Security Services --- + +var listSecurityServicesTool = mcp.NewTool("manila_list_security_services", + mcp.WithDescription("List security services (LDAP/Kerberos/Active Directory) configured for share networks."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("type", mcp.Description("Filter by security service type (ldap, kerberos, active_directory)")), + mcp.WithString("name", mcp.Description("Filter by security service name")), +) + +func listSecurityServicesHandler(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 + } + + opts := securityservices.ListOpts{ + Name: shared.StringParam(request, "name"), + } + if v := shared.StringParam(request, "type"); v != "" { + opts.Type = securityservices.SecurityServiceType(v) + } + + result := make([]map[string]any, 0) + err = securityservices.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + svcList, err := securityservices.ExtractSecurityServices(page) + if err != nil { + return false, err + } + for _, s := range svcList { + // SECURITY: Do NOT include Password field. + result = append(result, map[string]any{ + "id": s.ID, + "name": s.Name, + "type": s.Type, + "status": s.Status, + "dns_ip": s.DNSIP, + "server": s.Server, + "domain": s.Domain, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list security services: %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 + } +} + +// --- Share Types --- + +var listShareTypesTool = mcp.NewTool("manila_list_share_types", + mcp.WithDescription("List available share types. Returns share type ID, name, public visibility, and extra specifications."), + mcp.WithReadOnlyHintAnnotation(true), +) + +func listShareTypesHandler(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 + } + + result := make([]map[string]any, 0) + err = sharetypes.List(client, nil).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + typeList, err := sharetypes.ExtractShareTypes(page) + if err != nil { + return false, err + } + for _, t := range typeList { + result = append(result, map[string]any{ + "id": t.ID, + "name": t.Name, + "is_public": t.IsPublic, + "extra_specs": t.ExtraSpecs, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list share 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/neutron/neutron.go b/internal/tools/neutron/neutron.go index 63eec06..cb6acba 100644 --- a/internal/tools/neutron/neutron.go +++ b/internal/tools/neutron/neutron.go @@ -9,16 +9,20 @@ import ( "encoding/json" "fmt" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/agents" "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/networkipavailabilities" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/security/groups" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/security/rules" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/trunks" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/networks" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/ports" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/subnets" "github.com/gophercloud/gophercloud/v2/pagination" "github.com/mark3labs/mcp-go/mcp" mcpserver "github.com/mark3labs/mcp-go/server" + "github.com/sapcc/gophercloud-sapcc/v2/networking/v2/bgpvpn/interconnections" "github.com/notque/openstack-mcp-server/internal/auth" "github.com/notque/openstack-mcp-server/internal/tools/shared" @@ -26,16 +30,25 @@ import ( // Register adds all Neutron tools to the MCP server. // When readOnly is true, mutating tools (create/delete operations) are not registered. -func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { +// When admin is true, admin-only tools (agents) are registered. +func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly, admin bool) { s.AddTool(listNetworksTool, listNetworksHandler(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)) + s.AddTool(listTrunksTool, listTrunksHandler(provider)) + s.AddTool(listNetworkIPAvailabilitiesTool, listNetworkIPAvailabilitiesHandler(provider)) + s.AddTool(listBGPVPNInterconnectionsTool, listBGPVPNInterconnectionsHandler(provider)) if !readOnly { s.AddTool(createSecGroupRuleTool, createSecGroupRuleHandler(provider)) s.AddTool(deleteSecGroupRuleTool, deleteSecGroupRuleHandler(provider)) + s.AddTool(createFloatingIPTool, createFloatingIPHandler(provider)) + s.AddTool(deleteFloatingIPTool, deleteFloatingIPHandler(provider)) + } + if admin { + s.AddTool(listAgentsTool, listAgentsHandler(provider)) } } @@ -90,7 +103,7 @@ func listNetworksHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { opts.Limit = int(limit) } - var result []map[string]any + result := make([]map[string]any, 0) err = networks.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { nets, err := networks.ExtractNetworks(page) if err != nil { @@ -137,7 +150,7 @@ func listSubnetsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { opts.Limit = int(limit) } - var result []map[string]any + result := make([]map[string]any, 0) err = subnets.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { subs, err := subnets.ExtractSubnets(page) if err != nil { @@ -186,7 +199,7 @@ func listPortsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { opts.Limit = int(limit) } - var result []map[string]any + result := make([]map[string]any, 0) err = ports.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { ps, err := ports.ExtractPorts(page) if err != nil { @@ -232,7 +245,7 @@ func listSecGroupsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { opts.Limit = int(limit) } - var result []map[string]any + result := make([]map[string]any, 0) err = groups.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { sgs, err := groups.ExtractGroups(page) if err != nil { @@ -283,7 +296,7 @@ func listRoutersHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { opts.Limit = int(limit) } - var result []map[string]any + result := make([]map[string]any, 0) err = routers.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { rs, err := routers.ExtractRouters(page) if err != nil { @@ -343,7 +356,7 @@ func listFloatingIPsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { opts.Limit = int(limit) } - var result []map[string]any + result := make([]map[string]any, 0) err = floatingips.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { fips, err := floatingips.ExtractFloatingIPs(page) if err != nil { @@ -557,3 +570,360 @@ func deleteSecGroupRuleHandler(provider *auth.Provider) mcpserver.ToolHandlerFun return shared.ToolResult("Successfully deleted security group rule " + ruleID), nil } } + +// --- Trunk Tools --- + +var listTrunksTool = mcp.NewTool("neutron_list_trunks", + mcp.WithDescription("List trunk ports with sub-ports. Returns trunk ID, name, port ID, status, sub-ports, and admin state."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("name", mcp.Description("Filter by trunk name")), + mcp.WithString("port_id", mcp.Description("Filter by parent port ID")), +) + +func listTrunksHandler(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 := trunks.ListOpts{ + Name: shared.StringParam(request, "name"), + } + if v := shared.StringParam(request, "port_id"); v != "" { + if errResult := shared.ValidateUUID(v, "port_id"); errResult != nil { + return errResult, nil + } + opts.PortID = v + } + + result := make([]map[string]any, 0) + err = trunks.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + ts, err := trunks.ExtractTrunks(page) + if err != nil { + return false, err + } + for _, t := range ts { + result = append(result, map[string]any{ + "id": t.ID, + "name": t.Name, + "port_id": t.PortID, + "status": t.Status, + "sub_ports": t.Subports, + "tenant_id": t.TenantID, + "admin_state_up": t.AdminStateUp, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list trunks: %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 + } +} + +// --- Network IP Availability Tools --- + +var listNetworkIPAvailabilitiesTool = mcp.NewTool("neutron_list_network_ip_availabilities", + mcp.WithDescription("Show IP usage per network. Returns network ID, name, total IPs, used IPs, and subnet IP availabilities."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("network_id", mcp.Description("Filter by network UUID")), + mcp.WithString("network_name", mcp.Description("Filter by network name")), +) + +func listNetworkIPAvailabilitiesHandler(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 := networkipavailabilities.ListOpts{ + NetworkName: shared.StringParam(request, "network_name"), + } + if v := shared.StringParam(request, "network_id"); v != "" { + if errResult := shared.ValidateUUID(v, "network_id"); errResult != nil { + return errResult, nil + } + opts.NetworkID = v + } + + result := make([]map[string]any, 0) + err = networkipavailabilities.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + avails, err := networkipavailabilities.ExtractNetworkIPAvailabilities(page) + if err != nil { + return false, err + } + for _, a := range avails { + result = append(result, map[string]any{ + "network_id": a.NetworkID, + "network_name": a.NetworkName, + "total_ips": a.TotalIPs, + "used_ips": a.UsedIPs, + "subnet_ip_availabilities": a.SubnetIPAvailabilities, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list network IP availabilities: %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 + } +} + +// --- Floating IP Write Tools --- + +var createFloatingIPTool = mcp.NewTool("neutron_create_floating_ip", + mcp.WithDescription("Create/allocate a floating IP from an external network. Requires confirmation."), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("floating_network_id", mcp.Required(), mcp.Description("The UUID of the external network to allocate the floating IP from")), + mcp.WithString("port_id", mcp.Description("The UUID of the internal port to associate the floating IP with")), + mcp.WithString("subnet_id", mcp.Description("The UUID of the subnet for the floating IP")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +func createFloatingIPHandler(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 + } + + floatingNetworkID := shared.StringParam(request, "floating_network_id") + if floatingNetworkID == "" { + return shared.ToolError("floating_network_id is required"), nil + } + if errResult := shared.ValidateUUID(floatingNetworkID, "floating_network_id"); errResult != nil { + return errResult, nil + } + + portID := shared.StringParam(request, "port_id") + if portID != "" { + if errResult := shared.ValidateUUID(portID, "port_id"); errResult != nil { + return errResult, nil + } + } + + subnetID := shared.StringParam(request, "subnet_id") + if subnetID != "" { + if errResult := shared.ValidateUUID(subnetID, "subnet_id"); errResult != nil { + return errResult, nil + } + } + + preview := "Will ALLOCATE floating IP from network " + floatingNetworkID + if portID != "" { + preview += fmt.Sprintf(" (associated with port %s)", portID) + } + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + + createOpts := floatingips.CreateOpts{ + FloatingNetworkID: floatingNetworkID, + PortID: portID, + SubnetID: subnetID, + } + + fip, err := floatingips.Create(ctx, client, createOpts).Extract() + if err != nil { + return shared.ToolError("failed to create floating IP: %v", err), nil + } + + safe := map[string]any{ + "id": fip.ID, + "floating_ip_address": fip.FloatingIP, + "floating_network_id": fip.FloatingNetworkID, + "port_id": fip.PortID, + "status": fip.Status, + } + + out, err := json.MarshalIndent(safe, "", " ") + if err != nil { + return shared.ToolError("failed to marshal response: %v", err), nil + } + return shared.ToolResult(string(out)), nil + } +} + +var deleteFloatingIPTool = mcp.NewTool("neutron_delete_floating_ip", + mcp.WithDescription("Release/delete a floating IP. Requires confirmation."), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("floating_ip_id", mcp.Required(), mcp.Description("The UUID of the floating IP to delete")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +func deleteFloatingIPHandler(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 + } + + fipID := shared.StringParam(request, "floating_ip_id") + if fipID == "" { + return shared.ToolError("floating_ip_id is required"), nil + } + if errResult := shared.ValidateUUID(fipID, "floating_ip_id"); errResult != nil { + return errResult, nil + } + + // Fetch floating IP for preview. + fip, err := floatingips.Get(ctx, client, fipID).Extract() + if err != nil { + return shared.ToolError("failed to get floating IP %s: %v", fipID, err), nil + } + + preview := fmt.Sprintf("Will DELETE floating IP %s (%s)", fip.FloatingIP, fip.ID) + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + + if err := floatingips.Delete(ctx, client, fipID).ExtractErr(); err != nil { + return shared.ToolError("failed to delete floating IP %s: %v", fipID, err), nil + } + + return shared.ToolResult(fmt.Sprintf("Successfully deleted floating IP %s (%s)", fip.FloatingIP, fipID)), nil + } +} + +// --- Admin Tools --- + +var listAgentsTool = mcp.NewTool("neutron_list_agents", + mcp.WithDescription("[Admin] List neutron agents. Returns agent ID, type, binary, host, alive status, and heartbeat."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("agent_type", mcp.Description("Filter by agent type (e.g., 'Open vSwitch agent', 'DHCP agent', 'L3 agent')")), + mcp.WithString("host", mcp.Description("Filter by host name")), + mcp.WithString("alive", mcp.Description("Filter by alive status ('true' or 'false')")), +) + +func listAgentsHandler(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 := agents.ListOpts{ + AgentType: shared.StringParam(request, "agent_type"), + Host: shared.StringParam(request, "host"), + } + if v := shared.StringParam(request, "alive"); v != "" { + switch v { + case "true": + alive := true + opts.Alive = &alive + case "false": + alive := false + opts.Alive = &alive + default: + return shared.ToolError("alive must be 'true' or 'false' (got: %q)", v), nil + } + } + + result := make([]map[string]any, 0) + err = agents.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + as, err := agents.ExtractAgents(page) + if err != nil { + return false, err + } + for _, a := range as { + result = append(result, map[string]any{ + "id": a.ID, + "agent_type": a.AgentType, + "binary": a.Binary, + "host": a.Host, + "alive": a.Alive, + "admin_state_up": a.AdminStateUp, + "topic": a.Topic, + "heartbeat_timestamp": a.HeartbeatTimestamp, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list agents: %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 + } +} + +// --- BGP VPN Interconnection Tools (SAP CC) --- + +var listBGPVPNInterconnectionsTool = mcp.NewTool("neutron_list_bgpvpn_interconnections", + mcp.WithDescription("List BGP VPN interconnections (SAP CC extension). Returns interconnection ID, name, type, state, and resource details."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("name", mcp.Description("Filter by interconnection name")), + mcp.WithString("state", mcp.Description("Filter by interconnection state")), + mcp.WithString("project_id", mcp.Description("Filter by project ID")), +) + +func listBGPVPNInterconnectionsHandler(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 := interconnections.ListOpts{} + if v := shared.StringParam(request, "name"); v != "" { + opts.Name = []string{v} + } + if v := shared.StringParam(request, "state"); v != "" { + opts.State = []string{v} + } + if v := shared.StringParam(request, "project_id"); v != "" { + if errResult := shared.ValidateUUID(v, "project_id"); errResult != nil { + return errResult, nil + } + opts.ProjectID = []string{v} + } + + result := make([]map[string]any, 0) + err = interconnections.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + ics, err := interconnections.ExtractInterconnections(page) + if err != nil { + return false, err + } + for _, ic := range ics { + result = append(result, map[string]any{ + "id": ic.ID, + "name": ic.Name, + "project_id": ic.ProjectID, + "type": ic.Type, + "state": ic.State, + "local_resource_id": ic.LocalResourceID, + "remote_resource_id": ic.RemoteResourceID, + "remote_region": ic.RemoteRegion, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list BGP VPN interconnections: %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 c1692bf..148a5fd 100644 --- a/internal/tools/nova/nova.go +++ b/internal/tools/nova/nova.go @@ -10,11 +10,17 @@ import ( "fmt" "strings" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/aggregates" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/availabilityzones" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/flavors" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/hypervisors" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/instanceactions" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/keypairs" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/quotasets" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servergroups" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/services" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/volumeattach" "github.com/gophercloud/gophercloud/v2/pagination" "github.com/mark3labs/mcp-go/mcp" mcpserver "github.com/mark3labs/mcp-go/server" @@ -25,17 +31,27 @@ import ( // Register adds all Nova tools to the MCP server. // When readOnly is true, mutating tools (server actions) are not registered. -func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { +// When admin is true, admin-only tools (hypervisors, services, aggregates) are registered. +func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly, admin bool) { s.AddTool(listServersTool, listServersHandler(provider)) s.AddTool(getServerTool, getServerHandler(provider)) s.AddTool(listFlavorsTool, listFlavorsHandler(provider)) s.AddTool(listKeypairsTool, listKeypairsHandler(provider)) s.AddTool(getQuotasTool, getQuotasHandler(provider)) s.AddTool(listAvailabilityZonesTool, listAvailabilityZonesHandler(provider)) + s.AddTool(listInstanceActionsTool, listInstanceActionsHandler(provider)) + s.AddTool(listServerGroupsTool, listServerGroupsHandler(provider)) + s.AddTool(listVolumeAttachmentsTool, listVolumeAttachmentsHandler(provider)) if !readOnly { s.AddTool(serverActionTool, serverActionHandler(provider)) s.AddTool(createServerTool, createServerHandler(provider)) } + if admin { + s.AddTool(listHypervisorsTool, listHypervisorsHandler(provider)) + s.AddTool(getHypervisorTool, getHypervisorHandler(provider)) + s.AddTool(listServicesTool, listServicesHandler(provider)) + s.AddTool(listAggregatesTool, listAggregatesHandler(provider)) + } } // --- Tool Definitions --- @@ -104,7 +120,7 @@ func listServersHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { maxResults = 100 } - var result []map[string]any + result := make([]map[string]any, 0) err = servers.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { srvs, err := servers.ExtractServers(page) if err != nil { @@ -196,7 +212,7 @@ func listFlavorsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolError("failed to get compute client: %v", err), nil } - var result []map[string]any + result := make([]map[string]any, 0) err = flavors.ListDetail(client, nil).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { flvs, err := flavors.ExtractFlavors(page) if err != nil { @@ -232,7 +248,7 @@ func listKeypairsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolError("failed to get compute client: %v", err), nil } - var result []map[string]any + result := make([]map[string]any, 0) err = keypairs.List(client, nil).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { kps, err := keypairs.ExtractKeyPairs(page) if err != nil { @@ -517,3 +533,338 @@ func listAvailabilityZonesHandler(provider *auth.Provider) mcpserver.ToolHandler return shared.ToolResult(string(out)), nil } } + +// --- Instance Actions, Server Groups, Volume Attachments --- + +var listInstanceActionsTool = mcp.NewTool("nova_list_instance_actions", + mcp.WithDescription("List actions performed on a server. Returns request ID, action, instance UUID, start time, user ID, and message."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("server_id", mcp.Required(), mcp.Description("The UUID of the server to list actions for")), +) + +func listInstanceActionsHandler(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 + } + + serverID := shared.StringParam(request, "server_id") + if serverID == "" { + return shared.ToolError("server_id is required"), nil + } + if errResult := shared.ValidateUUID(serverID, "server_id"); errResult != nil { + return errResult, nil + } + + result := make([]map[string]any, 0) + err = instanceactions.List(client, serverID, nil).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + actions, err := instanceactions.ExtractInstanceActions(page) + if err != nil { + return false, err + } + for _, a := range actions { + result = append(result, map[string]any{ + "request_id": a.RequestID, + "action": a.Action, + "instance_uuid": a.InstanceUUID, + "start_time": a.StartTime, + "user_id": a.UserID, + "message": a.Message, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list instance actions for server %s: %v", serverID, 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 listServerGroupsTool = mcp.NewTool("nova_list_server_groups", + mcp.WithDescription("List server groups (anti-affinity, affinity) in the current project. Returns ID, name, policies, members, and metadata."), + mcp.WithReadOnlyHintAnnotation(true), +) + +func listServerGroupsHandler(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 + } + + result := make([]map[string]any, 0) + err = servergroups.List(client, nil).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + groups, err := servergroups.ExtractServerGroups(page) + if err != nil { + return false, err + } + for _, g := range groups { + result = append(result, map[string]any{ + "id": g.ID, + "name": g.Name, + "policies": g.Policies, + "members": g.Members, + "metadata": g.Metadata, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list server groups: %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 listVolumeAttachmentsTool = mcp.NewTool("nova_list_volume_attachments", + mcp.WithDescription("List volumes attached to a server. Returns attachment ID, volume ID, server ID, and device."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("server_id", mcp.Required(), mcp.Description("The UUID of the server to list volume attachments for")), +) + +func listVolumeAttachmentsHandler(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 + } + + serverID := shared.StringParam(request, "server_id") + if serverID == "" { + return shared.ToolError("server_id is required"), nil + } + if errResult := shared.ValidateUUID(serverID, "server_id"); errResult != nil { + return errResult, nil + } + + result := make([]map[string]any, 0) + err = volumeattach.List(client, serverID).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + attachments, err := volumeattach.ExtractVolumeAttachments(page) + if err != nil { + return false, err + } + for _, a := range attachments { + result = append(result, map[string]any{ + "id": a.ID, + "volume_id": a.VolumeID, + "server_id": a.ServerID, + "device": a.Device, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list volume attachments for server %s: %v", serverID, 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 + } +} + +// --- Admin Tools: Hypervisors, Services, Aggregates --- + +var listHypervisorsTool = mcp.NewTool("nova_list_hypervisors", + mcp.WithDescription("[Admin] List hypervisors. Requires admin role. Returns ID, hostname, status, state, vCPUs, memory, running VMs, and type."), + mcp.WithReadOnlyHintAnnotation(true), +) + +func listHypervisorsHandler(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 + } + + result := make([]map[string]any, 0) + err = hypervisors.List(client, nil).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + hvs, err := hypervisors.ExtractHypervisors(page) + if err != nil { + return false, err + } + for _, h := range hvs { + // SECURITY: Use field allowlist. Exclude HostIP, ServiceHost. + result = append(result, map[string]any{ + "id": h.ID, + "hypervisor_hostname": h.HypervisorHostname, + "status": h.Status, + "state": h.State, + "vcpus": h.VCPUs, + "vcpus_used": h.VCPUsUsed, + "memory_mb": h.MemoryMB, + "memory_mb_used": h.MemoryMBUsed, + "running_vms": h.RunningVMs, + "hypervisor_type": h.HypervisorType, + "hypervisor_version": h.HypervisorVersion, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list hypervisors: %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 getHypervisorTool = mcp.NewTool("nova_get_hypervisor", + mcp.WithDescription("[Admin] Get hypervisor details. Requires admin role. Returns ID, hostname, status, state, vCPUs, memory, running VMs, and type."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("hypervisor_id", mcp.Required(), mcp.Description("The ID of the hypervisor (UUID or integer ID)")), +) + +func getHypervisorHandler(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 + } + + hypervisorID := shared.StringParam(request, "hypervisor_id") + if hypervisorID == "" { + return shared.ToolError("hypervisor_id is required"), nil + } + if errResult := shared.ValidatePathSegment(hypervisorID, "hypervisor_id"); errResult != nil { + return errResult, nil + } + + h, err := hypervisors.Get(ctx, client, hypervisorID).Extract() + if err != nil { + return shared.ToolError("failed to get hypervisor %s: %v", hypervisorID, err), nil + } + + // SECURITY: Use field allowlist. Exclude HostIP, ServiceHost. + safe := map[string]any{ + "id": h.ID, + "hypervisor_hostname": h.HypervisorHostname, + "status": h.Status, + "state": h.State, + "vcpus": h.VCPUs, + "vcpus_used": h.VCPUsUsed, + "memory_mb": h.MemoryMB, + "memory_mb_used": h.MemoryMBUsed, + "running_vms": h.RunningVMs, + "hypervisor_type": h.HypervisorType, + "hypervisor_version": h.HypervisorVersion, + } + + out, err := json.MarshalIndent(safe, "", " ") + if err != nil { + return shared.ToolError("failed to marshal response: %v", err), nil + } + return shared.ToolResult(string(out)), nil + } +} + +var listServicesTool = mcp.NewTool("nova_list_services", + mcp.WithDescription("[Admin] List compute services. Requires admin role. Returns ID, binary, host, zone, status, state, and updated_at."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("binary", mcp.Description("Filter by service binary (e.g. 'nova-compute', 'nova-scheduler')")), + mcp.WithString("host", mcp.Description("Filter by host name")), +) + +func listServicesHandler(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 + } + + opts := services.ListOpts{ + Binary: shared.StringParam(request, "binary"), + Host: shared.StringParam(request, "host"), + } + + result := make([]map[string]any, 0) + err = services.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + svcs, err := services.ExtractServices(page) + if err != nil { + return false, err + } + for _, svc := range svcs { + result = append(result, map[string]any{ + "id": svc.ID, + "binary": svc.Binary, + "host": svc.Host, + "zone": svc.Zone, + "status": svc.Status, + "state": svc.State, + "updated_at": svc.UpdatedAt, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list compute services: %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 listAggregatesTool = mcp.NewTool("nova_list_aggregates", + mcp.WithDescription("[Admin] List host aggregates. Requires admin role. Returns ID, name, availability zone, hosts, metadata, and timestamps."), + mcp.WithReadOnlyHintAnnotation(true), +) + +func listAggregatesHandler(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 + } + + page, err := aggregates.List(client).AllPages(ctx) + if err != nil { + return shared.ToolError("failed to list aggregates: %v", err), nil + } + + aggs, err := aggregates.ExtractAggregates(page) + if err != nil { + return shared.ToolError("failed to extract aggregates: %v", err), nil + } + + result := make([]map[string]any, 0, len(aggs)) + for _, agg := range aggs { + result = append(result, map[string]any{ + "id": agg.ID, + "name": agg.Name, + "availability_zone": agg.AvailabilityZone, + "hosts": agg.Hosts, + "metadata": agg.Metadata, + "created_at": agg.CreatedAt, + "updated_at": agg.UpdatedAt, + }) + } + + 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/octavia/octavia.go b/internal/tools/octavia/octavia.go index dd41dfc..1f5e6f6 100644 --- a/internal/tools/octavia/octavia.go +++ b/internal/tools/octavia/octavia.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" + "github.com/gophercloud/gophercloud/v2/openstack/loadbalancer/v2/amphorae" "github.com/gophercloud/gophercloud/v2/openstack/loadbalancer/v2/l7policies" "github.com/gophercloud/gophercloud/v2/openstack/loadbalancer/v2/listeners" "github.com/gophercloud/gophercloud/v2/openstack/loadbalancer/v2/loadbalancers" @@ -24,7 +25,8 @@ import ( // Register adds all Octavia tools to the MCP server. // When readOnly is true, mutating tools (create/delete load balancers) are not registered. -func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { +// When admin is true, admin-only tools (amphorae) are registered. +func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly, admin bool) { s.AddTool(listLoadbalancersTool, listLoadbalancersHandler(provider)) s.AddTool(getLoadbalancerTool, getLoadbalancerHandler(provider)) s.AddTool(listListenersTool, listListenersHandler(provider)) @@ -32,6 +34,12 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { s.AddTool(listMembersTool, listMembersHandler(provider)) s.AddTool(listHealthmonitorsTool, listHealthmonitorsHandler(provider)) s.AddTool(listL7policiesTool, listL7policiesHandler(provider)) + s.AddTool(listL7RulesTool, listL7RulesHandler(provider)) + + if admin { + s.AddTool(listAmphoraeTool, listAmphoraeHandler(provider)) + } + if !readOnly { s.AddTool(createLoadbalancerTool, createLoadbalancerHandler(provider)) s.AddTool(deleteLoadbalancerTool, deleteLoadbalancerHandler(provider)) @@ -469,6 +477,120 @@ func listL7policiesHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { } } +// --- L7 Rules --- + +var listL7RulesTool = mcp.NewTool("octavia_list_l7rules", + mcp.WithDescription("List L7 rules for a specific policy. Returns rule ID, type, compare type, key, value, invert, and status."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("l7policy_id", mcp.Required(), mcp.Description("The UUID of the L7 policy to list rules for")), +) + +func listL7RulesHandler(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 + } + + policyID := shared.StringParam(request, "l7policy_id") + if policyID == "" { + return shared.ToolError("l7policy_id is required"), nil + } + if errResult := shared.ValidateUUID(policyID, "l7policy_id"); errResult != nil { + return errResult, nil + } + + result := make([]map[string]any, 0) + err = l7policies.ListRules(client, policyID, nil).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + allRules, err := l7policies.ExtractRules(page) + if err != nil { + return false, err + } + for _, r := range allRules { + result = append(result, map[string]any{ + "id": r.ID, + "type": r.RuleType, + "compare_type": r.CompareType, + "key": r.Key, + "value": r.Value, + "invert": r.Invert, + "operating_status": r.OperatingStatus, + fieldProvisioningStatus: r.ProvisioningStatus, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list L7 rules for policy %s: %v", policyID, 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 + } +} + +// --- Admin tools --- + +var listAmphoraeTool = mcp.NewTool("octavia_list_amphorae", + mcp.WithDescription("[Admin] List amphora instances. Requires admin role."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("loadbalancer_id", mcp.Description("Filter by load balancer UUID")), + mcp.WithString("status", mcp.Description("Filter by amphora status")), +) + +func listAmphoraeHandler(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 := amphorae.ListOpts{} + if v := shared.StringParam(request, "loadbalancer_id"); v != "" { + if errResult := shared.ValidateUUID(v, "loadbalancer_id"); errResult != nil { + return errResult, nil + } + opts.LoadbalancerID = v + } + if v := shared.StringParam(request, "status"); v != "" { + opts.Status = v + } + + result := make([]map[string]any, 0) + err = amphorae.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + allAmphorae, err := amphorae.ExtractAmphorae(page) + if err != nil { + return false, err + } + for _, a := range allAmphorae { + result = append(result, map[string]any{ + "id": a.ID, + "loadbalancer_id": a.LoadbalancerID, + "status": a.Status, + "role": a.Role, + "lb_network_ip": a.LBNetworkIP, + "ha_port_id": a.HAPortID, + "compute_id": a.ComputeID, + "cert_expiration": a.CertExpiration, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list amphorae: %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 + } +} + // --- Write Tools --- var createLoadbalancerTool = mcp.NewTool("octavia_create_loadbalancer", diff --git a/internal/tools/swift/swift.go b/internal/tools/swift/swift.go index aef3ec4..fcc31d7 100644 --- a/internal/tools/swift/swift.go +++ b/internal/tools/swift/swift.go @@ -22,7 +22,7 @@ import ( // Register adds all Swift tools to the MCP server. // When readOnly is true, mutating tools (upload/delete objects) are not registered. -func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { +func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly, _ bool) { s.AddTool(listContainersTool, listContainersHandler(provider)) s.AddTool(listObjectsTool, listObjectsHandler(provider)) s.AddTool(getObjectMetadataTool, getObjectMetadataHandler(provider))