From 87888abd26d2febcd2e67d69a21a33237b882759 Mon Sep 17 00:00:00 2001 From: Brezinben <45977531+Brezinben@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:07:50 +0200 Subject: [PATCH] feat(mcp-actions): serve Agent Skills Serve a directory of Agent Skills per MCP server with the MCP skills extension (SEP-2640), as proposed in backstage/backstage#36429. Signed-off-by: Brezinben <45977531+Brezinben@users.noreply.github.com> --- .changeset/bright-mcp-agent-skills.md | 5 + docs/ai/mcp-actions.md | 59 ++++++ plugins/mcp-actions-backend/README.md | 1 + plugins/mcp-actions-backend/config.d.ts | 20 ++ .../invalid-skills/find-entities/SKILL.md | 6 + .../invalid-skills/wrong-name/SKILL.md | 6 + .../skills/find-entities/SKILL.md | 8 + .../find-entities/references/FILTERS.md | 3 + plugins/mcp-actions-backend/src/config.ts | 2 + .../mcp-actions-backend/src/plugin.test.ts | 85 ++++++++ plugins/mcp-actions-backend/src/plugin.ts | 14 ++ .../src/routers/createStreamableRouter.ts | 5 +- .../src/services/McpService.test.ts | 183 ++++++++++++++++++ .../src/services/McpService.ts | 61 +++++- .../src/services/loadServerSkills.test.ts | 53 +++++ .../src/services/loadServerSkills.ts | 41 ++++ 16 files changed, 547 insertions(+), 5 deletions(-) create mode 100644 .changeset/bright-mcp-agent-skills.md create mode 100644 plugins/mcp-actions-backend/src/__fixtures__/invalid-skills/find-entities/SKILL.md create mode 100644 plugins/mcp-actions-backend/src/__fixtures__/invalid-skills/wrong-name/SKILL.md create mode 100644 plugins/mcp-actions-backend/src/__fixtures__/skills/find-entities/SKILL.md create mode 100644 plugins/mcp-actions-backend/src/__fixtures__/skills/find-entities/references/FILTERS.md create mode 100644 plugins/mcp-actions-backend/src/services/loadServerSkills.test.ts create mode 100644 plugins/mcp-actions-backend/src/services/loadServerSkills.ts diff --git a/.changeset/bright-mcp-agent-skills.md b/.changeset/bright-mcp-agent-skills.md new file mode 100644 index 00000000000000..d6cbde7d932270 --- /dev/null +++ b/.changeset/bright-mcp-agent-skills.md @@ -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). diff --git a/docs/ai/mcp-actions.md b/docs/ai/mcp-actions.md index 7b8040f44d8e66..aa3f482324bbe9 100644 --- a/docs/ai/mcp-actions.md +++ b/docs/ai/mcp-actions.md @@ -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`. diff --git a/plugins/mcp-actions-backend/README.md b/plugins/mcp-actions-backend/README.md index ab21135495db15..67dc19c7d61cad 100644 --- a/plugins/mcp-actions-backend/README.md +++ b/plugins/mcp-actions-backend/README.md @@ -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) diff --git a/plugins/mcp-actions-backend/config.d.ts b/plugins/mcp-actions-backend/config.d.ts index b86577d0a6fb73..281ba8b57d2290 100644 --- a/plugins/mcp-actions-backend/config.d.ts +++ b/plugins/mcp-actions-backend/config.d.ts @@ -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 @@ -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<{ diff --git a/plugins/mcp-actions-backend/src/__fixtures__/invalid-skills/find-entities/SKILL.md b/plugins/mcp-actions-backend/src/__fixtures__/invalid-skills/find-entities/SKILL.md new file mode 100644 index 00000000000000..5c9f26ae1c3a26 --- /dev/null +++ b/plugins/mcp-actions-backend/src/__fixtures__/invalid-skills/find-entities/SKILL.md @@ -0,0 +1,6 @@ +--- +name: find-entities +description: Find catalog entities with the catalog tools of this server. +--- + +# Find entities diff --git a/plugins/mcp-actions-backend/src/__fixtures__/invalid-skills/wrong-name/SKILL.md b/plugins/mcp-actions-backend/src/__fixtures__/invalid-skills/wrong-name/SKILL.md new file mode 100644 index 00000000000000..2e37a64fd5b4ec --- /dev/null +++ b/plugins/mcp-actions-backend/src/__fixtures__/invalid-skills/wrong-name/SKILL.md @@ -0,0 +1,6 @@ +--- +name: another-name +description: The name does not match the directory name. +--- + +# Wrong name diff --git a/plugins/mcp-actions-backend/src/__fixtures__/skills/find-entities/SKILL.md b/plugins/mcp-actions-backend/src/__fixtures__/skills/find-entities/SKILL.md new file mode 100644 index 00000000000000..7f2e60f8ab425a --- /dev/null +++ b/plugins/mcp-actions-backend/src/__fixtures__/skills/find-entities/SKILL.md @@ -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`. diff --git a/plugins/mcp-actions-backend/src/__fixtures__/skills/find-entities/references/FILTERS.md b/plugins/mcp-actions-backend/src/__fixtures__/skills/find-entities/references/FILTERS.md new file mode 100644 index 00000000000000..b6b8836eef1bba --- /dev/null +++ b/plugins/mcp-actions-backend/src/__fixtures__/skills/find-entities/references/FILTERS.md @@ -0,0 +1,3 @@ +# Filters + +Filter on `kind`, then on `spec.type`. diff --git a/plugins/mcp-actions-backend/src/config.ts b/plugins/mcp-actions-backend/src/config.ts index ace53c5c1951c3..9104e8c1d8c99d 100644 --- a/plugins/mcp-actions-backend/src/config.ts +++ b/plugins/mcp-actions-backend/src/config.ts @@ -28,6 +28,7 @@ export type McpServerConfig = { name: string; description?: string; instructions?: string; + skillsDirectory?: string; includeRules: FilterRule[]; excludeRules: FilterRule[]; }; @@ -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, }); diff --git a/plugins/mcp-actions-backend/src/plugin.test.ts b/plugins/mcp-actions-backend/src/plugin.test.ts index a26918b7fd61b8..7301319e198b95 100644 --- a/plugins/mcp-actions-backend/src/plugin.test.ts +++ b/plugins/mcp-actions-backend/src/plugin.test.ts @@ -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({ @@ -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([]); + }); + }); }); diff --git a/plugins/mcp-actions-backend/src/plugin.ts b/plugins/mcp-actions-backend/src/plugin.ts index d80226003ad45a..1bfc61408fe1a5 100644 --- a/plugins/mcp-actions-backend/src/plugin.ts +++ b/plugins/mcp-actions-backend/src/plugin.ts @@ -28,6 +28,7 @@ import { tracingServiceRef, } from '@backstage/backend-plugin-api/alpha'; import { parseServerConfigs } from './config'; +import { loadServerSkills } from './services/loadServerSkills'; /** * mcpPlugin backend plugin @@ -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()); @@ -95,6 +101,7 @@ export const mcpPlugin = createBackendPlugin({ tracing, auditor, serverConfig, + skills: await loadSkillsOf(key, serverConfig.skillsDirectory), }); router.use(`/v1/${key}`, streamableRouter); @@ -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: [], }; @@ -122,6 +132,10 @@ export const mcpPlugin = createBackendPlugin({ tracing, auditor, serverConfig: defaultServerConfig, + skills: await loadSkillsOf( + 'default', + defaultServerConfig.skillsDirectory, + ), }), ); diff --git a/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts b/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts index 41e578cbfeb1ff..9ae543bd4765e0 100644 --- a/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts +++ b/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts @@ -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 { @@ -48,6 +49,7 @@ export const createStreamableRouter = ({ tracing, auditor, serverConfig, + skills, }: { mcpService: McpService; logger: LoggerService; @@ -56,6 +58,7 @@ export const createStreamableRouter = ({ tracing: TracingService; auditor: AuditorService; serverConfig?: McpServerConfig; + skills?: SkillsExtension; }): Router => { const router = PromiseRouter(); @@ -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) }, ); diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index 9e3bd85689141c..87b1cf127abac6 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -22,6 +22,9 @@ import { tracingServiceMock, } from '@backstage/backend-test-utils/alpha'; import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { SkillsClient } from '@modelcontextprotocol/client/ext/skills'; +import { resolve } from 'node:path'; +import { loadServerSkills } from './loadServerSkills'; import { InputError, NotFoundError } from '@backstage/errors'; import { McpServerConfig, parseFilterRules } from '../config'; import { ActionsService } from '@backstage/backend-plugin-api/alpha'; @@ -1290,4 +1293,184 @@ describe('McpService', () => { expect(resultCall).toBeUndefined(); }); }); + + describe('skills', () => { + const connect = async ( + withSkills: boolean, + auditor = mockServices.auditor.mock(), + ) => { + const mockActionsRegistry = actionsRegistryServiceMock(); + mockActionsRegistry.register({ + name: 'mock-action', + title: 'Test', + description: 'Test', + schema: { + input: z => z.object({ input: z.string() }), + output: z => z.object({ output: z.string() }), + }, + action: async () => ({ output: { output: 'test' } }), + }); + const mcpService = await McpService.create({ + actions: mockActionsRegistry, + metrics: metricsServiceMock.mock(), + tracingService: tracingServiceMock.mock(), + auditor, + }); + const skills = withSkills + ? await loadServerSkills({ + directory: resolve(__dirname, '../__fixtures__/skills'), + serverName: 'test', + logger: mockServices.logger.mock(), + }) + : undefined; + + const server = mcpService.getServer({ + credentials: mockCredentials.user(), + skills, + }); + const client = new Client({ name: 'test', version: '1.0' }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + return client; + }; + + it('should not declare resources or the skills extension without skills', async () => { + const client = await connect(false); + + expect(client.getServerCapabilities()).toEqual({ tools: {} }); + expect(new SkillsClient(client).supported).toBe(false); + }); + + it('should declare resources and the skills extension', async () => { + const client = await connect(true); + + expect(client.getServerCapabilities()).toEqual( + expect.objectContaining({ + tools: {}, + resources: expect.objectContaining({ listChanged: false }), + extensions: expect.objectContaining({ + 'io.modelcontextprotocol/skills': expect.any(Object), + }), + }), + ); + }); + + it('should list, get and read the skills', async () => { + const skills = new SkillsClient(await connect(true)); + + const listing = await skills.list(); + expect(listing.skills.map(skill => skill.uri)).toEqual([ + 'skill://find-entities/SKILL.md', + ]); + + const { skill } = await skills.get('skill://find-entities/SKILL.md'); + const manifest = await skills.read(skill); + expect(manifest.verified).toBe(true); + expect(manifest.frontmatter).toEqual( + expect.objectContaining({ name: 'find-entities' }), + ); + + const reference = await skills.read(skill, 'references/FILTERS.md'); + expect(reference.text).toContain('spec.type'); + }); + + const auditedEvents = async ( + auditor: ReturnType, + eventId: string, + ) => { + const calls = auditor.createEvent.mock.calls + .map(([options], index) => ({ + options, + event: auditor.createEvent.mock.results[index]?.value, + })) + .filter(({ options }) => options.eventId === eventId); + return Promise.all( + calls.map(async ({ options, event }) => ({ + meta: options.meta, + event: await event, + })), + ); + }; + + it('should audit each skill read with its outcome', async () => { + const auditor = mockServices.auditor.mock(); + const client = await connect(true, auditor); + + await client.readResource({ uri: 'skill://find-entities/SKILL.md' }); + await expect( + client.readResource({ uri: 'skill://unknown/SKILL.md' }), + ).rejects.toThrow(); + + const [found, unknown] = await auditedEvents(auditor, 'skill-read'); + expect([found.meta, unknown.meta]).toEqual([ + { method: 'resources/read', uri: 'skill://find-entities/SKILL.md' }, + { method: 'resources/read', uri: 'skill://unknown/SKILL.md' }, + ]); + expect(found.event.success).toHaveBeenCalled(); + expect(unknown.event.fail).toHaveBeenCalled(); + }); + + it('should audit every way to discover the skills, with its outcome', async () => { + const auditor = mockServices.auditor.mock(); + const client = await connect(true, auditor); + const skills = new SkillsClient(client); + + await skills.list(); + await skills.get('skill://find-entities/SKILL.md'); + await expect(skills.get('skill://unknown/SKILL.md')).rejects.toThrow(); + await client.listResources(); + await skills.readDirectory('skill://find-entities'); + + const discoveries = await auditedEvents(auditor, 'skill-discovery'); + expect(discoveries.map(({ meta }) => meta)).toEqual([ + { method: 'skills/list' }, + { method: 'skills/get', uri: 'skill://find-entities/SKILL.md' }, + { method: 'skills/get', uri: 'skill://unknown/SKILL.md' }, + { method: 'resources/list' }, + { method: 'resources/directory/read', uri: 'skill://find-entities' }, + ]); + expect( + discoveries.map(({ event }) => event.fail.mock.calls.length), + ).toEqual([0, 0, 1, 0, 0]); + }); + + it('should cap the URI that a client puts in the audit event', async () => { + const auditor = mockServices.auditor.mock(); + const client = await connect(true, auditor); + const uri = `skill://${'a'.repeat(1000)}/SKILL.md`; + + await expect(client.readResource({ uri })).rejects.toThrow(); + + const [read] = await auditedEvents(auditor, 'skill-read'); + expect(read.meta).toEqual({ + method: 'resources/read', + uri: uri.slice(0, 256), + }); + }); + + it('should not read a file outside the skills directory', async () => { + const client = await connect(true); + + await expect( + client.readResource({ uri: 'skill://../loadServerSkills.test.ts' }), + ).rejects.toThrow(); + await expect( + client.readResource({ uri: 'skill://find-entities/../../README.md' }), + ).rejects.toThrow(); + }); + + it('should list the same tools with or without skills', async () => { + const clientWithoutSkills = await connect(false); + const clientWithSkills = await connect(true); + + const withoutSkills = await clientWithoutSkills.listTools(); + const withSkills = await clientWithSkills.listTools(); + + expect(withSkills.tools).toEqual(withoutSkills.tools); + }); + }); }); diff --git a/plugins/mcp-actions-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts index 0979a4eb0d27e6..7c65b8d7ec0ad9 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -20,11 +20,12 @@ import { } from '@backstage/backend-plugin-api'; import type { Request } from 'express'; import { - Server as McpServer, + McpServer, specTypeSchemas, StandardSchemaV1, Tool, } from '@modelcontextprotocol/server'; +import type { SkillsExtension } from '@modelcontextprotocol/server/ext/skills'; import { JsonObject } from '@backstage/types'; import { ActionsService, @@ -79,6 +80,8 @@ const PROPAGATED_BAGGAGE_ATTRIBUTES: ReadonlySet = new Set([ // against pathologically large values inflating exported span sizes. const BAGGAGE_ATTRIBUTE_VALUE_MAX_LENGTH = 256; +const AUDITED_URI_MAX_LENGTH = 256; + function baggageAttributes( tracingService: TracingService, ): Record { @@ -160,13 +163,15 @@ export class McpService { getServer({ credentials, serverConfig, + skills, req, }: { credentials: BackstageCredentials; serverConfig?: McpServerConfig; + skills?: SkillsExtension; req?: Request; }) { - const server = new McpServer( + const mcp = new McpServer( { name: serverConfig?.name ?? 'backstage', version, @@ -175,12 +180,21 @@ export class McpService { }), }, { - capabilities: { tools: {} }, ...(serverConfig?.instructions && { instructions: serverConfig.instructions, }), + ...(skills && { + // Skills load once at startup, so the resource list never changes. + capabilities: { resources: { listChanged: false } }, + extensions: [skills], + }), }, ); + const { server } = mcp; + + // Declared here and not in the McpServer options: there, McpServer would + // install its own tool handlers and advertise `tools.listChanged`. + server.registerCapabilities({ tools: {} }); server.setRequestHandler('tools/list', async () => { const startTime = performance.now(); @@ -376,7 +390,46 @@ export class McpService { } }); - return server; + if (skills) { + this.auditSkillRequests(server, req); + } + + return mcp; + } + + private auditSkillRequests(server: McpServer['server'], req?: Request) { + // The skills resources are the only resources of this server, so every + // resources method reveals or returns skills. + const auditedSkillMethods = [ + { method: 'skills/list', eventId: 'skill-discovery' }, + { method: 'skills/get', eventId: 'skill-discovery' }, + { method: 'resources/list', eventId: 'skill-discovery' }, + { method: 'resources/directory/read', eventId: 'skill-discovery' }, + { method: 'resources/read', eventId: 'skill-read' }, + ]; + for (const { method, eventId } of auditedSkillMethods) { + server.use(method, async (request, ctx, next) => { + // The middleware runs before the SDK validates the request, so the + // value is still caller-controlled. + const uri = + typeof request.params?.uri === 'string' + ? request.params.uri.slice(0, AUDITED_URI_MAX_LENGTH) + : undefined; + const auditorEvent = await this.auditor.createEvent({ + eventId, + ...(req && { request: req }), + meta: { method, ...(uri !== undefined && { uri }) }, + }); + try { + const result = await next(request, ctx); + await auditorEvent.success(); + return result; + } catch (err) { + await auditorEvent.fail({ error: toError(err) }); + throw err; + } + }); + } } private filterActions( diff --git a/plugins/mcp-actions-backend/src/services/loadServerSkills.test.ts b/plugins/mcp-actions-backend/src/services/loadServerSkills.test.ts new file mode 100644 index 00000000000000..65a469fdb06072 --- /dev/null +++ b/plugins/mcp-actions-backend/src/services/loadServerSkills.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { resolve } from 'node:path'; +import { mockServices } from '@backstage/backend-test-utils'; +import { loadServerSkills } from './loadServerSkills'; + +const fixtures = resolve(__dirname, '../__fixtures__'); + +describe('loadServerSkills', () => { + it('should skip an invalid skill and log a warning', async () => { + const logger = mockServices.logger.mock(); + + const skills = await loadServerSkills({ + directory: resolve(fixtures, 'invalid-skills'), + serverName: 'catalog', + logger, + }); + + expect(skills.entries.map(skill => skill.uri)).toEqual([ + 'skill://find-entities/SKILL.md', + ]); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('wrong-name'), + ); + }); + + it('should name the server and the directory when the directory cannot be read', async () => { + const directory = resolve(fixtures, 'missing'); + + await expect( + loadServerSkills({ + directory, + serverName: 'catalog', + logger: mockServices.logger.mock(), + }), + ).rejects.toThrow( + `Failed to load the skills of MCP server "catalog" from "${directory}"`, + ); + }); +}); diff --git a/plugins/mcp-actions-backend/src/services/loadServerSkills.ts b/plugins/mcp-actions-backend/src/services/loadServerSkills.ts new file mode 100644 index 00000000000000..3b6fb608a1ec14 --- /dev/null +++ b/plugins/mcp-actions-backend/src/services/loadServerSkills.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { LoggerService } from '@backstage/backend-plugin-api'; +import { ForwardedError } from '@backstage/errors'; +import { loadSkills } from '@modelcontextprotocol/server/ext/skills/node'; + +/** + * Leaves the SDK `strict` option off: an invalid skill is skipped with a + * warning, so that one bad skill does not stop the others from loading. + */ +export async function loadServerSkills(options: { + directory: string; + serverName: string; + logger: LoggerService; +}) { + const { directory, serverName, logger } = options; + try { + return await loadSkills(directory, { + onWarning: ({ path, message }) => + logger.warn(`MCP server "${serverName}" skills: ${path}: ${message}`), + }); + } catch (error) { + throw new ForwardedError( + `Failed to load the skills of MCP server "${serverName}" from "${directory}"`, + error, + ); + } +}