Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand All @@ -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

```
Expand Down
4 changes: 4 additions & 0 deletions internal/tools/archer/archer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
)

Expand Down
2 changes: 2 additions & 0 deletions internal/tools/barbican/barbican.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
)

Expand Down
3 changes: 3 additions & 0 deletions internal/tools/castellum/castellum.go
Original file line number Diff line number Diff line change
Expand Up @@ -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')")),
)
Expand Down
2 changes: 2 additions & 0 deletions internal/tools/cinder/cinder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
)

Expand Down
2 changes: 2 additions & 0 deletions internal/tools/cronus/cronus.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions internal/tools/designate/designate.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,21 @@ 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)")),
)

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.)")),
Expand Down
2 changes: 2 additions & 0 deletions internal/tools/glance/glance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")),
Expand All @@ -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")),
)

Expand Down
3 changes: 3 additions & 0 deletions internal/tools/hermes/hermes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")),
Expand All @@ -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'")),
)

Expand Down
2 changes: 2 additions & 0 deletions internal/tools/ironic/ironic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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')")),
Expand All @@ -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")),
)

Expand Down
3 changes: 3 additions & 0 deletions internal/tools/keppel/keppel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
)
Expand Down
5 changes: 5 additions & 0 deletions internal/tools/keystone/keystone.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.")),
Expand All @@ -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")),
)

Expand Down
3 changes: 3 additions & 0 deletions internal/tools/limes/limes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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')")),
Expand All @@ -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")),
)

Expand Down
3 changes: 3 additions & 0 deletions internal/tools/maia/maia.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions internal/tools/manila/manila.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ 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)")),
)

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")),
)

Expand Down
4 changes: 4 additions & 0 deletions internal/tools/neutron/neutron.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,28 @@ 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)")),
)

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")),
)

Expand Down
Loading
Loading