From dee4283ccf363d72ed2057fb6392e635ab52701e Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Tue, 17 Mar 2026 08:47:51 +0100 Subject: [PATCH] feat: add pluginId to actions, server name/description config, dot separator for namespaced tools (#33344) Signed-off-by: benjdlambert --- .changeset/actions-service-plugin-id.md | 7 ++ .changeset/mcp-server-name-description.md | 5 + .../actions/actionsServiceFactory.test.ts | 2 + .../DefaultActionsRegistryService.ts | 1 + .../backend-plugin-api/report-alpha.api.md | 1 + .../src/alpha/ActionsService.ts | 1 + .../src/alpha/services/MockActionsRegistry.ts | 1 + plugins/mcp-actions-backend/README.md | 2 +- plugins/mcp-actions-backend/config.d.ts | 14 ++- .../mcp-actions-backend/src/plugin.test.ts | 8 +- plugins/mcp-actions-backend/src/plugin.ts | 9 ++ .../src/services/McpService.test.ts | 115 +++++++++++++++--- .../src/services/McpService.ts | 9 +- 13 files changed, 150 insertions(+), 25 deletions(-) create mode 100644 .changeset/actions-service-plugin-id.md create mode 100644 .changeset/mcp-server-name-description.md diff --git a/.changeset/actions-service-plugin-id.md b/.changeset/actions-service-plugin-id.md new file mode 100644 index 0000000000..3fc86db8d8 --- /dev/null +++ b/.changeset/actions-service-plugin-id.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-plugin-api': patch +'@backstage/backend-defaults': patch +'@backstage/backend-test-utils': patch +--- + +Added `pluginId` field to `ActionsServiceAction` type, populated from the registering plugin's metadata. diff --git a/.changeset/mcp-server-name-description.md b/.changeset/mcp-server-name-description.md new file mode 100644 index 0000000000..55d3629cfa --- /dev/null +++ b/.changeset/mcp-server-name-description.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-mcp-actions-backend': patch +--- + +Added `mcpActions.name` and `mcpActions.description` config options to customize the MCP server identity. Namespaced tool names now use dot separator to align with the MCP spec convention. diff --git a/packages/backend-defaults/src/alpha/entrypoints/actions/actionsServiceFactory.test.ts b/packages/backend-defaults/src/alpha/entrypoints/actions/actionsServiceFactory.test.ts index dbce47bea4..3f600dcbbc 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actions/actionsServiceFactory.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actions/actionsServiceFactory.test.ts @@ -66,6 +66,7 @@ describe('actionsServiceFactory', () => { const mockActionsDefinition: ActionsServiceAction = { description: 'my mock description', id: 'my-plugin:test', + pluginId: 'my-plugin', name: 'testy', title: 'Test', schema: { @@ -755,6 +756,7 @@ describe('actionsServiceFactory', () => { { description: 'Test', id: 'plugin-with-action:with-validation', + pluginId: 'plugin-with-action', name: 'with-validation', schema: { input: { diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index ffe74f81d9..e0bf4bf145 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -73,6 +73,7 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { return res.json({ actions: Array.from(this.actions.entries()).map(([id, action]) => ({ id, + pluginId: this.metadata.getId(), ...action, attributes: { // Inspired by the @modelcontextprotocol/sdk defaults for the hints. diff --git a/packages/backend-plugin-api/report-alpha.api.md b/packages/backend-plugin-api/report-alpha.api.md index 7d6786b97e..17b389d9d4 100644 --- a/packages/backend-plugin-api/report-alpha.api.md +++ b/packages/backend-plugin-api/report-alpha.api.md @@ -82,6 +82,7 @@ export interface ActionsService { // @alpha (undocumented) export type ActionsServiceAction = { id: string; + pluginId: string; name: string; title: string; description: string; diff --git a/packages/backend-plugin-api/src/alpha/ActionsService.ts b/packages/backend-plugin-api/src/alpha/ActionsService.ts index 528b6cea67..6e432e962f 100644 --- a/packages/backend-plugin-api/src/alpha/ActionsService.ts +++ b/packages/backend-plugin-api/src/alpha/ActionsService.ts @@ -22,6 +22,7 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api'; */ export type ActionsServiceAction = { id: string; + pluginId: string; name: string; title: string; description: string; diff --git a/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts b/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts index 384a5e8e95..94d844746f 100644 --- a/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts +++ b/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts @@ -82,6 +82,7 @@ export class MockActionsRegistry return { actions: Array.from(this.actions.entries()).map(([id, action]) => ({ id, + pluginId: 'test', name: action.name, title: action.title, description: action.description, diff --git a/plugins/mcp-actions-backend/README.md b/plugins/mcp-actions-backend/README.md index 7793a9f5f9..d496cf0f03 100644 --- a/plugins/mcp-actions-backend/README.md +++ b/plugins/mcp-actions-backend/README.md @@ -73,7 +73,7 @@ export const myPlugin = createBackendPlugin({ ### 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`. +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`. You can disable this if you need the short names for backward compatibility: diff --git a/plugins/mcp-actions-backend/config.d.ts b/plugins/mcp-actions-backend/config.d.ts index a535f4c511..a530f5b038 100644 --- a/plugins/mcp-actions-backend/config.d.ts +++ b/plugins/mcp-actions-backend/config.d.ts @@ -16,10 +16,22 @@ export interface Config { mcpActions?: { + /** + * Display name for the MCP server. Defaults to "backstage". + * Used when running a single bundled server without mcpActions.servers. + */ + name?: string; + + /** + * Description of the MCP server. + * Used when running a single bundled server without mcpActions.servers. + */ + description?: string; + /** * When true, MCP tool names include the plugin ID prefix to avoid * collisions across plugins. For example an action registered as - * "get-entity" by the catalog plugin becomes "catalog:get-entity". + * "get-entity" by the catalog plugin becomes "catalog.get-entity". * Defaults to true. */ namespacedToolNames?: boolean; diff --git a/plugins/mcp-actions-backend/src/plugin.test.ts b/plugins/mcp-actions-backend/src/plugin.test.ts index ceae509f9f..2c03800915 100644 --- a/plugins/mcp-actions-backend/src/plugin.test.ts +++ b/plugins/mcp-actions-backend/src/plugin.test.ts @@ -118,7 +118,7 @@ describe('Mcp Backend', () => { required: ['name'], type: 'object', }, - name: 'local:make-greeting', + name: 'local.make-greeting', }, ]); }); @@ -161,7 +161,7 @@ describe('Mcp Backend', () => { required: ['name'], type: 'object', }, - name: 'local:make-greeting', + name: 'local.make-greeting', }, ]); }); @@ -264,7 +264,7 @@ describe('Mcp Backend', () => { ListToolsResultSchema, ); expect(catalogResult.tools).toHaveLength(1); - expect(catalogResult.tools[0].name).toBe('catalog-actions:get-entity'); + expect(catalogResult.tools[0].name).toBe('catalog-actions.get-entity'); const scaffolderClient = new Client({ name: 'test', version: '1.0' }); const scaffolderTransport = new StreamableHTTPClientTransport( @@ -277,7 +277,7 @@ describe('Mcp Backend', () => { ); expect(scaffolderResult.tools).toHaveLength(1); expect(scaffolderResult.tools[0].name).toBe( - 'scaffolder-actions:create-app', + 'scaffolder-actions.create-app', ); }); }); diff --git a/plugins/mcp-actions-backend/src/plugin.ts b/plugins/mcp-actions-backend/src/plugin.ts index 08f9db350f..f8844249f6 100644 --- a/plugins/mcp-actions-backend/src/plugin.ts +++ b/plugins/mcp-actions-backend/src/plugin.ts @@ -87,9 +87,17 @@ export const mcpPlugin = createBackendPlugin({ router.use(`/v1/${key}`, streamableRouter); } } else { + const serverConfig = { + name: config.getOptionalString('mcpActions.name') ?? 'backstage', + description: config.getOptionalString('mcpActions.description'), + includeRules: [], + excludeRules: [], + }; + const sseRouter = createSseRouter({ mcpService, httpAuth, + serverConfig, }); const streamableRouter = createStreamableRouter({ @@ -97,6 +105,7 @@ export const mcpPlugin = createBackendPlugin({ httpAuth, logger, metrics, + serverConfig, }); router.use('/v1/sse', sseRouter); diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index 8a6656b91c..1a7dc507c3 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -96,7 +96,7 @@ describe('McpService', () => { required: ['input'], type: 'object', }, - name: 'test:mock-action', + name: 'test.mock-action', }, ]); @@ -196,7 +196,7 @@ describe('McpService', () => { const result = await client.request( { method: 'tools/call', - params: { name: 'test:mock-action', arguments: { input: 'test' } }, + params: { name: 'test.mock-action', arguments: { input: 'test' } }, }, CallToolResultSchema, ); @@ -226,7 +226,7 @@ describe('McpService', () => { expect.any(Number), expect.objectContaining({ 'mcp.method.name': 'tools/call', - 'gen_ai.tool.name': 'test:mock-action', + 'gen_ai.tool.name': 'test.mock-action', 'gen_ai.operation.name': 'execute_tool', }), ); @@ -329,7 +329,7 @@ describe('McpService', () => { client.request( { method: 'tools/call', - params: { name: 'test:failing-action', arguments: {} }, + params: { name: 'test.failing-action', arguments: {} }, }, CallToolResultSchema, ), @@ -341,7 +341,7 @@ describe('McpService', () => { expect.any(Number), expect.objectContaining({ 'mcp.method.name': 'tools/call', - 'gen_ai.tool.name': 'test:failing-action', + 'gen_ai.tool.name': 'test.failing-action', 'gen_ai.operation.name': 'execute_tool', 'error.type': 'CustomError', }), @@ -388,7 +388,7 @@ describe('McpService', () => { const result = await client.request( { method: 'tools/call', - params: { name: 'test:failing-action', arguments: { value: 'test' } }, + params: { name: 'test.failing-action', arguments: { value: 'test' } }, }, CallToolResultSchema, ); @@ -444,7 +444,7 @@ describe('McpService', () => { const result = await client.request( { method: 'tools/call', - params: { name: 'test:not-found-action', arguments: { id: 'abc' } }, + params: { name: 'test.not-found-action', arguments: { id: 'abc' } }, }, CallToolResultSchema, ); @@ -464,6 +464,7 @@ describe('McpService', () => { const fakeActions = [ { id: 'catalog:get-entity', + pluginId: 'catalog', name: 'get-entity', title: 'Get Entity', description: 'Fetch an entity', @@ -475,6 +476,7 @@ describe('McpService', () => { }, { id: 'catalog:delete-entity', + pluginId: 'catalog', name: 'delete-entity', title: 'Delete Entity', description: 'Delete an entity', @@ -486,6 +488,7 @@ describe('McpService', () => { }, { id: 'scaffolder:create-app', + pluginId: 'scaffolder', name: 'create-app', title: 'Create App', description: 'Create an app', @@ -571,8 +574,8 @@ describe('McpService', () => { expect(result.tools).toHaveLength(2); expect(result.tools.map(t => t.name)).toEqual([ - 'catalog:get-entity', - 'catalog:delete-entity', + 'catalog.get-entity', + 'catalog.delete-entity', ]); }); @@ -611,7 +614,7 @@ describe('McpService', () => { ); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('catalog:get-entity'); + expect(result.tools[0].name).toBe('catalog.get-entity'); }); it('should apply include filter rules with glob patterns', async () => { @@ -649,7 +652,7 @@ describe('McpService', () => { ); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('catalog:get-entity'); + expect(result.tools[0].name).toBe('catalog.get-entity'); }); it('should reject tool calls for actions outside the filtered set', async () => { @@ -684,7 +687,7 @@ describe('McpService', () => { const result = await client.request( { method: 'tools/call', - params: { name: 'catalog:get-entity', arguments: {} }, + params: { name: 'catalog.get-entity', arguments: {} }, }, CallToolResultSchema, ); @@ -694,7 +697,7 @@ describe('McpService', () => { { type: 'text', text: expect.stringContaining( - 'Action "catalog:get-entity" not found', + 'Action "catalog.get-entity" not found', ), }, ], @@ -703,6 +706,88 @@ describe('McpService', () => { }); }); + describe('server name and description', () => { + it('should default server name to backstage when no config is provided', async () => { + const mcpService = await McpService.create({ + actions: actionsRegistryServiceMock(), + metrics: metricsServiceMock.mock(), + }); + + const server = mcpService.getServer({ + credentials: mockCredentials.user(), + }); + + const client = new Client({ name: 'test', version: '1.0' }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const serverInfo = client.getServerVersion(); + expect(serverInfo?.name).toBe('backstage'); + expect(serverInfo?.description).toBeUndefined(); + }); + + it('should use name and description from server config', async () => { + const mcpService = await McpService.create({ + actions: actionsRegistryServiceMock(), + metrics: metricsServiceMock.mock(), + }); + + const server = mcpService.getServer({ + credentials: mockCredentials.user(), + serverConfig: { + name: 'My Custom Server', + description: 'A custom MCP server for testing', + includeRules: [], + excludeRules: [], + }, + }); + + const client = new Client({ name: 'test', version: '1.0' }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const serverInfo = client.getServerVersion(); + expect(serverInfo?.name).toBe('My Custom Server'); + expect(serverInfo?.description).toBe('A custom MCP server for testing'); + }); + + it('should omit description when not provided in config', async () => { + const mcpService = await McpService.create({ + actions: actionsRegistryServiceMock(), + metrics: metricsServiceMock.mock(), + }); + + const server = mcpService.getServer({ + credentials: mockCredentials.user(), + serverConfig: { + name: 'Named Server', + includeRules: [], + excludeRules: [], + }, + }); + + const client = new Client({ name: 'test', version: '1.0' }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const serverInfo = client.getServerVersion(); + expect(serverInfo?.name).toBe('Named Server'); + expect(serverInfo?.description).toBeUndefined(); + }); + }); + describe('namespaced tool names', () => { it('should use action ID as tool name by default', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); @@ -739,7 +824,7 @@ describe('McpService', () => { ListToolsResultSchema, ); - expect(result.tools[0].name).toBe('test:mock-action'); + expect(result.tools[0].name).toBe('test.mock-action'); }); it('should use short action name when namespacing is disabled', async () => { @@ -814,7 +899,7 @@ describe('McpService', () => { const result = await client.request( { method: 'tools/call', - params: { name: 'test:mock-action', arguments: {} }, + params: { name: 'test.mock-action', arguments: {} }, }, CallToolResultSchema, ); diff --git a/plugins/mcp-actions-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts index 18a5e57908..49eea11d72 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -76,13 +76,14 @@ export class McpService { credentials: BackstageCredentials; serverConfig?: McpServerConfig; }) { - const serverName = serverConfig?.name ?? 'backstage'; - const server = new McpServer( { - name: serverName, + name: serverConfig?.name ?? 'backstage', // TODO: this version will most likely change in the future. version, + ...(serverConfig?.description && { + description: serverConfig.description, + }), }, { capabilities: { tools: {} } }, ); @@ -222,7 +223,7 @@ export class McpService { private getToolName(action: ActionsServiceAction): string { if (this.namespacedToolNames) { - return action.id; + return `${action.pluginId}.${action.name}`; } return action.name; }