feat: add pluginId to actions, server name/description config, dot separator for namespaced tools (#33344)

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-03-17 08:47:51 +01:00
committed by GitHub
parent 5096efc276
commit dee4283ccf
13 changed files with 150 additions and 25 deletions
+7
View File
@@ -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.
@@ -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.
@@ -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: {
@@ -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.
@@ -82,6 +82,7 @@ export interface ActionsService {
// @alpha (undocumented)
export type ActionsServiceAction = {
id: string;
pluginId: string;
name: string;
title: string;
description: string;
@@ -22,6 +22,7 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api';
*/
export type ActionsServiceAction = {
id: string;
pluginId: string;
name: string;
title: string;
description: string;
@@ -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,
+1 -1
View File
@@ -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:
+13 -1
View File
@@ -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;
@@ -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',
);
});
});
@@ -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);
@@ -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,
);
@@ -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;
}