Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/bright-mcp-agent-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/plugin-mcp-actions-backend': patch
---

Added support for Agent Skills. Set `mcpActions.skills.directory`, or `skills.directory` on a named server, to a directory of skills, and the MCP server serves them with the MCP skills extension. See the [MCP Actions documentation](https://backstage.io/docs/ai/mcp-actions#agent-skills).
59 changes: 59 additions & 0 deletions docs/ai/mcp-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,65 @@ mcpActions:

For named servers, configure instructions separately for each server.

## Agent Skills

Instructions suit a few paragraphs of guidance. For longer guidance, such as how your catalog is modeled or which filters find the components of a system, use [Agent Skills](https://agentskills.io/specification). An Agent Skill is a folder of Markdown instructions that an AI agent reads only when a task needs it.

The MCP server serves skills with the [MCP skills extension](https://modelcontextprotocol.io/extensions/skills/overview). Clients that support the extension list the skills with `skills/list` and load a skill on demand. Other clients see the `SKILL.md` of each skill in the MCP resource list, and can read the other files of a skill by URI. Each file has a `skill://` URI, for example `skill://find-entities/SKILL.md`.

Put one folder per skill in a directory. Each folder holds a `SKILL.md` file and the files that it refers to:

```text
mcp-skills/
└── find-entities/
├── SKILL.md
└── references/
└── FILTERS.md
```

`SKILL.md` starts with YAML frontmatter. The `name` must match the folder name and use only lowercase letters, digits and hyphens. Clients use the `description` to decide when to load the skill:

```markdown title="mcp-skills/find-entities/SKILL.md"
---
name: find-entities
description: Find catalog entities of this Backstage instance with the catalog tools.
---

# Find entities

Use `catalog.query-catalog-entities` and filter on `kind` first.
The filters for each kind are in `references/FILTERS.md`.
```

Then point the default server to the directory:

```yaml title="app-config.yaml"
mcpActions:
skills:
directory: ./mcp-skills
```

A relative path resolves from the working directory of the backend process. With `yarn start`, this is `packages/backend`. In a container image, it is the `WORKDIR`, so copy the directory into the image. For example, with the default backend Dockerfile (`WORKDIR /app`), add `COPY packages/backend/mcp-skills ./mcp-skills`.

Named servers do not inherit `mcpActions.skills`. Set `skills.directory` on each named server that serves skills:

```yaml title="app-config.yaml"
mcpActions:
servers:
catalog:
name: 'Backstage Catalog'
skills:
directory: ./mcp-skills
```

The server lists the skills and computes a digest of each file at startup. To change a skill, deploy the change and restart the backend. If a file changes on disk without a restart, reads of that file fail until the next restart.

A skill that does not follow the specification, for example a `name` that does not match its folder, is skipped with a warning in the backend logs. A skill that contains a symbolic link is skipped. For example, the files of a Kubernetes ConfigMap mounted as a volume are symbolic links. If the directory, a subfolder or a file cannot be read, the plugin fails at startup.

:::note
Skills are not filtered by the permission framework. Every user who can connect to the MCP server can read every skill of that server. Do not put secrets in skills. Each read of a skill file emits a `skill-read` audit event. Each request that lists skills or their files (`skills/list`, `skills/get`, `resources/list` and `resources/directory/read`) emits a `skill-discovery` audit event.
:::

## Namespaced Tool Names

By default, MCP tool names include the plugin ID prefix to avoid collisions across plugins. For example, an action registered as `greet-user` by `my-custom-plugin` is exposed as `my-custom-plugin.greet-user`.
Expand Down
1 change: 1 addition & 0 deletions plugins/mcp-actions-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ For full documentation on configuring and using this plugin, see the [MCP Action

- Configuring which actions to expose
- Server name, description, and instructions
- Agent Skills
- Namespaced tool names
- Multiple MCP servers with filter rules
- Authentication (CIMD, static tokens)
Expand Down
20 changes: 20 additions & 0 deletions plugins/mcp-actions-backend/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ export interface Config {
*/
instructions?: string;

/**
* Agent Skills served with the MCP skills extension on the default /v1
* server. Named servers do not inherit this value.
*/
skills?: {
/**
* Directory that holds one folder per skill, each with a SKILL.md file.
* The skills are listed at startup.
*/
directory: string;
};

/**
* When true, MCP tool names include the plugin ID prefix to avoid
* collisions across plugins. For example an action registered as
Expand Down Expand Up @@ -70,6 +82,14 @@ export interface Config {
description?: string;
/** Instructions describing how clients should use the MCP server. */
instructions?: string;
/** Agent Skills served with the MCP skills extension. */
skills?: {
/**
* Directory that holds one folder per skill, each with a SKILL.md
* file. The skills are listed at startup.
*/
directory: string;
};
/** Filter rules to include or exclude specific actions. */
filter?: {
include?: Array<{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
name: find-entities
description: Find catalog entities with the catalog tools of this server.
---

# Find entities
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
name: another-name
description: The name does not match the directory name.
---

# Wrong name
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
name: find-entities
description: Find catalog entities with the catalog tools of this server.
---

# Find entities

Use `catalog.query-catalog-entities` with a filter. See `references/FILTERS.md`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Filters

Filter on `kind`, then on `spec.type`.
2 changes: 2 additions & 0 deletions plugins/mcp-actions-backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type McpServerConfig = {
name: string;
description?: string;
instructions?: string;
skillsDirectory?: string;
includeRules: FilterRule[];
excludeRules: FilterRule[];
};
Expand Down Expand Up @@ -90,6 +91,7 @@ export function parseServerConfigs(
name: serverConfig.getString('name'),
description: serverConfig.getOptionalString('description'),
instructions: serverConfig.getOptionalString('instructions'),
skillsDirectory: serverConfig.getOptionalString('skills.directory'),
includeRules,
excludeRules,
});
Expand Down
85 changes: 85 additions & 0 deletions plugins/mcp-actions-backend/src/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ import {
Client,
StreamableHTTPClientTransport,
} from '@modelcontextprotocol/client';
import { SkillsClient } from '@modelcontextprotocol/client/ext/skills';
import { resolve } from 'node:path';
import request from 'supertest';
import waitForExpect from 'wait-for-expect';
import type { JsonObject } from '@backstage/types';

describe('Mcp Backend', () => {
const mockPluginWithActions = createBackendPlugin({
Expand Down Expand Up @@ -673,4 +676,86 @@ describe('Mcp Backend', () => {
.expect(200);
});
});

describe('skills', () => {
const skillsDirectory = resolve(__dirname, '__fixtures__/skills');

const startBackend = async (mcpActions: JsonObject) => {
const { server } = await startTestBackend({
features: [
mcpPlugin,
mockPluginWithActions,
metricsServiceMock.mock().factory,
tracingServiceMock.mock().factory,
mockServices.rootConfig.factory({
data: {
backend: { actions: { pluginSources: ['local'] } },
mcpActions,
},
}),
],
});
const address = server.address();
if (typeof address !== 'object' || !('port' in address!)) {
throw new Error('server broke');
}
return `http://localhost:${address.port}/api/mcp-actions`;
};

const listSkills = async (url: string, pinModern = false) => {
const client = new Client(
{ name: 'test client', version: '1.0' },
pinModern
? { versionNegotiation: { mode: { pin: '2026-07-28' } } }
: undefined,
);
await client.connect(new StreamableHTTPClientTransport(new URL(url)));
const { skills } = await new SkillsClient(client).list();
return skills.map(skill => skill.uri);
};

it.each([
['2025-11-25', false],
['2026-07-28', true],
])(
'should serve the configured skills to a %s client',
async (_version, pinModern) => {
const baseUrl = await startBackend({
skills: { directory: skillsDirectory },
});

await expect(listSkills(`${baseUrl}/v1`, pinModern)).resolves.toEqual([
'skill://find-entities/SKILL.md',
]);
},
);

it('should not serve the root skills on a named server', async () => {
const baseUrl = await startBackend({
skills: { directory: skillsDirectory },
servers: { catalog: { name: 'Catalog Server' } },
});

await expect(listSkills(`${baseUrl}/v1/catalog`)).resolves.toEqual([]);
await expect(listSkills(`${baseUrl}/v1`)).resolves.toEqual([
'skill://find-entities/SKILL.md',
]);
});

it('should serve skills only on the server that configures them', async () => {
const baseUrl = await startBackend({
servers: {
catalog: {
name: 'Catalog Server',
skills: { directory: skillsDirectory },
},
},
});

await expect(listSkills(`${baseUrl}/v1/catalog`)).resolves.toEqual([
'skill://find-entities/SKILL.md',
]);
await expect(listSkills(`${baseUrl}/v1`)).resolves.toEqual([]);
});
});
});
14 changes: 14 additions & 0 deletions plugins/mcp-actions-backend/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
tracingServiceRef,
} from '@backstage/backend-plugin-api/alpha';
import { parseServerConfigs } from './config';
import { loadServerSkills } from './services/loadServerSkills';

/**
* mcpPlugin backend plugin
Expand Down Expand Up @@ -82,6 +83,11 @@ export const mcpPlugin = createBackendPlugin({
captureToolPayloads,
});

const loadSkillsOf = (serverName: string, directory?: string) =>
directory
? loadServerSkills({ directory, serverName, logger })
: undefined;

const router = Router();
router.use(json());

Expand All @@ -95,6 +101,7 @@ export const mcpPlugin = createBackendPlugin({
tracing,
auditor,
serverConfig,
skills: await loadSkillsOf(key, serverConfig.skillsDirectory),
});

router.use(`/v1/${key}`, streamableRouter);
Expand All @@ -108,6 +115,9 @@ export const mcpPlugin = createBackendPlugin({
name: config.getOptionalString('mcpActions.name') ?? 'backstage',
description: config.getOptionalString('mcpActions.description'),
instructions: config.getOptionalString('mcpActions.instructions'),
skillsDirectory: config.getOptionalString(
'mcpActions.skills.directory',
),
includeRules: [],
excludeRules: [],
};
Expand All @@ -122,6 +132,10 @@ export const mcpPlugin = createBackendPlugin({
tracing,
auditor,
serverConfig: defaultServerConfig,
skills: await loadSkillsOf(
'default',
defaultServerConfig.skillsDirectory,
),
}),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
SUPPORTED_PROTOCOL_VERSIONS,
} from '@modelcontextprotocol/server';
import { toNodeHandler } from '@modelcontextprotocol/node';
import type { SkillsExtension } from '@modelcontextprotocol/server/ext/skills';
import { toError } from '@backstage/errors';
import { TracingService } from '@backstage/backend-plugin-api/alpha';
import {
Expand All @@ -48,6 +49,7 @@ export const createStreamableRouter = ({
tracing,
auditor,
serverConfig,
skills,
}: {
mcpService: McpService;
logger: LoggerService;
Expand All @@ -56,6 +58,7 @@ export const createStreamableRouter = ({
tracing: TracingService;
auditor: AuditorService;
serverConfig?: McpServerConfig;
skills?: SkillsExtension;
}): Router => {
const router = PromiseRouter();

Expand Down Expand Up @@ -95,7 +98,7 @@ export const createStreamableRouter = ({
// credentials of this request. Stateless serving lets several backend
// instances run without sticky sessions.
const handler = createMcpHandler(
() => mcpService.getServer({ credentials, serverConfig, req }),
() => mcpService.getServer({ credentials, serverConfig, skills, req }),
{ onerror: err => logger.error(err.message) },
);

Expand Down
Loading