From 9728b7c9d46cff4afc6f8b043d92e0a283202953 Mon Sep 17 00:00:00 2001 From: Nathan Oyler Date: Wed, 6 May 2026 19:40:52 -0700 Subject: [PATCH] feat: add MCP tool annotations for human-in-the-loop safety All 54 tools now declare their intent via MCP protocol annotations: - 51 read-only tools: ReadOnlyHint=true (clients may auto-approve) - 3 destructive tools: DestructiveHint=true (clients prompt user) This enables MCP clients (Claude Code, Cursor) to enforce confirmation dialogs before executing server actions or credential mutations, even when MCP_READ_ONLY=false allows the tools to be registered. Also updates README Security section to document the three-layer safety architecture: read-only mode + tool annotations + credential isolation. --- README.md | 17 +++++++++++++++++ internal/tools/archer/archer.go | 4 ++++ internal/tools/barbican/barbican.go | 2 ++ internal/tools/castellum/castellum.go | 3 +++ internal/tools/cinder/cinder.go | 2 ++ internal/tools/cronus/cronus.go | 2 ++ internal/tools/designate/designate.go | 3 +++ internal/tools/glance/glance.go | 2 ++ internal/tools/hermes/hermes.go | 3 +++ internal/tools/ironic/ironic.go | 2 ++ internal/tools/keppel/keppel.go | 3 +++ internal/tools/keystone/keystone.go | 5 +++++ internal/tools/limes/limes.go | 3 +++ internal/tools/maia/maia.go | 3 +++ internal/tools/manila/manila.go | 2 ++ internal/tools/neutron/neutron.go | 4 ++++ internal/tools/nova/nova.go | 4 ++++ internal/tools/octavia/octavia.go | 4 ++++ internal/tools/swift/swift.go | 3 +++ 19 files changed, 71 insertions(+) diff --git a/README.md b/README.md index f8312dd..bba9568 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,14 @@ Add to your `~/.claude/settings.json`: ## Security +### Three-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 | + ### Read-Only Mode (Default) By default, mutating tools are **disabled**: @@ -105,6 +113,15 @@ By default, mutating tools are **disabled**: Set `MCP_READ_ONLY=false` only when you explicitly need write operations. +### 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** (51 tools): Annotated with `readOnlyHint: true`. Clients may auto-approve these. +- **Destructive tools** (3 tools): Annotated with `destructiveHint: true`. Clients **must prompt the user** before execution. + +This means even when `MCP_READ_ONLY=false` enables destructive tools, the MCP client (Claude Code, Cursor, etc.) will still ask "Allow this action?" before executing server actions or credential mutations. The server declares, the client enforces. + ### Credential Isolation Architecture ``` diff --git a/internal/tools/archer/archer.go b/internal/tools/archer/archer.go index a6cd53b..e106d87 100644 --- a/internal/tools/archer/archer.go +++ b/internal/tools/archer/archer.go @@ -29,22 +29,26 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var listServicesTool = mcp.NewTool("archer_list_services", mcp.WithDescription("List Archer services available for endpoint creation. Services are private/public network resources accessible through endpoints."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("status", mcp.Description("Filter by service status")), ) var listEndpointsTool = mcp.NewTool("archer_list_endpoints", mcp.WithDescription("List Archer endpoints in the current project. Endpoints provide local IP access to remote services."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("service_id", mcp.Description("Filter by the service this endpoint connects to")), mcp.WithString("status", mcp.Description("Filter by endpoint status")), ) var getServiceTool = mcp.NewTool("archer_get_service", mcp.WithDescription("Get details of a specific Archer service."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("service_id", mcp.Required(), mcp.Description("The UUID of the service")), ) var getEndpointTool = mcp.NewTool("archer_get_endpoint", mcp.WithDescription("Get details of a specific Archer endpoint."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("endpoint_id", mcp.Required(), mcp.Description("The UUID of the endpoint")), ) diff --git a/internal/tools/barbican/barbican.go b/internal/tools/barbican/barbican.go index 96fab67..a09ac96 100644 --- a/internal/tools/barbican/barbican.go +++ b/internal/tools/barbican/barbican.go @@ -25,12 +25,14 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var listSecretsTool = mcp.NewTool("barbican_list_secrets", mcp.WithDescription("List secrets stored in the key manager. Returns metadata only (secret_ref, name, status, secret_type, algorithm, bit_length, created, expiration). The secret payload is never returned for security."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("name", mcp.Description("Filter by secret name")), mcp.WithString("secret_type", mcp.Description("Filter by secret type (symmetric, public, private, passphrase, certificate, opaque)")), ) var getSecretTool = mcp.NewTool("barbican_get_secret", mcp.WithDescription("Get metadata for a specific secret. Returns name, status, secret_type, algorithm, bit_length, mode, created, updated, expiration, and content_types. The secret payload is never returned for security."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("secret_id", mcp.Required(), mcp.Description("The UUID of the secret to retrieve")), ) diff --git a/internal/tools/castellum/castellum.go b/internal/tools/castellum/castellum.go index 65dabc9..6b1051f 100644 --- a/internal/tools/castellum/castellum.go +++ b/internal/tools/castellum/castellum.go @@ -30,17 +30,20 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var getProjectResourcesTool = mcp.NewTool("castellum_get_project_resources", mcp.WithDescription("Get autoscaling configuration and resource status for a project from Castellum."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("project_id", mcp.Required(), mcp.Description("The UUID of the project to retrieve autoscaling configuration for")), ) var listPendingOperationsTool = mcp.NewTool("castellum_list_pending_operations", mcp.WithDescription("List pending resize operations in Castellum. These are operations that have been scheduled but not yet completed."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("project_id", mcp.Description("Filter by project UUID")), mcp.WithString("asset_type", mcp.Description("Filter by asset type (e.g., 'project-quota:compute:cores')")), ) var listRecentlyFailedOperationsTool = mcp.NewTool("castellum_list_recently_failed_operations", mcp.WithDescription("List recently failed resize operations in Castellum. Useful for diagnosing autoscaling issues."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("project_id", mcp.Description("Filter by project UUID")), mcp.WithString("asset_type", mcp.Description("Filter by asset type (e.g., 'project-quota:compute:cores')")), ) diff --git a/internal/tools/cinder/cinder.go b/internal/tools/cinder/cinder.go index 3d5a982..60c7f8f 100644 --- a/internal/tools/cinder/cinder.go +++ b/internal/tools/cinder/cinder.go @@ -25,12 +25,14 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var listVolumesTool = mcp.NewTool("cinder_list_volumes", mcp.WithDescription("List block storage volumes in the current project. Returns volume ID, name, status, size, type, and attachments."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("status", mcp.Description("Filter by volume status (available, in-use, error, creating, deleting)")), mcp.WithString("name", mcp.Description("Filter by volume name")), ) var getVolumeTool = mcp.NewTool("cinder_get_volume", mcp.WithDescription("Get detailed information about a specific block storage volume."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("volume_id", mcp.Required(), mcp.Description("The UUID of the volume to retrieve")), ) diff --git a/internal/tools/cronus/cronus.go b/internal/tools/cronus/cronus.go index 3a4f899..87550ad 100644 --- a/internal/tools/cronus/cronus.go +++ b/internal/tools/cronus/cronus.go @@ -26,10 +26,12 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var getUsageTool = mcp.NewTool("cronus_get_usage", mcp.WithDescription("Get email sending usage and status for the current project from Cronus."), + mcp.WithReadOnlyHintAnnotation(true), ) var listTemplatesTool = mcp.NewTool("cronus_list_templates", mcp.WithDescription("List available email templates in Cronus for the current project."), + mcp.WithReadOnlyHintAnnotation(true), ) func getUsageHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { diff --git a/internal/tools/designate/designate.go b/internal/tools/designate/designate.go index 106e71c..e72abe1 100644 --- a/internal/tools/designate/designate.go +++ b/internal/tools/designate/designate.go @@ -27,6 +27,7 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var listZonesTool = mcp.NewTool("designate_list_zones", mcp.WithDescription("List DNS zones in the current project. Returns zone ID, name, email, TTL, status, type, serial, and created_at."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("name", mcp.Description("Filter by zone name")), mcp.WithString("status", mcp.Description("Filter by zone status (ACTIVE, PENDING, ERROR)")), mcp.WithString("type", mcp.Description("Filter by zone type (PRIMARY, SECONDARY)")), @@ -34,11 +35,13 @@ var listZonesTool = mcp.NewTool("designate_list_zones", var getZoneTool = mcp.NewTool("designate_get_zone", mcp.WithDescription("Get detailed information about a specific DNS zone."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("zone_id", mcp.Required(), mcp.Description("The UUID of the zone to retrieve")), ) var listRecordsetsTool = mcp.NewTool("designate_list_recordsets", mcp.WithDescription("List DNS recordsets in a zone. Returns recordset ID, name, type, records, TTL, and status."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("zone_id", mcp.Required(), mcp.Description("The UUID of the zone to list recordsets for")), mcp.WithString("name", mcp.Description("Filter by recordset name")), mcp.WithString("type", mcp.Description("Filter by recordset type (A, AAAA, CNAME, MX, TXT, etc.)")), diff --git a/internal/tools/glance/glance.go b/internal/tools/glance/glance.go index 252f1d9..56236d0 100644 --- a/internal/tools/glance/glance.go +++ b/internal/tools/glance/glance.go @@ -25,6 +25,7 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var listImagesTool = mcp.NewTool("glance_list_images", mcp.WithDescription("List images available in the image service. Returns ID, name, status, visibility, disk/container format, and size."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("name", mcp.Description("Filter by image name")), mcp.WithString("status", mcp.Description("Filter by image status (queued, saving, active, killed, deleted, deactivated)")), mcp.WithString("visibility", mcp.Description("Filter by visibility (public, private, shared, community)")), @@ -33,6 +34,7 @@ var listImagesTool = mcp.NewTool("glance_list_images", var getImageTool = mcp.NewTool("glance_get_image", mcp.WithDescription("Get detailed information about a specific image."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("image_id", mcp.Required(), mcp.Description("The UUID of the image to retrieve")), ) diff --git a/internal/tools/hermes/hermes.go b/internal/tools/hermes/hermes.go index 59d301e..e2de763 100644 --- a/internal/tools/hermes/hermes.go +++ b/internal/tools/hermes/hermes.go @@ -29,6 +29,7 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var listEventsTool = mcp.NewTool("hermes_list_events", mcp.WithDescription("List audit events from the Hermes audit trail. Events are in CADF format covering all OpenStack and SAP CC service actions."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("target_type", mcp.Description("Filter by target resource type (e.g., 'compute/server', 'network/port', 'identity/project')")), mcp.WithString("target_id", mcp.Description("Filter by target resource UUID")), mcp.WithString("initiator_name", mcp.Description("Filter by who performed the action (username)")), @@ -42,11 +43,13 @@ var listEventsTool = mcp.NewTool("hermes_list_events", var getEventTool = mcp.NewTool("hermes_get_event", mcp.WithDescription("Get a specific audit event by its ID. Returns full CADF event details."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("event_id", mcp.Required(), mcp.Description("The UUID of the audit event")), ) var listAttributesTool = mcp.NewTool("hermes_list_attributes", mcp.WithDescription("List available attribute values for filtering audit events (e.g., all known target_types, actions, or observers)."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("attribute", mcp.Required(), mcp.Description("Attribute to list values for: 'target_type', 'action', 'outcome', 'observer_type', 'initiator_type'")), ) diff --git a/internal/tools/ironic/ironic.go b/internal/tools/ironic/ironic.go index c0ab9f7..0005baf 100644 --- a/internal/tools/ironic/ironic.go +++ b/internal/tools/ironic/ironic.go @@ -25,6 +25,7 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var listNodesTool = mcp.NewTool("ironic_list_nodes", mcp.WithDescription("List baremetal nodes in Ironic. Returns UUID, name, provision state, power state, maintenance status, driver, resource class, and instance UUID."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("provision_state", mcp.Description("Filter by provision state (e.g., 'active', 'available', 'deploying', 'error')")), mcp.WithString("maintenance", mcp.Description("Filter by maintenance mode ('true' to show only nodes in maintenance)")), mcp.WithString("driver", mcp.Description("Filter by driver name (e.g., 'ipmi', 'redfish')")), @@ -33,6 +34,7 @@ var listNodesTool = mcp.NewTool("ironic_list_nodes", var getNodeTool = mcp.NewTool("ironic_get_node", mcp.WithDescription("Get detailed information about a specific baremetal node."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("node_id", mcp.Required(), mcp.Description("The UUID or name of the baremetal node")), ) diff --git a/internal/tools/keppel/keppel.go b/internal/tools/keppel/keppel.go index 3543928..108997d 100644 --- a/internal/tools/keppel/keppel.go +++ b/internal/tools/keppel/keppel.go @@ -27,15 +27,18 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var listAccountsTool = mcp.NewTool("keppel_list_accounts", mcp.WithDescription("List Keppel container registry accounts. Each account is a namespace with dedicated backing storage."), + mcp.WithReadOnlyHintAnnotation(true), ) var listReposTool = mcp.NewTool("keppel_list_repositories", mcp.WithDescription("List repositories within a Keppel account. Shows image repositories with manifest and tag counts."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("account", mcp.Required(), mcp.Description("The account name to list repositories for")), ) var listManifestsTool = mcp.NewTool("keppel_list_manifests", mcp.WithDescription("List manifests (image versions) in a repository. Shows tags, digest, size, and vulnerability status."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("account", mcp.Required(), mcp.Description("The account name")), mcp.WithString("repository", mcp.Required(), mcp.Description("The repository name within the account")), ) diff --git a/internal/tools/keystone/keystone.go b/internal/tools/keystone/keystone.go index b4d13e5..1705c53 100644 --- a/internal/tools/keystone/keystone.go +++ b/internal/tools/keystone/keystone.go @@ -38,16 +38,19 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { var listProjectsTool = mcp.NewTool("keystone_list_projects", mcp.WithDescription("List projects (tenants) accessible to the current user. Returns project ID, name, domain, and enabled status."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("domain_id", mcp.Description("Filter by domain ID")), mcp.WithString("name", mcp.Description("Filter by project name")), ) var tokenInfoTool = mcp.NewTool("keystone_token_info", mcp.WithDescription("Get information about the current authentication context: user, project, domain, roles, and service catalog. Note: the actual token value is never exposed."), + mcp.WithReadOnlyHintAnnotation(true), ) var createAppCredentialTool = mcp.NewTool("keystone_create_application_credential", mcp.WithDescription("Create an application credential for the current user. Application credentials allow authentication without exposing your main password — ideal for MCP server configuration. IMPORTANT: The secret is only shown once at creation time. Save it immediately. Best practice: call keystone_list_application_credentials first to check for existing credentials before creating a new one."), + mcp.WithDestructiveHintAnnotation(true), mcp.WithString("name", mcp.Required(), mcp.Description("Name for the application credential (must be unique per user)")), 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.")), @@ -56,11 +59,13 @@ var createAppCredentialTool = mcp.NewTool("keystone_create_application_credentia var listAppCredentialsTool = mcp.NewTool("keystone_list_application_credentials", mcp.WithDescription("List application credentials for the current user. Shows ID, name, description, roles, and expiration. Secrets are never shown (only available at creation time)."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("name", mcp.Description("Filter by application credential name")), ) var deleteAppCredentialTool = mcp.NewTool("keystone_delete_application_credential", 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")), ) diff --git a/internal/tools/limes/limes.go b/internal/tools/limes/limes.go index c27dd58..d28c0db 100644 --- a/internal/tools/limes/limes.go +++ b/internal/tools/limes/limes.go @@ -27,6 +27,7 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var getProjectQuotaTool = mcp.NewTool("limes_get_project_quota", mcp.WithDescription("Get quota and usage report for a specific project. Shows all services (compute, network, storage, etc.) with their quota limits, current usage, and physical usage."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("domain_id", mcp.Required(), mcp.Description("The domain ID containing the project")), mcp.WithString("project_id", mcp.Required(), mcp.Description("The project ID to get quota for")), mcp.WithString("service", mcp.Description("Filter by service type (e.g., 'compute', 'network', 'object-store')")), @@ -35,12 +36,14 @@ var getProjectQuotaTool = mcp.NewTool("limes_get_project_quota", var getDomainQuotaTool = mcp.NewTool("limes_get_domain_quota", mcp.WithDescription("Get aggregated quota and usage report for all projects in a domain."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("domain_id", mcp.Required(), mcp.Description("The domain ID to get quota for")), mcp.WithString("service", mcp.Description("Filter by service type")), ) var getClusterQuotaTool = mcp.NewTool("limes_get_cluster_quota", mcp.WithDescription("Get cluster-wide capacity and usage information. Shows total capacity, used capacity, and remaining capacity per service."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("service", mcp.Description("Filter by service type")), ) diff --git a/internal/tools/maia/maia.go b/internal/tools/maia/maia.go index ad0ad43..ce9bbea 100644 --- a/internal/tools/maia/maia.go +++ b/internal/tools/maia/maia.go @@ -28,17 +28,20 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var queryTool = mcp.NewTool("maia_query", mcp.WithDescription("Execute a PromQL query against Maia (multi-tenant Prometheus). Returns instant query results scoped to the current project."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("query", mcp.Required(), mcp.Description("PromQL expression to evaluate (e.g., 'up', 'node_cpu_seconds_total{mode=\"idle\"}')")), mcp.WithString("time", mcp.Description("Evaluation timestamp (RFC3339 or Unix). Defaults to current time.")), ) var labelValuesTool = mcp.NewTool("maia_label_values", mcp.WithDescription("Get all values for a specific Prometheus label in Maia. Useful for discovering available metrics and dimensions."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("label", mcp.Required(), mcp.Description("The label name to get values for (e.g., '__name__' for metric names, 'instance', 'job')")), ) var metricNamesTool = mcp.NewTool("maia_metric_names", mcp.WithDescription("List all available metric names in Maia for the current project scope."), + mcp.WithReadOnlyHintAnnotation(true), ) func queryHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { diff --git a/internal/tools/manila/manila.go b/internal/tools/manila/manila.go index 4ceb594..98c5c90 100644 --- a/internal/tools/manila/manila.go +++ b/internal/tools/manila/manila.go @@ -25,6 +25,7 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var listSharesTool = mcp.NewTool("manila_list_shares", mcp.WithDescription("List shared file system shares in the current project. Returns share ID, name, status, protocol, size, and availability zone."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("name", mcp.Description("Filter by share name")), mcp.WithString("status", mcp.Description("Filter by share status (available, error, creating, deleting, error_deleting)")), mcp.WithString("share_proto", mcp.Description("Filter by share protocol (NFS, CIFS, GlusterFS, HDFS, CephFS)")), @@ -32,6 +33,7 @@ var listSharesTool = mcp.NewTool("manila_list_shares", var getShareTool = mcp.NewTool("manila_get_share", mcp.WithDescription("Get detailed information about a specific shared file system share."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("share_id", mcp.Required(), mcp.Description("The UUID of the share to retrieve")), ) diff --git a/internal/tools/neutron/neutron.go b/internal/tools/neutron/neutron.go index a7fd225..6356879 100644 --- a/internal/tools/neutron/neutron.go +++ b/internal/tools/neutron/neutron.go @@ -30,17 +30,20 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var listNetworksTool = mcp.NewTool("neutron_list_networks", mcp.WithDescription("List networks in the current project. Returns network ID, name, status, subnets, and admin state."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("name", mcp.Description("Filter by network name")), mcp.WithString("status", mcp.Description("Filter by network status (ACTIVE, DOWN, BUILD, ERROR)")), ) var listSubnetsTool = mcp.NewTool("neutron_list_subnets", mcp.WithDescription("List subnets in the current project. Returns subnet ID, name, CIDR, gateway, and network ID."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("network_id", mcp.Description("Filter by network ID")), ) var listPortsTool = mcp.NewTool("neutron_list_ports", mcp.WithDescription("List ports in the current project. Returns port ID, name, status, MAC, fixed IPs, and device owner."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("network_id", mcp.Description("Filter by network ID")), mcp.WithString("device_id", mcp.Description("Filter by device (server) ID")), mcp.WithString("status", mcp.Description("Filter by port status (ACTIVE, DOWN, BUILD)")), @@ -48,6 +51,7 @@ var listPortsTool = mcp.NewTool("neutron_list_ports", var listSecGroupsTool = mcp.NewTool("neutron_list_security_groups", mcp.WithDescription("List security groups in the current project with their rules."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("name", mcp.Description("Filter by security group name")), ) diff --git a/internal/tools/nova/nova.go b/internal/tools/nova/nova.go index 7161f4b..b739f79 100644 --- a/internal/tools/nova/nova.go +++ b/internal/tools/nova/nova.go @@ -34,6 +34,7 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider, readOnly bool) { var listServersTool = mcp.NewTool("nova_list_servers", mcp.WithDescription("List compute instances (servers) in the current project. Returns server ID, name, status, addresses, and flavor."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("status", mcp.Description("Filter by server status (ACTIVE, SHUTOFF, ERROR, BUILD, etc.)")), mcp.WithString("name", mcp.Description("Filter by server name (regex supported)")), mcp.WithNumber("limit", mcp.Description("Maximum number of servers to return (default: 100)")), @@ -41,15 +42,18 @@ var listServersTool = mcp.NewTool("nova_list_servers", var getServerTool = mcp.NewTool("nova_get_server", mcp.WithDescription("Get detailed information about a specific compute instance by ID."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("server_id", mcp.Required(), mcp.Description("The UUID of the server to retrieve")), ) var listFlavorsTool = mcp.NewTool("nova_list_flavors", mcp.WithDescription("List available compute flavors (instance types) with their specs: vCPUs, RAM, disk."), + mcp.WithReadOnlyHintAnnotation(true), ) var serverActionTool = mcp.NewTool("nova_server_action", mcp.WithDescription("Perform an action on a compute instance: start, stop, reboot, pause, unpause, suspend, resume."), + mcp.WithDestructiveHintAnnotation(true), 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.")), diff --git a/internal/tools/octavia/octavia.go b/internal/tools/octavia/octavia.go index e211146..b77525b 100644 --- a/internal/tools/octavia/octavia.go +++ b/internal/tools/octavia/octavia.go @@ -31,6 +31,7 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var listLoadbalancersTool = mcp.NewTool("octavia_list_loadbalancers", mcp.WithDescription("List load balancers in the current project. Returns ID, name, status, VIP address, and provider."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("name", mcp.Description("Filter by load balancer name")), mcp.WithString("provisioning_status", mcp.Description("Filter by provisioning status (ACTIVE, PENDING_CREATE, ERROR)")), mcp.WithString("vip_address", mcp.Description("Filter by virtual IP address")), @@ -38,6 +39,7 @@ var listLoadbalancersTool = mcp.NewTool("octavia_list_loadbalancers", var getLoadbalancerTool = mcp.NewTool("octavia_get_loadbalancer", mcp.WithDescription("Get detailed information about a specific load balancer."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("loadbalancer_id", mcp.Required(), mcp.Description("The UUID of the load balancer to retrieve")), ) @@ -120,6 +122,7 @@ func getLoadbalancerHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { var listListenersTool = mcp.NewTool("octavia_list_listeners", mcp.WithDescription("List load balancer listeners. Returns ID, name, protocol, port, and associated load balancers."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("name", mcp.Description("Filter by listener name")), mcp.WithString("protocol", mcp.Description("Filter by protocol (TCP, HTTP, HTTPS, TERMINATED_HTTPS, UDP, SCTP)")), mcp.WithString("loadbalancer_id", mcp.Description("Filter by load balancer UUID")), @@ -182,6 +185,7 @@ func listListenersHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { var listPoolsTool = mcp.NewTool("octavia_list_pools", mcp.WithDescription("List load balancer pools. Returns ID, name, protocol, LB algorithm, and status."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("name", mcp.Description("Filter by pool name")), mcp.WithString("protocol", mcp.Description("Filter by protocol (TCP, HTTP, HTTPS, PROXY, UDP, SCTP)")), mcp.WithString("loadbalancer_id", mcp.Description("Filter by load balancer UUID")), diff --git a/internal/tools/swift/swift.go b/internal/tools/swift/swift.go index 3392fc8..8ad5196 100644 --- a/internal/tools/swift/swift.go +++ b/internal/tools/swift/swift.go @@ -27,12 +27,14 @@ func Register(s *mcpserver.MCPServer, provider *auth.Provider) { var listContainersTool = mcp.NewTool("swift_list_containers", mcp.WithDescription("List object storage containers in the current account. Returns container name, object count, and total bytes."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("prefix", mcp.Description("Filter containers by name prefix")), mcp.WithNumber("limit", mcp.Description("Maximum number of containers to return (default 100)")), ) var listObjectsTool = mcp.NewTool("swift_list_objects", mcp.WithDescription("List objects in a container. Returns object name, size in bytes, content_type, last_modified, and hash."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("container", mcp.Required(), mcp.Description("The name of the container to list objects from")), mcp.WithString("prefix", mcp.Description("Filter objects by name prefix")), mcp.WithString("delimiter", mcp.Description("Delimiter for pseudo-directory listings (e.g. '/')")), @@ -41,6 +43,7 @@ var listObjectsTool = mcp.NewTool("swift_list_objects", var getObjectMetadataTool = mcp.NewTool("swift_get_object_metadata", mcp.WithDescription("Get metadata for a specific object (not the object content). Returns content_type, content_length, etag, and last_modified."), + mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("container", mcp.Required(), mcp.Description("The name of the container")), mcp.WithString("object", mcp.Required(), mcp.Description("The name of the object")), )