Key Takeaway
Backstage's MCP actions backend turns every registered Backstage action into an MCP tool at /api/mcp-actions/v1, and that default endpoint always exposes all of them. Secure it by filtering out destructive and write actions, preferring CIMD OAuth over a shared static token, and keeping tool payload tracing switched off.
I went looking for how an AI client is supposed to reach a Backstage portal and expected a thin wrapper around the catalog API. What the docs describe is different: a plugin that takes whatever other plugins have registered in an Actions Registry and republishes it as MCP tools, including the scaffolder's execute-template.
This post is a close reading of the Backstage MCP Actions documentation, the Actions and Actions Registry service docs, the plugin changelog, the catalog and scaffolder action source on the master branch, and the MCP authorization and tools specifications. I did not run this against a production portal, so every claim below is something I read, and the places where I am inferring are marked as inference.
What the MCP actions backend actually is
The package is @backstage/plugin-mcp-actions-backend. It does not define tools of its own. Backend plugins register actions with the Actions Registry service, and this plugin lists them and exposes them over Streamable HTTP at /api/mcp-actions/v1. You choose which plugins contribute with backend.actions.pluginSources.
# From the repo root
yarn --cwd packages/backend add @backstage/plugin-mcp-actions-backend
// packages/backend/src/index.ts
const backend = createBackend();
// ...
backend.add(import('@backstage/plugin-mcp-actions-backend'));
// ...
backend.start();
# app-config.yaml -- only plugins listed here have their actions
# discovered and exposed as MCP tools.
backend:
actions:
pluginSources:
- 'catalog'
- 'my-custom-plugin'Two details matter before any security work. First, the Actions Registry is documented as alpha, and the example imports come from @backstage/backend-plugin-api/alpha. Second, when I read the master branch the plugin's package.json said 0.2.3-next.1, so the docs I am quoting describe a moving target, not a frozen release. Pin your version and re-read the changelog on every upgrade.
The changelog also records one breaking change worth knowing: version 0.2.0 removed the deprecated Server-Sent Events transport, so clients must use the Streamable HTTP endpoint or a named-server endpoint. An older client config that still points at an SSE URL will simply stop working after that upgrade.
What an AI client gets by default
The most useful thing I did was read the registration code instead of the prose. Each action declares readOnly, destructive and idempotent attributes. The table below lists every action registered by the catalog and scaffolder plugins on master, with the values from the source files. Action IDs follow pluginId:actionName, and the MCP tool name replaces the colon with a dot by default, so catalog:get-catalog-entity becomes catalog.get-catalog-entity.
| Action ID | readOnly | destructive | idempotent |
|---|---|---|---|
| catalog:get-catalog-model-description | yes | no | yes |
| catalog:get-catalog-entity | yes | no | yes |
| catalog:query-catalog-entities | yes | no | yes |
| catalog:validate-entity | yes | no | yes |
| catalog:register-entity | no | no | no |
| catalog:refresh-catalog-entity | no | no | yes |
| catalog:unregister-entity | no | yes | yes |
| scaffolder:list-scaffolder-actions | yes | no | yes |
| scaffolder:list-scaffolder-tasks | yes | no | yes |
| scaffolder:get-scaffolder-task-logs | yes | no | yes |
| scaffolder:dry-run-template | yes | no | yes |
| scaffolder:execute-template | no | yes | no |
Read the two writes that are not marked destructive. register-entity creates a Location that points at a remote catalog-info.yaml, so an agent can make the catalog import whatever file a URL serves. It is declared destructive: false because it adds rather than deletes. That is a fair reading of the attribute, but it means a rule that only excludes destructive: true leaves register-entity available. This is my interpretation, not a documented warning.
The other detail is execute-template. Its description says the template runs with the credentials given to the action and respects RBAC attached to them, and tells the agent to fetch the Template entity first to discover its parameter schema and secrets definition. In the source, the optional secrets for the template are part of the action input, not a separate secrets schema, which is the opposite of what the registry docs recommend for credentials.
The default endpoint exposes everything
The docs say it plainly: the default server at /api/mcp-actions/v1 is always available and always exposes every registered action, whether or not mcpActions.servers is configured. Named servers are subsets of it. The 0.2.2 changelog explains the change: previously, configuring one named server replaced the default endpoint, and now both exist side by side.
# app-config.yaml
mcpActions:
name: 'Acme Backstage'
servers:
catalog-readonly: # served at /api/mcp-actions/v1/catalog-readonly
name: 'Backstage Catalog (read-only)'
description: 'Look up services, owners and APIs in the software catalog'
instructions: 'Inspect catalog entities before suggesting changes.'
filter:
include:
- id: 'catalog:*'
exclude: # exclude rules beat include rules
- attributes:
destructive: true # drops catalog:unregister-entity
- id: 'catalog:register-entity'
- id: 'catalog:refresh-catalog-entity'
# Client side: point the AI client at the NAMED endpoint, not the default one.
{
"mcpServers": {
"backstage-catalog": {
"url": "https://backstage.example.com/api/mcp-actions/v1/catalog-readonly",
"headers": { "Authorization": "Bearer ${MCP_TOKEN}" }
}
}
}That means a tidy named server is a convenience for the client, not a boundary. Anyone with a token that can call the default endpoint can still reach everything the plugin sources expose. Name servers for focus, as the docs intend, and put the enforcement elsewhere.
Adding a named read-only server does not remove the default endpoint. Before 0.2.2, adding one did replace it, so a config written against older behaviour may have been safe by accident and stops being safe after an upgrade. Re-test tools/list against the default URL after every upgrade.
Enforce at the Actions Service and the permission layer
The documentation gives you four places to say no, and they do different jobs. The table lists them with what each one is documented to stop. The service-level filter is the one that applies to the default endpoint, because it sits under the MCP plugin rather than inside one named server.
| Control | Where it lives | What it stops |
|---|---|---|
| pluginSources | backend.actions.pluginSources in app-config.yaml | Whole plugins contributing actions. A plugin not listed is never discovered as MCP tools. |
| Service filter | backend.actions.filter include and exclude rules | Individual actions by ID glob or attributes, for every consumer of the Actions Service including the default MCP endpoint. |
| Named server filter | mcpActions.servers.NAME.filter | What one named endpoint lists. Exclude rules win over include rules. A subset view, not a boundary. |
| visibilityPermission | On the action, checked by the permissions framework | Per caller visibility. A denied action disappears from listings and answers 404 on invocation. Actions without it stay visible to everyone. |
# app-config.yaml -- the service-level filter applies to the Actions Service
# itself, so it holds for every consumer, including the default MCP endpoint.
# Within one rule, id and attributes are ANDed; separate rules are ORed.
backend:
actions:
pluginSources:
- catalog
- scaffolder
filter:
exclude:
- attributes:
destructive: true # unregister-entity, execute-template
- id: 'catalog:register-entity' # declared destructive: false, still a writeThe filter semantics are documented on the Actions page: inside one rule, id and attributes are ANDed, and separate rules in the same include or exclude array are ORed. The MCP page only says its filters support glob patterns on IDs and attribute matching, so I would assume the same logic but verify it with a tools/list call against a test token before you trust it.
Authentication: static token, CIMD or DCR
Authentication is required by default. The docs offer three routes, and the honest summary is that only one of them is presented as the destination. Static tokens carry a warning that they are a temporary workaround until device authentication is completed, and DCR is labelled deprecated.
| Method | Documented status and requirements | Trade-off I see |
|---|---|---|
| Static token | Temporary workaround. Configured under backend.auth.externalAccess with a token, a subject and accessRestrictions. | One shared secret and one subject for every caller. The subject in the docs example is mcp-clients, so in logs everyone looks the same (my inference). |
| CIMD OAuth | Recommended. Needs the New Frontend System, auth-backend and the @backstage/plugin-auth frontend plugin. Enabled with auth.clientIdMetadataDocuments.enabled. | No token pasted into client settings, and the user approves in a Backstage popup. Costs a frontend migration if you are still on the old system. |
| DCR OAuth | Deprecated. Still works, logs a deprecation warning at startup. The MCP spec demoted DCR from SHOULD to MAY in its November 2025 revision. | Fine for an existing deployment, wrong for a new one. Plan the move to CIMD. |
# Static token -- the documented stopgap. Generate it, never hand-type it:
# node -p 'require("crypto").randomBytes(24).toString("base64")'
backend:
auth:
externalAccess:
- type: static
options:
token: ${MCP_TOKEN}
subject: mcp-clients # every caller shares this one identity
accessRestrictions:
- plugin: mcp-actions
- plugin: catalog
# CIMD -- the recommended path. Note the top-level "auth:" key.
auth:
clientIdMetadataDocuments:
enabled: true
# Setting this REPLACES the Claude / VS Code / ChatGPT Codex defaults.
# allowedClientIdPatterns:
# - 'https://claude.ai/*'
# Defaults to loopback addresses only.
# allowedRedirectUriPatterns:
# - 'http://localhost:*/*'The MCP authorization specification explains why CIMD is attractive and where it bites. A client uses an HTTPS URL as its client_id, and the authorization server fetches the metadata document at that URL, so the spec warns about SSRF and tells servers to validate that the document's client_id matches the URL and that redirect URIs match. It also admits that CIMD cannot stop localhost impersonation by itself: an attacker can present a legitimate client's metadata URL while receiving the code on a localhost port, which is why the spec asks servers to warn on localhost-only redirect URIs.
Backstage's knobs map to that threat model. allowedClientIdPatterns limits which client_id URLs are accepted, and setting it replaces the Claude, VS Code and ChatGPT Codex defaults entirely, so a custom list must include every client you still want. allowedRedirectUriPatterns defaults to loopback addresses. Tighten both to the clients your engineers actually use.
Writing your own action without regretting it
If your platform team adds actions, the registry gives you four safety levers and the example below uses all of them: honest attributes, a visibilityPermission, a secrets schema kept separate from input, and typed errors from @backstage/errors.
import { createPermission } from '@backstage/plugin-permission-common';
import { NotAllowedError, NotFoundError } from '@backstage/errors';
const restartPermission = createPermission({
name: 'my-plugin.actions.restartService',
attributes: { action: 'update' },
});
actionsRegistry.register({
// kebab-case verb; the plugin id is added for you (my-plugin:restart-service)
name: 'restart-service',
title: 'Restart Service',
description: 'Restarts one service from the catalog. Check the entity first.',
// BasicPermission only. Denied callers do not see the tool and get a 404.
visibilityPermission: restartPermission,
schema: {
input: z => z.object({ entityRef: z.string() }),
output: z => z.object({ restarted: z.boolean() }),
// Declared apart from input so it never lands in the tool definition
// or the model's context.
secrets: z =>
z.object({ deployToken: z.string().describe('Deploy API token') }),
},
// Defaults are conservative, but say it out loud: clients read these.
attributes: { destructive: true, idempotent: false, readOnly: false },
action: async ({ input, secrets, credentials, logger }) => {
logger.warn(`Restart requested for ${input.entityRef}`);
const svc = await lookupService(input.entityRef, credentials);
if (!svc) throw new NotFoundError(`No service ${input.entityRef}`);
if (!canRestart(credentials, svc)) {
throw new NotAllowedError('Not allowed to restart this service');
}
await restart(svc, secrets.deployToken);
return { output: { restarted: true } };
},
});The MCP tools specification explains why the attributes alone are not a control. It says clients must treat tool annotations as untrusted unless they come from trusted servers, and that there should always be a human in the loop able to deny invocations. Backstage's docs describe attributes as information that lets a client warn before a destructive call or skip confirmation on a read-only one. They are a hint to the client, so a filter or a permission has to do the enforcing.
Errors deserve a line. Known Backstage errors such as InputError and NotFoundError are caught and returned as isError: true tool results, while unrecognised error types may surface as a generic 500. Throw the typed ones so the model sees a message it can act on rather than an opaque failure.
Where this goes wrong
These are the traps I found in the sources, each one tied to a line in the docs, changelog or code rather than to an incident of mine.
- Config under the wrong key. auth.clientIdMetadataDocuments must sit under the top-level auth key. Put it under backend.auth and OAuth clients get invalid_client.
- Stale client IDs. VS Code can cache old OAuth client IDs, and the docs tell you to run Authentication: Remove Dynamic Authentication Providers and restart the server.
- Payload tracing left on. mcpActions.tracing.capture.toolPayload records tool arguments and results on the span, and the docs warn they may contain entity payloads and scaffolder inputs.
- A malformed action silently drops out. Since 0.1.14 the plugin validates each action against the MCP tool schema on tools/list, logs a warning and omits the bad action. Your new tool may be missing with no error in the client.
- Shared static token. One token and one subject for every engineer means that traces say a service principal acted, not who asked.
The common thread is that the safe path needs deliberate configuration, while the easy path, a static token against the default endpoint with every plugin source listed, is the one that exposes the most.
What to do on Monday
A sequence I would follow, in this order, on a non-production portal first.
- List backend.actions.pluginSources and remove every plugin you cannot defend. Start with catalog only.
- Add a backend.actions.filter that excludes destructive: true and the write actions that are not marked destructive, then call tools/list on the default endpoint and confirm they are gone.
- Switch to CIMD if you are on the New Frontend System. If not, use a static token with accessRestrictions and plan the migration, treating the token as short-lived.
- Add a visibilityPermission to every action you write yourself, and set attributes explicitly instead of trusting defaults.
- Turn on tracing and metrics, leave toolPayload off, and decide deliberately whether to enable backend.tracing.capture.endUser.
# Observability that is safe by default. Both flags default to false.
backend:
tracing:
capture:
endUser: true # adds enduser.id to spans; backend-wide, all plugins
mcpActions:
tracing:
capture:
toolPayload: false # keep OFF: arguments and results may hold sensitive data
# Then in your tracing backend, filter tools/call spans by:
# backstage.plugin.id which plugin owned the action (catalog, scaffolder)
# backstage.principal.type user | service | none
# gen_ai.tool.name which tool
# and chart mcp.server.operation.duration per tool.The plugin emits mcp.server.operation.duration and mcp.server.session.duration metrics, and its 0.2.1 release added audit events for connection, tool-discovery and tool-execution through the Backstage Auditor Service. Those are what let you answer who called which tool, which is the question a shared token cannot answer.
The rule I would carry out of this: in Backstage's MCP actions backend, a named server is a view and the Actions Service filter is the fence. Expose the smallest set of plugins, exclude writes at the service level, authenticate per user with CIMD, and treat action attributes as hints to the client, not as protection.
Sources and further reading
- Backstage docs: MCP Actions Backend
- Backstage docs: Actions Registry (alpha)
- Backstage docs: Actions service and filtering
- GitHub: mcp-actions-backend CHANGELOG
- GitHub: scaffolder execute-template action source
- GitHub: catalog unregister-entity action source
- MCP specification 2025-11-25: Authorization
- MCP specification 2025-06-18: Tools

