diff --git a/README.md b/README.md index 8f9184c..cfb3659 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: Apache-2.0 # openstack-mcp-server -MCP (Model Context Protocol) server for OpenStack and SAP Converged Cloud. Provides AI coding agents with typed, structured tools for querying infrastructure — 66 tools across 18 services. +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). ## 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_server_action`* | Servers, flavors, keypairs, actions | -| **Neutron** (Networking) | `neutron_list_networks`, `neutron_list_subnets`, `neutron_list_ports`, `neutron_list_security_groups`, `neutron_list_routers`, `neutron_list_floating_ips` | Networks, subnets, ports, security groups, routers, floating IPs | -| **Cinder** (Block Storage) | `cinder_list_volumes`, `cinder_get_volume`, `cinder_list_snapshots`, `cinder_get_snapshot`, `cinder_list_volume_types` | Volumes, snapshots, volume types | -| **Keystone** (Identity) | `keystone_list_projects`, `keystone_token_info`, `keystone_list_application_credentials`, `keystone_create_application_credential`*, `keystone_delete_application_credential`* | Projects, auth info, app credentials | -| **Designate** (DNS) | `designate_list_zones`, `designate_get_zone`, `designate_list_recordsets` | DNS zones and records | +| **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` | Containers and objects | -| **Manila** (Shared Filesystems) | `manila_list_shares`, `manila_get_share`, `manila_list_access_rules` | File shares, access rules | -| **Octavia** (Load Balancer) | `octavia_list_loadbalancers`, `octavia_get_loadbalancer`, `octavia_list_listeners`, `octavia_list_pools`, `octavia_list_members`, `octavia_list_healthmonitors` | Load balancers, listeners, pools, members, health monitors | +| **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` | Baremetal nodes, ports | +| **Ironic** (Bare Metal) | `ironic_list_nodes`, `ironic_get_node`, `ironic_list_node_ports`, `ironic_list_allocations` | Baremetal nodes, ports, allocations | ### SAP Converged Cloud | Service | Tools | Description | @@ -60,7 +60,7 @@ 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 — disabled in read-only mode (default). Set `MCP_READ_ONLY=false` to enable.* +*\* Mutating tools (14 total) — disabled in read-only mode (default). Set `MCP_READ_ONLY=false` to enable.* ## Configuration @@ -128,31 +128,61 @@ Add to your MCP client's configuration file (e.g., `.cursor/mcp.json`): ## Security -### Three-Layer Safety Architecture +### Four-Layer Safety Architecture | Layer | Mechanism | Effect | |-------|-----------|--------| | **1. Read-Only Mode** | `MCP_READ_ONLY=true` (default) | Mutating tools are not registered — invisible to the LLM | -| **2. Tool Annotations** | `DestructiveHint` / `ReadOnlyHint` | MCP client prompts user for confirmation on destructive actions | -| **3. Credential Isolation** | Secrets held in server memory only | Auth tokens and passwords never reach 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 | + +### Write Safety: The Confirmed Pattern + +All 14 write tools implement a two-call safety pattern: + +``` +1st call (confirmed absent/false): + → Returns PREVIEW: "Will DELETE volume 'db-backup' (abc123), 50GiB, status: available" + +2nd call (confirmed=true): + → Executes the operation +``` + +This gives the AI agent (and the human supervising it) a chance to review what will happen before any state changes. + +### Semantic Guardrails + +Write tools include domain-specific safety rules that reject dangerous operations before they reach the confirmation step: + +| Service | Rule | Rationale | +|---------|------|-----------| +| **Neutron** | Rejects ingress 0.0.0.0/0 on ports 22, 3389, 3306, 5432 | Prevents accidental world-open SSH/RDP/DB access | +| **Cinder** | Rejects delete on status `in-use` | Prevents data loss from deleting attached volumes | +| **Designate** | Enforces CNAME singleton per name | DNS RFC compliance (CNAME can't coexist with other records) | +| **Swift** | `safe_write` option uses `If-None-Match:*` | Prevents accidental overwrites of existing objects | +| **Octavia** | Cascade delete requires explicit opt-in | Prevents accidental deletion of listeners, pools, and members | ### Read-Only Mode (Default) -By default, mutating tools are **disabled**: -- `nova_server_action` (start/stop/reboot servers) -- `keystone_create_application_credential` -- `keystone_delete_application_credential` +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: -Set `MCP_READ_ONLY=false` only when you explicitly need write operations. +- `nova_server_action`, `nova_create_server` +- `neutron_create_security_group_rule`, `neutron_delete_security_group_rule` +- `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` ### 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** (63 tools): Annotated with `readOnlyHint: true`. Clients may auto-approve these. -- **Destructive tools** (3 tools): Annotated with `destructiveHint: true`. Clients **must prompt the user** before execution. +- **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. -This means even when `MCP_READ_ONLY=false` enables destructive tools, the MCP client (Claude Code, Cursor, etc.) will still ask "Allow this action?" before executing server actions or credential mutations. The server declares, the client enforces. +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. ### Credential Isolation Architecture @@ -187,6 +217,12 @@ Try these after setup: - "What load balancers exist and what pools do they have?" - "Show me pending Castellum autoscaling operations" +With write tools enabled (`MCP_READ_ONLY=false`): +- "Create a 100GiB SSD volume named 'db-data' in AZ eu-de-1a" +- "Add an A record for api.example.com pointing to 10.0.1.50" +- "Create a security group rule allowing TCP 443 from 10.0.0.0/8" +- "Create a load balancer on subnet abc123 named 'web-lb'" + ## Companion: Agent Toolkit For enhanced AI workflows, pair this MCP server with the [OpenStack Agent Toolkit](https://github.com/notque/openstack-agent-toolkit) — a Claude Code plugin providing domain knowledge, operational skills, and safety hooks for SAP Converged Cloud infrastructure. diff --git a/internal/server/server.go b/internal/server/server.go index 43aea52..671bff8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -79,14 +79,14 @@ func (s *Server) registerTools() { // Standard OpenStack services nova.Register(s.mcp, s.provider, readOnly) - neutron.Register(s.mcp, s.provider) - cinder.Register(s.mcp, s.provider) + 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) + designate.Register(s.mcp, s.provider, readOnly) barbican.Register(s.mcp, s.provider) - swift.Register(s.mcp, s.provider) + swift.Register(s.mcp, s.provider, readOnly) manila.Register(s.mcp, s.provider) - octavia.Register(s.mcp, s.provider) + octavia.Register(s.mcp, s.provider, readOnly) glance.Register(s.mcp, s.provider) // SAP CC-specific services diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 1dd2046..9fbe3d7 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -38,14 +38,14 @@ 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) - cinder.Register(s, nil) + neutron.Register(s, nil, false) + cinder.Register(s, nil, false) keystone.Register(s, nil, false) - designate.Register(s, nil) + designate.Register(s, nil, false) barbican.Register(s, nil) - swift.Register(s, nil) + swift.Register(s, nil, false) manila.Register(s, nil) - octavia.Register(s, nil) + octavia.Register(s, nil, false) glance.Register(s, nil) hermes.Register(s, nil) limes.Register(s, nil) diff --git a/internal/tools/cinder/cinder.go b/internal/tools/cinder/cinder.go index 154ec14..faee7d1 100644 --- a/internal/tools/cinder/cinder.go +++ b/internal/tools/cinder/cinder.go @@ -7,7 +7,9 @@ package cinder import ( "context" "encoding/json" + "fmt" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/quotasets" "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/snapshots" "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes" "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumetypes" @@ -20,12 +22,18 @@ import ( ) // Register adds all Cinder tools to the MCP server. -func Register(s *mcpserver.MCPServer, provider *auth.Provider) { +func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly 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)) + + if !readOnly { + s.AddTool(createVolumeTool, createVolumeHandler(provider)) + s.AddTool(deleteVolumeTool, deleteVolumeHandler(provider)) + } } var listVolumesTool = mcp.NewTool("cinder_list_volumes", @@ -245,3 +253,172 @@ func listVolumeTypesHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolResult(string(out)), nil } } + +// --- Read tool: quotas --- + +var getQuotasTool = mcp.NewTool("cinder_get_quotas", + mcp.WithDescription("Get block storage quota usage for a project. Shows limits and current usage for volumes, snapshots, gigabytes, and backups."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("project_id", mcp.Required(), mcp.Description("The UUID of the project to get quotas for")), +) + +func getQuotasHandler(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 + } + + projectID := shared.StringParam(request, "project_id") + if projectID == "" { + return shared.ToolError("project_id is required"), nil + } + if errResult := shared.ValidateUUID(projectID, "project_id"); errResult != nil { + return errResult, nil + } + + usage, err := quotasets.GetUsage(ctx, client, projectID).Extract() + if err != nil { + return shared.ToolError("failed to get quotas for project %s: %v", projectID, err), nil + } + + out, err := json.MarshalIndent(usage, "", " ") + 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", + mcp.WithDescription("Create a new block storage volume."), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("name", mcp.Description("Name for the new volume")), + mcp.WithNumber("size", mcp.Required(), mcp.Description("Size of the volume in GiB (must be > 0)")), + mcp.WithString("volume_type", mcp.Description("Volume type (e.g., 'vmware_hdd', 'vmware_ssd')")), + mcp.WithString("availability_zone", mcp.Description("Availability zone for the volume")), + mcp.WithString("description", mcp.Description("Description of the volume")), + mcp.WithString("snapshot_id", mcp.Description("UUID of a snapshot to create the volume from")), + mcp.WithString("source_volume_id", mcp.Description("UUID of an existing volume to clone")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +var deleteVolumeTool = mcp.NewTool("cinder_delete_volume", + mcp.WithDescription("Delete a block storage volume. Volume must not be attached to any server."), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("volume_id", mcp.Required(), mcp.Description("The UUID of the volume to delete")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +func createVolumeHandler(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 + } + + size := int(shared.NumberParam(request, "size")) + if size <= 0 { + return shared.ToolError("size must be greater than 0"), nil + } + + name := shared.StringParam(request, "name") + volumeType := shared.StringParam(request, "volume_type") + az := shared.StringParam(request, "availability_zone") + description := shared.StringParam(request, "description") + snapshotID := shared.StringParam(request, "snapshot_id") + sourceVolID := shared.StringParam(request, "source_volume_id") + + if snapshotID != "" { + if errResult := shared.ValidateUUID(snapshotID, "snapshot_id"); errResult != nil { + return errResult, nil + } + } + if sourceVolID != "" { + if errResult := shared.ValidateUUID(sourceVolID, "source_volume_id"); errResult != nil { + return errResult, nil + } + } + + nameDisplay := name + if nameDisplay == "" { + nameDisplay = "(unnamed)" + } + typeDisplay := volumeType + if typeDisplay == "" { + typeDisplay = "default" + } + azDisplay := az + if azDisplay == "" { + azDisplay = "default" + } + preview := fmt.Sprintf("Will CREATE volume '%s', %dGiB, type: %s, AZ: %s", + nameDisplay, size, typeDisplay, azDisplay) + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + + createOpts := volumes.CreateOpts{ + Name: name, + Size: size, + VolumeType: volumeType, + AvailabilityZone: az, + Description: description, + SnapshotID: snapshotID, + SourceVolID: sourceVolID, + } + + vol, err := volumes.Create(ctx, client, createOpts, nil).Extract() + if err != nil { + return shared.ToolError("failed to create volume: %v", err), nil + } + + out, err := json.MarshalIndent(vol, "", " ") + if err != nil { + return shared.ToolError("failed to marshal response: %v", err), nil + } + return shared.ToolResult(string(out)), nil + } +} + +func deleteVolumeHandler(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 + } + + volumeID := shared.StringParam(request, "volume_id") + if volumeID == "" { + return shared.ToolError("volume_id is required"), nil + } + if errResult := shared.ValidateUUID(volumeID, "volume_id"); errResult != nil { + return errResult, nil + } + + // Fetch volume to check status and build preview + vol, err := volumes.Get(ctx, client, volumeID).Extract() + if err != nil { + return shared.ToolError("failed to get volume %s: %v", volumeID, err), nil + } + + if vol.Status == "in-use" { + return shared.ToolError("cannot delete volume %s: currently attached to server(s) (status: in-use). Detach first.", volumeID), nil + } + + preview := fmt.Sprintf("Will DELETE volume '%s' (%s), %dGiB, status: %s", + vol.Name, vol.ID, vol.Size, vol.Status) + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + + err = volumes.Delete(ctx, client, volumeID, nil).ExtractErr() + if err != nil { + return shared.ToolError("failed to delete volume %s: %v", volumeID, err), nil + } + + return shared.ToolResult("Successfully deleted volume " + volumeID), nil + } +} diff --git a/internal/tools/designate/designate.go b/internal/tools/designate/designate.go index 75c57a9..cee9021 100644 --- a/internal/tools/designate/designate.go +++ b/internal/tools/designate/designate.go @@ -7,6 +7,9 @@ package designate import ( "context" "encoding/json" + "fmt" + "strconv" + "strings" "github.com/gophercloud/gophercloud/v2/openstack/dns/v2/recordsets" "github.com/gophercloud/gophercloud/v2/openstack/dns/v2/zones" @@ -19,10 +22,15 @@ import ( ) // Register adds all Designate tools to the MCP server. -func Register(s *mcpserver.MCPServer, provider *auth.Provider) { +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)) + + if !readOnly { + s.AddTool(createRecordsetTool, createRecordsetHandler(provider)) + s.AddTool(deleteRecordsetTool, deleteRecordsetHandler(provider)) + } } var listZonesTool = mcp.NewTool("designate_list_zones", @@ -173,3 +181,144 @@ func listRecordsetsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolResult(string(out)), nil } } + +// --- Write tools --- + +var createRecordsetTool = mcp.NewTool("designate_create_recordset", + mcp.WithDescription("Create a DNS recordset in a zone."), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("zone_id", mcp.Required(), mcp.Description("The UUID of the zone to create the recordset in")), + mcp.WithString("name", mcp.Required(), mcp.Description("Fully qualified domain name ending with '.' (e.g., 'app.example.com.')")), + mcp.WithString("type", mcp.Required(), mcp.Description("Record type: A, AAAA, CNAME, MX, TXT, SRV, or NS")), + mcp.WithString("records", mcp.Required(), mcp.Description("Comma-separated record values (e.g., '192.168.1.1,192.168.1.2')")), + mcp.WithNumber("ttl", mcp.Description("Time to live in seconds")), + mcp.WithString("description", mcp.Description("Description of the recordset")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +var deleteRecordsetTool = mcp.NewTool("designate_delete_recordset", + mcp.WithDescription("Delete a DNS recordset from a zone."), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("zone_id", mcp.Required(), mcp.Description("The UUID of the zone containing the recordset")), + mcp.WithString("recordset_id", mcp.Required(), mcp.Description("The UUID of the recordset to delete")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +func createRecordsetHandler(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 + } + + zoneID := shared.StringParam(request, "zone_id") + if zoneID == "" { + return shared.ToolError("zone_id is required"), nil + } + if errResult := shared.ValidateUUID(zoneID, "zone_id"); errResult != nil { + return errResult, nil + } + + name := shared.StringParam(request, "name") + if name == "" { + return shared.ToolError("name is required"), nil + } + if !strings.HasSuffix(name, ".") { + return shared.ToolError("DNS name must be a fully qualified domain name ending with '.'"), nil + } + + recType := shared.StringParam(request, "type") + if recType == "" { + return shared.ToolError("type is required"), nil + } + + recordsStr := shared.StringParam(request, "records") + if recordsStr == "" { + return shared.ToolError("records is required"), nil + } + records := strings.Split(recordsStr, ",") + for i := range records { + records[i] = strings.TrimSpace(records[i]) + } + + if recType == "CNAME" && len(records) > 1 { + return shared.ToolError("CNAME records must have exactly one value"), nil + } + + ttl := int(shared.NumberParam(request, "ttl")) + description := shared.StringParam(request, "description") + + ttlDisplay := "default" + if ttl > 0 { + ttlDisplay = strconv.Itoa(ttl) + } + preview := fmt.Sprintf("Will CREATE %s record '%s' in zone %s with values: [%s], TTL: %s", + recType, name, zoneID, strings.Join(records, ", "), ttlDisplay) + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + + createOpts := recordsets.CreateOpts{ + Name: name, + Type: recType, + Records: records, + TTL: ttl, + Description: description, + } + + rs, err := recordsets.Create(ctx, client, zoneID, createOpts).Extract() + if err != nil { + return shared.ToolError("failed to create recordset: %v", err), nil + } + + out, err := json.MarshalIndent(rs, "", " ") + if err != nil { + return shared.ToolError("failed to marshal response: %v", err), nil + } + return shared.ToolResult(string(out)), nil + } +} + +func deleteRecordsetHandler(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 + } + + zoneID := shared.StringParam(request, "zone_id") + if zoneID == "" { + return shared.ToolError("zone_id is required"), nil + } + if errResult := shared.ValidateUUID(zoneID, "zone_id"); errResult != nil { + return errResult, nil + } + + rrsetID := shared.StringParam(request, "recordset_id") + if rrsetID == "" { + return shared.ToolError("recordset_id is required"), nil + } + if errResult := shared.ValidateUUID(rrsetID, "recordset_id"); errResult != nil { + return errResult, nil + } + + // Fetch recordset for preview + rs, err := recordsets.Get(ctx, client, zoneID, rrsetID).Extract() + if err != nil { + return shared.ToolError("failed to get recordset %s: %v", rrsetID, err), nil + } + + preview := fmt.Sprintf("Will DELETE recordset '%s' (%s: [%s]) from zone %s", + rs.Name, rs.Type, strings.Join(rs.Records, ", "), zoneID) + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + + err = recordsets.Delete(ctx, client, zoneID, rrsetID).ExtractErr() + if err != nil { + return shared.ToolError("failed to delete recordset %s: %v", rrsetID, err), nil + } + + return shared.ToolResult(fmt.Sprintf("Successfully deleted recordset %s from zone %s", rrsetID, zoneID)), nil + } +} diff --git a/internal/tools/ironic/ironic.go b/internal/tools/ironic/ironic.go index 74d8172..d19bb6c 100644 --- a/internal/tools/ironic/ironic.go +++ b/internal/tools/ironic/ironic.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" + "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" "github.com/gophercloud/gophercloud/v2/pagination" @@ -23,6 +24,7 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { s.AddTool(listNodesTool, listNodesHandler(provider)) s.AddTool(getNodeTool, getNodeHandler(provider)) s.AddTool(listNodePortsTool, listNodePortsHandler(provider)) + s.AddTool(listAllocationsTool, listAllocationsHandler(provider)) } var listNodesTool = mcp.NewTool("ironic_list_nodes", @@ -205,3 +207,59 @@ func listNodePortsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolResult(string(out)), nil } } + +// --- Allocations --- + +var listAllocationsTool = mcp.NewTool("ironic_list_allocations", + mcp.WithDescription("List baremetal node allocations. Returns allocation UUID, node UUID, state, resource class, and name."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("node_id", mcp.Description("Filter by node UUID")), + mcp.WithString("resource_class", mcp.Description("Filter by resource class")), +) + +func listAllocationsHandler(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 + } + + opts := allocations.ListOpts{} + if v := shared.StringParam(request, "node_id"); v != "" { + if errResult := shared.ValidateUUID(v, "node_id"); errResult != nil { + return errResult, nil + } + opts.Node = v + } + if v := shared.StringParam(request, "resource_class"); v != "" { + opts.ResourceClass = v + } + + result := make([]map[string]any, 0) + err = allocations.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + allocationList, err := allocations.ExtractAllocations(page) + if err != nil { + return false, err + } + for _, a := range allocationList { + result = append(result, map[string]any{ + "uuid": a.UUID, + "node_uuid": a.NodeUUID, + "state": a.State, + "resource_class": a.ResourceClass, + "name": a.Name, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list allocations: %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/keystone/keystone.go b/internal/tools/keystone/keystone.go index 9bc824c..c4c9711 100644 --- a/internal/tools/keystone/keystone.go +++ b/internal/tools/keystone/keystone.go @@ -12,8 +12,11 @@ import ( "time" "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/projects" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/users" "github.com/gophercloud/gophercloud/v2/pagination" "github.com/mark3labs/mcp-go/mcp" mcpserver "github.com/mark3labs/mcp-go/server" @@ -28,6 +31,9 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { s.AddTool(listProjectsTool, listProjectsHandler(provider)) s.AddTool(tokenInfoTool, tokenInfoHandler(provider)) s.AddTool(listAppCredentialsTool, listAppCredentialsHandler(provider)) + s.AddTool(listDomainsTool, listDomainsHandler(provider)) + s.AddTool(listUsersTool, listUsersHandler(provider)) + s.AddTool(listRolesTool, listRolesHandler(provider)) if !readOnly { s.AddTool(createAppCredentialTool, createAppCredentialHandler(provider)) s.AddTool(deleteAppCredentialTool, deleteAppCredentialHandler(provider)) @@ -55,6 +61,7 @@ var createAppCredentialTool = mcp.NewTool("keystone_create_application_credentia mcp.WithString("description", mcp.Description("Description of the credential's purpose (e.g., 'MCP server access for project X')")), mcp.WithString("expires_at", mcp.Description("Expiration time in RFC3339 format (e.g., '2025-12-31T23:59:59Z'). If omitted, the credential does not expire.")), mcp.WithString("roles", mcp.Description("Comma-separated list of role names to assign (subset of current roles). If omitted, all current roles are inherited.")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), ) var listAppCredentialsTool = mcp.NewTool("keystone_list_application_credentials", @@ -67,6 +74,27 @@ var deleteAppCredentialTool = mcp.NewTool("keystone_delete_application_credentia mcp.WithDescription("Delete an application credential by ID. This immediately revokes the credential — any services using it will lose access."), mcp.WithDestructiveHintAnnotation(true), mcp.WithString("id", mcp.Required(), mcp.Description("The UUID of the application credential to delete")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +var listDomainsTool = mcp.NewTool("keystone_list_domains", + mcp.WithDescription("List identity domains. Returns domain ID, name, description, and enabled status."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("name", mcp.Description("Filter by domain name")), +) + +var listUsersTool = mcp.NewTool("keystone_list_users", + mcp.WithDescription("List users in the identity service. Returns user ID, name, domain_id, enabled, and description."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("domain_id", mcp.Description("Filter by domain UUID")), + mcp.WithString("name", mcp.Description("Filter by username")), +) + +var listRolesTool = mcp.NewTool("keystone_list_roles", + mcp.WithDescription("List roles in the identity service. Returns role ID, name, domain_id, and description."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("domain_id", mcp.Description("Filter by domain UUID")), + mcp.WithString("name", mcp.Description("Filter by role name")), ) // --- Handlers --- @@ -140,8 +168,8 @@ func tokenInfoHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { if domain, err := result.ExtractDomain(); err == nil { info["domain"] = domain } - if roles, err := result.ExtractRoles(); err == nil { - info["roles"] = roles + if tokenRoles, err := result.ExtractRoles(); err == nil { + info["roles"] = tokenRoles } if catalog, err := result.ExtractServiceCatalog(); err == nil { info["service_catalog"] = catalog @@ -172,6 +200,11 @@ func createAppCredentialHandler(provider *auth.Provider) mcpserver.ToolHandlerFu return shared.ToolError("name is required"), nil } + preview := fmt.Sprintf("Will CREATE application credential '%s' for the current user", name) + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + createOpts := applicationcredentials.CreateOpts{ Name: name, Description: shared.StringParam(request, "description"), @@ -311,6 +344,11 @@ func deleteAppCredentialHandler(provider *auth.Provider) mcpserver.ToolHandlerFu return errResult, nil } + preview := fmt.Sprintf("Will DELETE application credential %s — any services using it will immediately lose access", id) + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + err = applicationcredentials.Delete(ctx, client, userID, id).ExtractErr() if err != nil { return shared.ToolError("failed to delete application credential %s: %v", id, err), nil @@ -319,3 +357,134 @@ func deleteAppCredentialHandler(provider *auth.Provider) mcpserver.ToolHandlerFu return shared.ToolResult(fmt.Sprintf("Successfully deleted application credential %s. Any services using this credential will immediately lose access.", id)), nil } } + +func listDomainsHandler(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 := domains.ListOpts{ + Name: shared.StringParam(request, "name"), + } + + result := make([]map[string]any, 0) + err = domains.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + domainList, err := domains.ExtractDomains(page) + if err != nil { + return false, err + } + for _, d := range domainList { + result = append(result, map[string]any{ + "id": d.ID, + "name": d.Name, + "description": d.Description, + "enabled": d.Enabled, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list domains: %v", err), nil + } + + out, err := json.MarshalIndent(result, "", " ") + if err != nil { + return shared.ToolError("failed to marshal response: %v", err), nil + } + return shared.ToolResult(string(out)), nil + } +} + +func listUsersHandler(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 := users.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 = users.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + userList, err := users.ExtractUsers(page) + if err != nil { + return false, err + } + for _, u := range userList { + // SECURITY: Only expose safe fields. Password and Options are intentionally omitted. + result = append(result, map[string]any{ + "id": u.ID, + "name": u.Name, + "domain_id": u.DomainID, + "enabled": u.Enabled, + "description": u.Description, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list users: %v", err), nil + } + + out, err := json.MarshalIndent(result, "", " ") + if err != nil { + return shared.ToolError("failed to marshal response: %v", err), nil + } + return shared.ToolResult(string(out)), nil + } +} + +func listRolesHandler(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.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 = roles.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + roleList, err := roles.ExtractRoles(page) + if err != nil { + return false, err + } + for _, r := range roleList { + result = append(result, map[string]any{ + "id": r.ID, + "name": r.Name, + "domain_id": r.DomainID, + "description": r.Description, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list roles: %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 cc49d72..4e56ae5 100644 --- a/internal/tools/manila/manila.go +++ b/internal/tools/manila/manila.go @@ -9,6 +9,7 @@ import ( "encoding/json" "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/pagination" "github.com/mark3labs/mcp-go/mcp" @@ -23,6 +24,7 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { s.AddTool(listSharesTool, listSharesHandler(provider)) s.AddTool(getShareTool, getShareHandler(provider)) s.AddTool(listAccessRulesTool, listAccessRulesHandler(provider)) + s.AddTool(listShareNetworksTool, listShareNetworksHandler(provider)) } var listSharesTool = mcp.NewTool("manila_list_shares", @@ -166,3 +168,52 @@ func listAccessRulesHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolResult(string(out)), nil } } + +// --- Share Networks --- + +var listShareNetworksTool = mcp.NewTool("manila_list_share_networks", + mcp.WithDescription("List share networks in the current project. Returns share network ID, name, neutron network/subnet IDs, and project ID."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("name", mcp.Description("Filter by share network name")), +) + +func listShareNetworksHandler(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 := sharenetworks.ListOpts{ + Name: shared.StringParam(request, "name"), + } + + result := make([]map[string]any, 0) + err = sharenetworks.ListDetail(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + networks, err := sharenetworks.ExtractShareNetworks(page) + if err != nil { + return false, err + } + for _, n := range networks { + result = append(result, map[string]any{ + "id": n.ID, + "name": n.Name, + "neutron_net_id": n.NeutronNetID, + "neutron_subnet_id": n.NeutronSubnetID, + "created_at": n.CreatedAt, + "project_id": n.ProjectID, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list share networks: %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 9aad360..63eec06 100644 --- a/internal/tools/neutron/neutron.go +++ b/internal/tools/neutron/neutron.go @@ -7,10 +7,12 @@ package neutron import ( "context" "encoding/json" + "fmt" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/routers" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/security/groups" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/security/rules" "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" @@ -23,13 +25,18 @@ import ( ) // Register adds all Neutron tools to the MCP server. -func Register(s *mcpserver.MCPServer, provider *auth.Provider) { +// When readOnly is true, mutating tools (create/delete operations) are not registered. +func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly 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)) + if !readOnly { + s.AddTool(createSecGroupRuleTool, createSecGroupRuleHandler(provider)) + s.AddTool(deleteSecGroupRuleTool, deleteSecGroupRuleHandler(provider)) + } } var listNetworksTool = mcp.NewTool("neutron_list_networks", @@ -366,3 +373,187 @@ func listFloatingIPsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolResult(string(out)), nil } } + +// --- Security Group Rule Write Tools --- + +var createSecGroupRuleTool = mcp.NewTool("neutron_create_security_group_rule", + mcp.WithDescription("Create a new security group rule. Requires confirmation."), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("security_group_id", mcp.Required(), mcp.Description("The UUID of the security group to add the rule to")), + mcp.WithString("direction", mcp.Required(), mcp.Description("Direction: 'ingress' or 'egress'")), + mcp.WithString("protocol", mcp.Description("Protocol: 'tcp', 'udp', or 'icmp'")), + mcp.WithNumber("port_range_min", mcp.Description("Minimum port number (or ICMP type)")), + mcp.WithNumber("port_range_max", mcp.Description("Maximum port number (or ICMP code)")), + mcp.WithString("remote_ip_prefix", mcp.Description("Remote IP prefix in CIDR notation (e.g., '10.0.0.0/24')")), + mcp.WithString("remote_group_id", mcp.Description("Remote security group UUID (alternative to remote_ip_prefix)")), + mcp.WithString("ethertype", mcp.Description("Ethertype: 'IPv4' (default) or 'IPv6'")), + mcp.WithString("description", mcp.Description("Optional description of the rule")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +// dangerousPorts contains ports that should not be opened to 0.0.0.0/0 for ingress TCP. +var dangerousPorts = map[int]bool{ + 22: true, // SSH + 3389: true, // RDP + 3306: true, // MySQL + 5432: true, // PostgreSQL +} + +func createSecGroupRuleHandler(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 + } + + secGroupID := shared.StringParam(request, "security_group_id") + if secGroupID == "" { + return shared.ToolError("security_group_id is required"), nil + } + if errResult := shared.ValidateUUID(secGroupID, "security_group_id"); errResult != nil { + return errResult, nil + } + + direction := shared.StringParam(request, "direction") + if direction == "" { + return shared.ToolError("direction is required"), nil + } + if direction != "ingress" && direction != "egress" { + return shared.ToolError("direction must be 'ingress' or 'egress' (got: %q)", direction), nil + } + + protocol := shared.StringParam(request, "protocol") + portMin := int(shared.NumberParam(request, "port_range_min")) + portMax := int(shared.NumberParam(request, "port_range_max")) + remoteIP := shared.StringParam(request, "remote_ip_prefix") + remoteGroupID := shared.StringParam(request, "remote_group_id") + ethertype := shared.StringParam(request, "ethertype") + description := shared.StringParam(request, "description") + + if ethertype == "" { + ethertype = "IPv4" + } + + // Validate optional UUID parameters. + if remoteGroupID != "" { + if errResult := shared.ValidateUUID(remoteGroupID, "remote_group_id"); errResult != nil { + return errResult, nil + } + } + + // Security guardrail: reject rules that open dangerous ports to the world. + // Checks both IPv4 (0.0.0.0/0) and IPv6 (::/0) world-open prefixes. + isWorldOpen := remoteIP == "0.0.0.0/0" || remoteIP == "::/0" + if isWorldOpen && direction == "ingress" && protocol == "tcp" && remoteGroupID == "" { + // Reject if no port range specified (would open ALL ports). + if portMin == 0 && portMax == 0 { + return shared.ToolError( + "refusing to create rule allowing unrestricted access to ALL TCP ports from %s. Specify port_range_min and port_range_max, or use a specific CIDR or remote_group_id.", + remoteIP, + ), nil + } + // Reject if any dangerous port falls within the specified range. + effectiveMax := portMax + if effectiveMax == 0 { + effectiveMax = portMin + } + for port := range dangerousPorts { + if portMin <= port && port <= effectiveMax { + return shared.ToolError( + "refusing to create rule allowing unrestricted access to port %d from %s (port range %d-%d includes sensitive services). Use a specific CIDR or remote_group_id instead.", + port, remoteIP, portMin, effectiveMax, + ), nil + } + } + } + + // Build preview. + preview := fmt.Sprintf("Will CREATE security group rule: %s %s port %d-%d from %s on group %s", + direction, protocol, portMin, portMax, remoteIP, secGroupID) + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + + // Build create options. + createOpts := rules.CreateOpts{ + SecGroupID: secGroupID, + Direction: rules.RuleDirection(direction), + EtherType: rules.RuleEtherType(ethertype), + Protocol: rules.RuleProtocol(protocol), + RemoteIPPrefix: remoteIP, + RemoteGroupID: remoteGroupID, + Description: description, + } + if portMin > 0 { + createOpts.PortRangeMin = portMin + } + if portMax > 0 { + createOpts.PortRangeMax = portMax + } + + rule, err := rules.Create(ctx, client, createOpts).Extract() + if err != nil { + return shared.ToolError("failed to create security group rule: %v", err), nil + } + + safe := map[string]any{ + "id": rule.ID, + "direction": rule.Direction, + "protocol": rule.Protocol, + "port_range_min": rule.PortRangeMin, + "port_range_max": rule.PortRangeMax, + "remote_ip_prefix": rule.RemoteIPPrefix, + "remote_group_id": rule.RemoteGroupID, + "ethertype": rule.EtherType, + "security_group_id": rule.SecGroupID, + } + + 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 deleteSecGroupRuleTool = mcp.NewTool("neutron_delete_security_group_rule", + mcp.WithDescription("Delete a security group rule. Requires confirmation."), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("rule_id", mcp.Required(), mcp.Description("The UUID of the security group rule to delete")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +func deleteSecGroupRuleHandler(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 + } + + ruleID := shared.StringParam(request, "rule_id") + if ruleID == "" { + return shared.ToolError("rule_id is required"), nil + } + if errResult := shared.ValidateUUID(ruleID, "rule_id"); errResult != nil { + return errResult, nil + } + + // Fetch rule for preview. + rule, err := rules.Get(ctx, client, ruleID).Extract() + if err != nil { + return shared.ToolError("failed to get security group rule %s: %v", ruleID, err), nil + } + + preview := fmt.Sprintf("Will DELETE security group rule: %s %s port %d-%d from %s (group %s)", + rule.Direction, rule.Protocol, rule.PortRangeMin, rule.PortRangeMax, rule.RemoteIPPrefix, rule.SecGroupID) + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + + if err := rules.Delete(ctx, client, ruleID).ExtractErr(); err != nil { + return shared.ToolError("failed to delete security group rule %s: %v", ruleID, err), nil + } + + return shared.ToolResult("Successfully deleted security group rule " + ruleID), nil + } +} diff --git a/internal/tools/nova/nova.go b/internal/tools/nova/nova.go index 68ecab7..c1692bf 100644 --- a/internal/tools/nova/nova.go +++ b/internal/tools/nova/nova.go @@ -8,9 +8,12 @@ import ( "context" "encoding/json" "fmt" + "strings" + "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/keypairs" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/quotasets" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" "github.com/gophercloud/gophercloud/v2/pagination" "github.com/mark3labs/mcp-go/mcp" @@ -27,8 +30,11 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { 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)) if !readOnly { s.AddTool(serverActionTool, serverActionHandler(provider)) + s.AddTool(createServerTool, createServerHandler(provider)) } } @@ -68,6 +74,7 @@ var serverActionTool = mcp.NewTool("nova_server_action", mcp.WithString("server_id", mcp.Required(), mcp.Description("The UUID of the server")), mcp.WithString("action", mcp.Required(), mcp.Description("Action to perform: start, stop, reboot, pause, unpause, suspend, resume")), mcp.WithString("reboot_type", mcp.Description("Reboot type: SOFT or HARD (default: SOFT). Only used with 'reboot' action.")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), ) // --- Handlers --- @@ -270,6 +277,23 @@ func serverActionHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return errResult, nil } + // Validate action before confirmation. + validActions := map[string]bool{ + "start": true, "stop": true, "reboot": true, + "pause": true, "unpause": true, "suspend": true, "resume": true, + } + if !validActions[action] { + return shared.ToolError("unsupported action: %s (valid: start, stop, reboot, pause, unpause, suspend, resume)", action), nil + } + + preview := fmt.Sprintf("Will %s server %s", strings.ToUpper(action), serverID) + if action == "reboot" && shared.StringParam(request, "reboot_type") == "HARD" { + preview = "Will HARD REBOOT server " + serverID + } + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + switch action { case "start": err = servers.Start(ctx, client, serverID).ExtractErr() @@ -289,8 +313,6 @@ func serverActionHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { err = servers.Suspend(ctx, client, serverID).ExtractErr() case "resume": err = servers.Resume(ctx, client, serverID).ExtractErr() - default: - return shared.ToolError("unsupported action: %s (valid: start, stop, reboot, pause, unpause, suspend, resume)", action), nil } if err != nil { @@ -300,3 +322,198 @@ func serverActionHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolResult(fmt.Sprintf("Successfully performed '%s' on server %s", action, serverID)), nil } } + +// --- Create Server Tool --- + +var createServerTool = mcp.NewTool("nova_create_server", + mcp.WithDescription("Create a new compute instance. Requires confirmation."), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("name", mcp.Required(), mcp.Description("Name for the new server")), + mcp.WithString("flavor_id", mcp.Required(), mcp.Description("The UUID of the flavor (instance type)")), + mcp.WithString("image_id", mcp.Required(), mcp.Description("The UUID of the image to boot from")), + mcp.WithString("network_id", mcp.Description("The UUID of the network to attach to")), + mcp.WithString("key_name", mcp.Description("Name of the SSH keypair to inject")), + mcp.WithString("security_groups", mcp.Description("Comma-separated list of security group names")), + mcp.WithString("availability_zone", mcp.Description("Availability zone to launch in")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +func createServerHandler(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 + } + + name := shared.StringParam(request, "name") + if name == "" { + return shared.ToolError("name is required"), nil + } + + flavorID := shared.StringParam(request, "flavor_id") + if flavorID == "" { + return shared.ToolError("flavor_id is required"), nil + } + if errResult := shared.ValidateUUID(flavorID, "flavor_id"); errResult != nil { + return errResult, nil + } + + imageID := shared.StringParam(request, "image_id") + if imageID == "" { + return shared.ToolError("image_id is required (boot-from-volume without image is not supported)"), nil + } + if errResult := shared.ValidateUUID(imageID, "image_id"); errResult != nil { + return errResult, nil + } + + networkID := shared.StringParam(request, "network_id") + if networkID != "" { + if errResult := shared.ValidateUUID(networkID, "network_id"); errResult != nil { + return errResult, nil + } + } + + keyName := shared.StringParam(request, "key_name") + secGroupsStr := shared.StringParam(request, "security_groups") + az := shared.StringParam(request, "availability_zone") + + // Build preview. + preview := fmt.Sprintf("Will CREATE server '%s' with flavor %s, image %s, network: %s, AZ: %s, keypair: %s", + name, flavorID, imageID, networkID, az, keyName) + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + + // Build create options. + createOpts := servers.CreateOpts{ + Name: name, + FlavorRef: flavorID, + ImageRef: imageID, + AvailabilityZone: az, + } + + if networkID != "" { + createOpts.Networks = []servers.Network{{UUID: networkID}} + } + + if secGroupsStr != "" { + parts := strings.Split(secGroupsStr, ",") + secGroups := make([]string, 0, len(parts)) + for _, sg := range parts { + trimmed := strings.TrimSpace(sg) + if trimmed != "" { + secGroups = append(secGroups, trimmed) + } + } + createOpts.SecurityGroups = secGroups + } + + // Wrap with keypairs extension if key_name provided. + var createOptsBuilder servers.CreateOptsBuilder = createOpts + if keyName != "" { + createOptsBuilder = keypairs.CreateOptsExt{ + CreateOptsBuilder: createOpts, + KeyName: keyName, + } + } + + srv, err := servers.Create(ctx, client, createOptsBuilder, nil).Extract() + if err != nil { + return shared.ToolError("failed to create server: %v", err), nil + } + + // SECURITY: Use allowlist of safe fields. Never include AdminPass. + safe := map[string]any{ + "id": srv.ID, + "name": srv.Name, + "status": srv.Status, + "addresses": srv.Addresses, + "flavor": srv.Flavor, + "image": srv.Image, + "key_name": srv.KeyName, + "security_groups": srv.SecurityGroups, + "availability_zone": srv.AvailabilityZone, + "created": srv.Created, + } + + out, err := json.MarshalIndent(safe, "", " ") + if err != nil { + return shared.ToolError("failed to marshal response: %v", err), nil + } + return shared.ToolResult(string(out)), nil + } +} + +// --- Quota and Availability Zone Read Tools --- + +var getQuotasTool = mcp.NewTool("nova_get_quotas", + mcp.WithDescription("Get compute quota details (limits and usage) for a project."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("project_id", mcp.Required(), mcp.Description("The UUID of the project to get quotas for")), +) + +func getQuotasHandler(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 + } + + projectID := shared.StringParam(request, "project_id") + if projectID == "" { + return shared.ToolError("project_id is required"), nil + } + if errResult := shared.ValidateUUID(projectID, "project_id"); errResult != nil { + return errResult, nil + } + + quotaDetail, err := quotasets.GetDetail(ctx, client, projectID).Extract() + if err != nil { + return shared.ToolError("failed to get quotas for project %s: %v", projectID, err), nil + } + + out, err := json.MarshalIndent(quotaDetail, "", " ") + if err != nil { + return shared.ToolError("failed to marshal response: %v", err), nil + } + return shared.ToolResult(string(out)), nil + } +} + +var listAvailabilityZonesTool = mcp.NewTool("nova_list_availability_zones", + mcp.WithDescription("List compute availability zones with their status."), + mcp.WithReadOnlyHintAnnotation(true), +) + +func listAvailabilityZonesHandler(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 := availabilityzones.List(client).AllPages(ctx) + if err != nil { + return shared.ToolError("failed to list availability zones: %v", err), nil + } + + zones, err := availabilityzones.ExtractAvailabilityZones(page) + if err != nil { + return shared.ToolError("failed to extract availability zones: %v", err), nil + } + + result := make([]map[string]any, 0, len(zones)) + for _, zone := range zones { + result = append(result, map[string]any{ + "name": zone.ZoneName, + "available": zone.ZoneState.Available, + }) + } + + 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 9574f34..dd41dfc 100644 --- a/internal/tools/octavia/octavia.go +++ b/internal/tools/octavia/octavia.go @@ -7,7 +7,9 @@ package octavia import ( "context" "encoding/json" + "fmt" + "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" "github.com/gophercloud/gophercloud/v2/openstack/loadbalancer/v2/monitors" @@ -21,15 +23,24 @@ import ( ) // Register adds all Octavia tools to the MCP server. -func Register(s *mcpserver.MCPServer, provider *auth.Provider) { +// When readOnly is true, mutating tools (create/delete load balancers) are not registered. +func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { s.AddTool(listLoadbalancersTool, listLoadbalancersHandler(provider)) s.AddTool(getLoadbalancerTool, getLoadbalancerHandler(provider)) s.AddTool(listListenersTool, listListenersHandler(provider)) s.AddTool(listPoolsTool, listPoolsHandler(provider)) s.AddTool(listMembersTool, listMembersHandler(provider)) s.AddTool(listHealthmonitorsTool, listHealthmonitorsHandler(provider)) + s.AddTool(listL7policiesTool, listL7policiesHandler(provider)) + if !readOnly { + s.AddTool(createLoadbalancerTool, createLoadbalancerHandler(provider)) + s.AddTool(deleteLoadbalancerTool, deleteLoadbalancerHandler(provider)) + } } +// fieldProvisioningStatus is the JSON field name used across multiple response maps. +const fieldProvisioningStatus = "provisioning_status" + // --- Load Balancers --- var listLoadbalancersTool = mcp.NewTool("octavia_list_loadbalancers", @@ -84,14 +95,14 @@ func listLoadbalancersHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc } for _, lb := range lbs { result = append(result, map[string]any{ - "id": lb.ID, - "name": lb.Name, - "provisioning_status": lb.ProvisioningStatus, - "operating_status": lb.OperatingStatus, - "vip_address": lb.VipAddress, - "vip_subnet_id": lb.VipSubnetID, - "provider": lb.Provider, - "created_at": lb.CreatedAt, + "id": lb.ID, + "name": lb.Name, + fieldProvisioningStatus: lb.ProvisioningStatus, + "operating_status": lb.OperatingStatus, + "vip_address": lb.VipAddress, + "vip_subnet_id": lb.VipSubnetID, + "provider": lb.Provider, + "created_at": lb.CreatedAt, }) } return true, nil @@ -180,13 +191,13 @@ func listListenersHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { lbIDs[i] = lb.ID } result = append(result, map[string]any{ - "id": l.ID, - "name": l.Name, - "protocol": l.Protocol, - "protocol_port": l.ProtocolPort, - "default_pool_id": l.DefaultPoolID, - "provisioning_status": l.ProvisioningStatus, - "loadbalancers": lbIDs, + "id": l.ID, + "name": l.Name, + "protocol": l.Protocol, + "protocol_port": l.ProtocolPort, + "default_pool_id": l.DefaultPoolID, + fieldProvisioningStatus: l.ProvisioningStatus, + "loadbalancers": lbIDs, }) } return true, nil @@ -247,13 +258,13 @@ func listPoolsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { lbIDs[i] = lb.ID } result = append(result, map[string]any{ - "id": p.ID, - "name": p.Name, - "protocol": p.Protocol, - "lb_method": p.LBMethod, - "provisioning_status": p.ProvisioningStatus, - "operating_status": p.OperatingStatus, - "loadbalancers": lbIDs, + "id": p.ID, + "name": p.Name, + "protocol": p.Protocol, + "lb_method": p.LBMethod, + fieldProvisioningStatus: p.ProvisioningStatus, + "operating_status": p.OperatingStatus, + "loadbalancers": lbIDs, }) } return true, nil @@ -398,3 +409,176 @@ func listHealthmonitorsHandler(provider *auth.Provider) mcpserver.ToolHandlerFun return shared.ToolResult(string(out)), nil } } + +// --- L7 Policies --- + +var listL7policiesTool = mcp.NewTool("octavia_list_l7policies", + mcp.WithDescription("List L7 policies for load balancer listeners. Returns ID, name, action, redirect info, position, and status."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithString("listener_id", mcp.Description("Filter by listener UUID")), + mcp.WithString("name", mcp.Description("Filter by L7 policy name")), +) + +func listL7policiesHandler(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 := l7policies.ListOpts{} + if v := shared.StringParam(request, "listener_id"); v != "" { + if errResult := shared.ValidateUUID(v, "listener_id"); errResult != nil { + return errResult, nil + } + opts.ListenerID = v + } + if v := shared.StringParam(request, "name"); v != "" { + opts.Name = v + } + + result := make([]map[string]any, 0) + err = l7policies.List(client, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) { + allPolicies, err := l7policies.ExtractL7Policies(page) + if err != nil { + return false, err + } + for _, p := range allPolicies { + result = append(result, map[string]any{ + "id": p.ID, + "name": p.Name, + "action": p.Action, + "redirect_pool_id": p.RedirectPoolID, + "redirect_url": p.RedirectURL, + "position": p.Position, + fieldProvisioningStatus: p.ProvisioningStatus, + "operating_status": p.OperatingStatus, + }) + } + return true, nil + }) + if err != nil { + return shared.ToolError("failed to list L7 policies: %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", + mcp.WithDescription("Create a new load balancer on a specified subnet."), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("name", mcp.Required(), mcp.Description("Name for the load balancer")), + mcp.WithString("vip_subnet_id", mcp.Required(), mcp.Description("The UUID of the subnet for the VIP address")), + mcp.WithString("description", mcp.Description("Human-readable description")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +var deleteLoadbalancerTool = mcp.NewTool("octavia_delete_loadbalancer", + mcp.WithDescription("Delete a load balancer. Optionally cascade-deletes all child resources (listeners, pools, members, health monitors)."), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("loadbalancer_id", mcp.Required(), mcp.Description("The UUID of the load balancer to delete")), + mcp.WithBoolean("cascade", mcp.Description("If true, deletes all associated listeners, pools, members, and health monitors")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +func createLoadbalancerHandler(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 + } + + name := shared.StringParam(request, "name") + if name == "" { + return shared.ToolError("name is required"), nil + } + + vipSubnetID := shared.StringParam(request, "vip_subnet_id") + if vipSubnetID == "" { + return shared.ToolError("vip_subnet_id is required"), nil + } + if errResult := shared.ValidateUUID(vipSubnetID, "vip_subnet_id"); errResult != nil { + return errResult, nil + } + + preview := fmt.Sprintf("Will CREATE load balancer '%s' on subnet %s", name, vipSubnetID) + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + + createOpts := loadbalancers.CreateOpts{ + Name: name, + VipSubnetID: vipSubnetID, + Description: shared.StringParam(request, "description"), + } + + lb, err := loadbalancers.Create(ctx, client, createOpts).Extract() + if err != nil { + return shared.ToolError("failed to create load balancer: %v", err), nil + } + + lbResult := map[string]any{ + "id": lb.ID, + "name": lb.Name, + "vip_address": lb.VipAddress, + "vip_subnet_id": lb.VipSubnetID, + "operating_status": lb.OperatingStatus, + fieldProvisioningStatus: lb.ProvisioningStatus, + "provider": lb.Provider, + } + + out, err := json.MarshalIndent(lbResult, "", " ") + if err != nil { + return shared.ToolError("failed to marshal response: %v", err), nil + } + return shared.ToolResult(string(out)), nil + } +} + +func deleteLoadbalancerHandler(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 + } + + lbID := shared.StringParam(request, "loadbalancer_id") + if lbID == "" { + return shared.ToolError("loadbalancer_id is required"), nil + } + if errResult := shared.ValidateUUID(lbID, "loadbalancer_id"); errResult != nil { + return errResult, nil + } + + cascade := shared.BoolParam(request, "cascade") + + // Always fetch the LB to verify state and build preview. + lb, err := loadbalancers.Get(ctx, client, lbID).Extract() + if err != nil { + return shared.ToolError("failed to get load balancer %s: %v", lbID, err), nil + } + + preview := fmt.Sprintf("Will DELETE load balancer '%s' (%s), VIP: %s, status: %s", + lb.Name, lb.ID, lb.VipAddress, lb.ProvisioningStatus) + if cascade { + preview += " and ALL associated listeners, pools, members, and health monitors" + } + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + + err = loadbalancers.Delete(ctx, client, lbID, loadbalancers.DeleteOpts{Cascade: cascade}).ExtractErr() + if err != nil { + return shared.ToolError("failed to delete load balancer %s: %v", lbID, err), nil + } + + return shared.ToolResult("Successfully deleted load balancer " + lbID), nil + } +} diff --git a/internal/tools/shared/helpers.go b/internal/tools/shared/helpers.go index 87b1db3..833d9e9 100644 --- a/internal/tools/shared/helpers.go +++ b/internal/tools/shared/helpers.go @@ -125,3 +125,43 @@ func NumberParam(req mcp.CallToolRequest, key string) float64 { } return 0 } + +// BoolParam extracts a boolean parameter from an MCP request. +func BoolParam(req mcp.CallToolRequest, key string) bool { + args := req.GetArguments() + if args == nil { + return false + } + if v, ok := args[key]; ok { + if b, ok := v.(bool); ok { + return b + } + } + return false +} + +// RequireConfirmation checks the "confirmed" parameter in a tool request. +// If confirmed=false (or absent), returns a preview message asking the caller +// to re-invoke with confirmed=true. Returns nil when confirmed, meaning the +// handler should proceed with execution. +// +// Usage pattern in write handlers: +// +// preview := fmt.Sprintf("Will DELETE volume %q (%s, %dGiB)", name, id, size) +// if result := shared.RequireConfirmation(request, preview); result != nil { +// return result, nil +// } +// // proceed with actual deletion... +func RequireConfirmation(req mcp.CallToolRequest, preview string) *mcp.CallToolResult { + if BoolParam(req, "confirmed") { + return nil + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(fmt.Sprintf( + "CONFIRMATION REQUIRED\n\n%s\n\nTo proceed, re-call this tool with confirmed=true.", + SanitizeResponse(preview), + )), + }, + } +} diff --git a/internal/tools/shared/helpers_test.go b/internal/tools/shared/helpers_test.go index fe90d1a..35b8f46 100644 --- a/internal/tools/shared/helpers_test.go +++ b/internal/tools/shared/helpers_test.go @@ -5,6 +5,8 @@ package shared import ( "testing" + + "github.com/mark3labs/mcp-go/mcp" ) func TestValidateUUID_ValidUUIDs(t *testing.T) { @@ -181,3 +183,57 @@ func contains(s, substr string) bool { } return false } + +func TestBoolParam(t *testing.T) { + tests := []struct { + name string + args map[string]any + key string + want bool + }{ + {"true value", map[string]any{"confirmed": true}, "confirmed", true}, + {"false value", map[string]any{"confirmed": false}, "confirmed", false}, + {"missing key", map[string]any{"other": true}, "confirmed", false}, + {"nil args", nil, "confirmed", false}, + {"non-bool value", map[string]any{"confirmed": "true"}, "confirmed", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := mcp.CallToolRequest{} + if tt.args != nil { + req.Params.Arguments = tt.args + } + got := BoolParam(req, tt.key) + if got != tt.want { + t.Errorf("BoolParam() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRequireConfirmation_NotConfirmed(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{"confirmed": false} + + result := RequireConfirmation(req, "Will delete server 'web-1'") + if result == nil { + t.Fatal("RequireConfirmation should return non-nil when not confirmed") + } + text := result.Content[0].(mcp.TextContent).Text + if !contains(text, "CONFIRMATION REQUIRED") { + t.Errorf("expected confirmation message, got: %s", text) + } + if !contains(text, "Will delete server 'web-1'") { + t.Errorf("expected preview in message, got: %s", text) + } +} + +func TestRequireConfirmation_Confirmed(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{"confirmed": true} + + result := RequireConfirmation(req, "Will delete server 'web-1'") + if result != nil { + t.Fatal("RequireConfirmation should return nil when confirmed") + } +} diff --git a/internal/tools/swift/swift.go b/internal/tools/swift/swift.go index 8ad5196..aef3ec4 100644 --- a/internal/tools/swift/swift.go +++ b/internal/tools/swift/swift.go @@ -5,8 +5,10 @@ package swift import ( + "bytes" "context" "encoding/json" + "fmt" "github.com/gophercloud/gophercloud/v2/openstack/objectstorage/v1/containers" "github.com/gophercloud/gophercloud/v2/openstack/objectstorage/v1/objects" @@ -19,10 +21,15 @@ import ( ) // Register adds all Swift tools to the MCP server. -func Register(s *mcpserver.MCPServer, provider *auth.Provider) { +// When readOnly is true, mutating tools (upload/delete objects) are not registered. +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)) + if !readOnly { + s.AddTool(uploadObjectTool, uploadObjectHandler(provider)) + s.AddTool(deleteObjectTool, deleteObjectHandler(provider)) + } } var listContainersTool = mcp.NewTool("swift_list_containers", @@ -180,3 +187,118 @@ func getObjectMetadataHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc return shared.ToolResult(string(out)), nil } } + +// --- Write Tools --- + +var uploadObjectTool = mcp.NewTool("swift_upload_object", + mcp.WithDescription("Upload a text object to a container. Creates or overwrites the object unless safe_write is enabled."), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("container", mcp.Required(), mcp.Description("The name of the container")), + mcp.WithString("object", mcp.Required(), mcp.Description("The name (path) of the object")), + mcp.WithString("content", mcp.Required(), mcp.Description("The text content to upload")), + mcp.WithString("content_type", mcp.Description("Content type (default: application/octet-stream)")), + mcp.WithBoolean("safe_write", mcp.Description("If true, fails if object already exists (sets If-None-Match: *)")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +var deleteObjectTool = mcp.NewTool("swift_delete_object", + mcp.WithDescription("Delete an object from a container."), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("container", mcp.Required(), mcp.Description("The name of the container")), + mcp.WithString("object", mcp.Required(), mcp.Description("The name (path) of the object")), + mcp.WithBoolean("confirmed", mcp.Description("Set to true to execute. Without this, returns a preview of the action.")), +) + +func uploadObjectHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.ObjectStorageClient() + if err != nil { + return shared.ToolError("failed to get object storage client: %v", err), nil + } + + container := shared.StringParam(request, "container") + if errResult := shared.ValidatePathSegment(container, "container"); errResult != nil { + return errResult, nil + } + + object := shared.StringParam(request, "object") + if errResult := shared.ValidatePathSegment(object, "object"); errResult != nil { + return errResult, nil + } + + content := shared.StringParam(request, "content") + if content == "" { + return shared.ToolError("content is required"), nil + } + + contentType := shared.StringParam(request, "content_type") + if contentType == "" { + contentType = "application/octet-stream" + } + + safeWrite := shared.BoolParam(request, "safe_write") + + preview := fmt.Sprintf("Will UPLOAD object '%s' to container '%s', %d bytes, content_type: %s", + object, container, len(content), contentType) + if safeWrite { + preview += " (safe mode: will fail if object already exists)" + } + + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + + createOpts := objects.CreateOpts{ + Content: bytes.NewReader([]byte(content)), + ContentType: contentType, + } + if safeWrite { + createOpts.IfNoneMatch = "*" + } + + _, err = objects.Create(ctx, client, container, object, createOpts).Extract() + if err != nil { + return shared.ToolError("failed to upload object %s/%s: %v", container, object, err), nil + } + + return shared.ToolResult(fmt.Sprintf("Successfully uploaded object '%s' to container '%s' (%d bytes)", object, container, len(content))), nil + } +} + +func deleteObjectHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := provider.ObjectStorageClient() + if err != nil { + return shared.ToolError("failed to get object storage client: %v", err), nil + } + + container := shared.StringParam(request, "container") + if errResult := shared.ValidatePathSegment(container, "container"); errResult != nil { + return errResult, nil + } + + object := shared.StringParam(request, "object") + if errResult := shared.ValidatePathSegment(object, "object"); errResult != nil { + return errResult, nil + } + + // Always fetch metadata to verify existence and build preview. + header, err := objects.Get(ctx, client, container, object, nil).Extract() + if err != nil { + return shared.ToolError("failed to get object metadata %s/%s: %v", container, object, err), nil + } + + preview := fmt.Sprintf("Will DELETE object '%s' from container '%s' (size: %d bytes, content_type: %s)", + object, container, header.ContentLength, header.ContentType) + if result := shared.RequireConfirmation(request, preview); result != nil { + return result, nil + } + + _, err = objects.Delete(ctx, client, container, object, nil).Extract() + if err != nil { + return shared.ToolError("failed to delete object %s/%s: %v", container, object, err), nil + } + + return shared.ToolResult(fmt.Sprintf("Successfully deleted object '%s' from container '%s'", object, container)), nil + } +}