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:
@@ -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: {
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user