feat(mcp-actions): Add the ability to configure different plugins for different servers (#33235)
* feat: split MCP actions into per-plugin servers Add mcpActions.servers config to create multiple MCP server endpoints scoped by plugin source, with per-server include/exclude filtering. Add mcpActions.tools for global tool description overrides. Signed-off-by: benjdlambert <ben@blam.sh> * feat: namespace tool names, use filter rules for server scoping - Tool names now use action ID (plugin:name) by default, opt out via mcpActions.namespacedToolNames - Removed pluginSources from server config, use filter.include with id glob patterns instead - Removed tool description overrides (deferred to followup) - Added server key validation for route safety Signed-off-by: benjdlambert <ben@blam.sh> * docs: update README for filter-based server scoping Signed-off-by: benjdlambert <ben@blam.sh> * feat: drop SSE routes for split servers Signed-off-by: benjdlambert <ben@blam.sh> * fix: handle empty servers config, fix test name Signed-off-by: benjdlambert <ben@blam.sh> --------- Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-mcp-actions-backend': patch
|
||||
---
|
||||
|
||||
Added support for splitting MCP actions into multiple servers via `mcpActions.servers` configuration. Each server gets its own endpoint at `/api/mcp-actions/v1/{key}` with actions scoped using include/exclude filter rules. Tool names are now namespaced with the plugin ID by default, configurable via `mcpActions.namespacedToolNames`. When `mcpActions.servers` is not configured, the plugin continues to serve a single server at `/api/mcp-actions/v1`.
|
||||
@@ -71,6 +71,64 @@ 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`.
|
||||
|
||||
You can disable this if you need the short names for backward compatibility:
|
||||
|
||||
```yaml
|
||||
mcpActions:
|
||||
namespacedToolNames: false
|
||||
```
|
||||
|
||||
### Multiple MCP Servers
|
||||
|
||||
By default, the plugin serves a single MCP server at `/api/mcp-actions/v1` that exposes all available actions. You can split actions into multiple focused servers by configuring `mcpActions.servers`, where each key becomes a separate MCP server endpoint.
|
||||
|
||||
```yaml
|
||||
mcpActions:
|
||||
servers:
|
||||
catalog:
|
||||
name: 'Backstage Catalog'
|
||||
description: 'Tools for interacting with the software catalog'
|
||||
filter:
|
||||
include:
|
||||
- id: 'catalog:*'
|
||||
scaffolder:
|
||||
name: 'Backstage Scaffolder'
|
||||
description: 'Tools for creating new software from templates'
|
||||
filter:
|
||||
include:
|
||||
- id: 'scaffolder:*'
|
||||
```
|
||||
|
||||
This creates two MCP server endpoints:
|
||||
|
||||
- `http://localhost:7007/api/mcp-actions/v1/catalog`
|
||||
- `http://localhost:7007/api/mcp-actions/v1/scaffolder`
|
||||
|
||||
Each server uses include filter rules with glob patterns on action IDs to control which actions are exposed. For example, `id: 'catalog:*'` matches all actions registered by the catalog plugin.
|
||||
|
||||
When `mcpActions.servers` is not configured, the plugin behaves exactly as before with a single server at `/api/mcp-actions/v1`.
|
||||
|
||||
#### Filter Rules
|
||||
|
||||
Include and exclude filter rules support glob patterns on action IDs and attribute matching. Exclude rules take precedence over include rules. When include rules are specified, actions must match at least one include rule to be exposed.
|
||||
|
||||
```yaml
|
||||
mcpActions:
|
||||
servers:
|
||||
catalog:
|
||||
name: 'Backstage Catalog'
|
||||
filter:
|
||||
include:
|
||||
- id: 'catalog:*'
|
||||
exclude:
|
||||
- attributes:
|
||||
destructive: true
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
When errors are thrown from MCP actions, the backend will handle and surface error message for any error from `@backstage/errors`. Unknown errors will be handled by `@modelcontextprotocol/sdk`'s default error handling, which may result in a generic `500 Server Error` being returned. As a result, we recommend using errors from `@backstage/errors` when applicable.
|
||||
@@ -159,16 +217,15 @@ The MCP server supports both Server-Sent Events (SSE) and Streamable HTTP protoc
|
||||
|
||||
The SSE protocol is deprecated, and should be avoided as it will be removed in a future release.
|
||||
|
||||
### Single Server (default)
|
||||
|
||||
- `Streamable HTTP`: `http://localhost:7007/api/mcp-actions/v1`
|
||||
- `SSE`: `http://localhost:7007/api/mcp-actions/v1/sse`
|
||||
|
||||
There's a few different ways to configure MCP tools, but here's a snippet of the most common.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"backstage-actions": {
|
||||
// you can also replace this with the public / internal URL of the deployed backend.
|
||||
"url": "http://localhost:7007/api/mcp-actions/v1",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${MCP_TOKEN}"
|
||||
@@ -178,6 +235,32 @@ There's a few different ways to configure MCP tools, but here's a snippet of the
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Servers
|
||||
|
||||
When `mcpActions.servers` is configured, each server key becomes part of the URL. For example, with servers named `catalog` and `scaffolder`:
|
||||
|
||||
- `http://localhost:7007/api/mcp-actions/v1/catalog`
|
||||
- `http://localhost:7007/api/mcp-actions/v1/scaffolder`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"backstage-catalog": {
|
||||
"url": "http://localhost:7007/api/mcp-actions/v1/catalog",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${MCP_TOKEN}"
|
||||
}
|
||||
},
|
||||
"backstage-scaffolder": {
|
||||
"url": "http://localhost:7007/api/mcp-actions/v1/scaffolder",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${MCP_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Metrics
|
||||
|
||||
The MCP Actions Backend emits metrics for the following operations:
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface Config {
|
||||
mcpActions?: {
|
||||
/**
|
||||
* 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".
|
||||
* Defaults to true.
|
||||
*/
|
||||
namespacedToolNames?: boolean;
|
||||
|
||||
/**
|
||||
* Named MCP servers, each exposed at /api/mcp-actions/v1/{key}.
|
||||
* When not configured, the plugin serves a single server at /api/mcp-actions/v1.
|
||||
*/
|
||||
servers?: {
|
||||
[serverKey: string]: {
|
||||
/** Display name for the MCP server. */
|
||||
name: string;
|
||||
/** Description of the MCP server. */
|
||||
description?: string;
|
||||
/** Filter rules to include or exclude specific actions. */
|
||||
filter?: {
|
||||
include?: Array<{
|
||||
/** Glob pattern matched against action ID. */
|
||||
id?: string;
|
||||
/** Match actions by their attribute flags. */
|
||||
attributes?: {
|
||||
destructive?: boolean;
|
||||
readOnly?: boolean;
|
||||
idempotent?: boolean;
|
||||
};
|
||||
}>;
|
||||
exclude?: Array<{
|
||||
/** Glob pattern matched against action ID. */
|
||||
id?: string;
|
||||
/** Match actions by their attribute flags. */
|
||||
attributes?: {
|
||||
destructive?: boolean;
|
||||
readOnly?: boolean;
|
||||
idempotent?: boolean;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -21,8 +21,10 @@
|
||||
"license": "Apache-2.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"configSchema": "config.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
@@ -36,6 +38,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/catalog-client": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/plugin-catalog-node": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
@@ -43,6 +46,7 @@
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"express": "^4.22.0",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"minimatch": "^10.2.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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 { Config } from '@backstage/config';
|
||||
import { Minimatch } from 'minimatch';
|
||||
|
||||
export type FilterRule = {
|
||||
idMatcher?: Minimatch;
|
||||
attributes?: Partial<
|
||||
Record<'destructive' | 'readOnly' | 'idempotent', boolean>
|
||||
>;
|
||||
};
|
||||
|
||||
export type McpServerConfig = {
|
||||
name: string;
|
||||
description?: string;
|
||||
includeRules: FilterRule[];
|
||||
excludeRules: FilterRule[];
|
||||
};
|
||||
|
||||
const SERVER_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
export function parseFilterRules(configArray: Config[]): FilterRule[] {
|
||||
return configArray.map(ruleConfig => {
|
||||
const idPattern = ruleConfig.getOptionalString('id');
|
||||
const attributesConfig = ruleConfig.getOptionalConfig('attributes');
|
||||
|
||||
const rule: FilterRule = {};
|
||||
|
||||
if (idPattern) {
|
||||
rule.idMatcher = new Minimatch(idPattern);
|
||||
}
|
||||
|
||||
if (attributesConfig) {
|
||||
rule.attributes = {};
|
||||
for (const key of ['destructive', 'readOnly', 'idempotent'] as const) {
|
||||
const value = attributesConfig.getOptionalBoolean(key);
|
||||
if (value !== undefined) {
|
||||
rule.attributes[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rule;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseServerConfigs(
|
||||
config: Config,
|
||||
): Map<string, McpServerConfig> | undefined {
|
||||
const serversConfig = config.getOptionalConfig('mcpActions.servers');
|
||||
if (!serversConfig) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const servers = new Map<string, McpServerConfig>();
|
||||
|
||||
for (const key of serversConfig.keys()) {
|
||||
if (!SERVER_KEY_PATTERN.test(key)) {
|
||||
throw new Error(
|
||||
`Invalid MCP server key "${key}": must be lowercase alphanumeric with hyphens`,
|
||||
);
|
||||
}
|
||||
|
||||
const serverConfig = serversConfig.getConfig(key);
|
||||
|
||||
const filterConfig = serverConfig.getOptionalConfig('filter');
|
||||
const includeRules = parseFilterRules(
|
||||
filterConfig?.getOptionalConfigArray('include') ?? [],
|
||||
);
|
||||
const excludeRules = parseFilterRules(
|
||||
filterConfig?.getOptionalConfigArray('exclude') ?? [],
|
||||
);
|
||||
|
||||
servers.set(key, {
|
||||
name: serverConfig.getString('name'),
|
||||
description: serverConfig.getOptionalString('description'),
|
||||
includeRules,
|
||||
excludeRules,
|
||||
});
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
@@ -118,7 +118,7 @@ describe('Mcp Backend', () => {
|
||||
required: ['name'],
|
||||
type: 'object',
|
||||
},
|
||||
name: 'make-greeting',
|
||||
name: 'local:make-greeting',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -161,11 +161,127 @@ describe('Mcp Backend', () => {
|
||||
required: ['name'],
|
||||
type: 'object',
|
||||
},
|
||||
name: 'make-greeting',
|
||||
name: 'local:make-greeting',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe('multi-server routing', () => {
|
||||
const mockCatalogPlugin = createBackendPlugin({
|
||||
pluginId: 'catalog-actions',
|
||||
register({ registerInit }) {
|
||||
registerInit({
|
||||
deps: { actionsRegistry: actionsRegistryServiceRef },
|
||||
async init({ actionsRegistry }) {
|
||||
actionsRegistry.register({
|
||||
name: 'get-entity',
|
||||
title: 'Get Entity',
|
||||
description: 'Fetch an entity',
|
||||
schema: {
|
||||
input: z => z.object({ name: z.string() }),
|
||||
output: z => z.object({ entity: z.string() }),
|
||||
},
|
||||
action: async ({ input }) => ({
|
||||
output: { entity: input.name },
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const mockScaffolderPlugin = createBackendPlugin({
|
||||
pluginId: 'scaffolder-actions',
|
||||
register({ registerInit }) {
|
||||
registerInit({
|
||||
deps: { actionsRegistry: actionsRegistryServiceRef },
|
||||
async init({ actionsRegistry }) {
|
||||
actionsRegistry.register({
|
||||
name: 'create-app',
|
||||
title: 'Create App',
|
||||
description: 'Create an app from template',
|
||||
schema: {
|
||||
input: z => z.object({ template: z.string() }),
|
||||
output: z => z.object({ name: z.string() }),
|
||||
},
|
||||
action: async ({ input }) => ({
|
||||
output: { name: input.template },
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
it('should route to per-server endpoints when mcpActions.servers is configured', async () => {
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
mcpPlugin,
|
||||
mockCatalogPlugin,
|
||||
mockScaffolderPlugin,
|
||||
metricsServiceMock.mock().factory,
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
backend: {
|
||||
actions: {
|
||||
pluginSources: ['catalog-actions', 'scaffolder-actions'],
|
||||
},
|
||||
},
|
||||
mcpActions: {
|
||||
servers: {
|
||||
catalog: {
|
||||
name: 'Catalog Server',
|
||||
filter: {
|
||||
include: [{ id: 'catalog-actions:*' }],
|
||||
},
|
||||
},
|
||||
scaffolder: {
|
||||
name: 'Scaffolder Server',
|
||||
filter: {
|
||||
include: [{ id: 'scaffolder-actions:*' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (typeof address !== 'object' || !('port' in address!)) {
|
||||
throw new Error('server broke');
|
||||
}
|
||||
const serverAddress = `http://localhost:${address.port}`;
|
||||
|
||||
const catalogClient = new Client({ name: 'test', version: '1.0' });
|
||||
const catalogTransport = new StreamableHTTPClientTransport(
|
||||
new URL(`${serverAddress}/api/mcp-actions/v1/catalog`),
|
||||
);
|
||||
await catalogClient.connect(catalogTransport);
|
||||
const catalogResult = await catalogClient.request(
|
||||
{ method: 'tools/list' },
|
||||
ListToolsResultSchema,
|
||||
);
|
||||
expect(catalogResult.tools).toHaveLength(1);
|
||||
expect(catalogResult.tools[0].name).toBe('catalog-actions:get-entity');
|
||||
|
||||
const scaffolderClient = new Client({ name: 'test', version: '1.0' });
|
||||
const scaffolderTransport = new StreamableHTTPClientTransport(
|
||||
new URL(`${serverAddress}/api/mcp-actions/v1/scaffolder`),
|
||||
);
|
||||
await scaffolderClient.connect(scaffolderTransport);
|
||||
const scaffolderResult = await scaffolderClient.request(
|
||||
{ method: 'tools/list' },
|
||||
ListToolsResultSchema,
|
||||
);
|
||||
expect(scaffolderResult.tools).toHaveLength(1);
|
||||
expect(scaffolderResult.tools[0].name).toBe(
|
||||
'scaffolder-actions:create-app',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth well-known endpoints', () => {
|
||||
it('should not expose oauth endpoints when neither DCR nor CIMD is enabled', async () => {
|
||||
const { server } = await startTestBackend({
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
actionsServiceRef,
|
||||
metricsServiceRef,
|
||||
} from '@backstage/backend-plugin-api/alpha';
|
||||
import { parseServerConfigs } from './config';
|
||||
|
||||
/**
|
||||
* mcpPlugin backend plugin
|
||||
@@ -59,28 +60,48 @@ export const mcpPlugin = createBackendPlugin({
|
||||
config,
|
||||
metrics,
|
||||
}) {
|
||||
const serverConfigs = parseServerConfigs(config);
|
||||
const namespacedToolNames = config.getOptionalBoolean(
|
||||
'mcpActions.namespacedToolNames',
|
||||
);
|
||||
|
||||
const mcpService = await McpService.create({
|
||||
actions,
|
||||
metrics,
|
||||
});
|
||||
|
||||
const sseRouter = createSseRouter({
|
||||
mcpService,
|
||||
httpAuth,
|
||||
});
|
||||
|
||||
const streamableRouter = createStreamableRouter({
|
||||
mcpService,
|
||||
httpAuth,
|
||||
logger,
|
||||
metrics,
|
||||
namespacedToolNames,
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
router.use(json());
|
||||
|
||||
router.use('/v1/sse', sseRouter);
|
||||
router.use('/v1', streamableRouter);
|
||||
if (serverConfigs && serverConfigs.size > 0) {
|
||||
for (const [key, serverConfig] of serverConfigs) {
|
||||
const streamableRouter = createStreamableRouter({
|
||||
mcpService,
|
||||
httpAuth,
|
||||
logger,
|
||||
metrics,
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
router.use(`/v1/${key}`, streamableRouter);
|
||||
}
|
||||
} else {
|
||||
const sseRouter = createSseRouter({
|
||||
mcpService,
|
||||
httpAuth,
|
||||
});
|
||||
|
||||
const streamableRouter = createStreamableRouter({
|
||||
mcpService,
|
||||
httpAuth,
|
||||
logger,
|
||||
metrics,
|
||||
});
|
||||
|
||||
router.use('/v1/sse', sseRouter);
|
||||
router.use('/v1', streamableRouter);
|
||||
}
|
||||
|
||||
httpRouter.use(router);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Router } from 'express';
|
||||
import { McpService } from '../services/McpService';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { HttpAuthService } from '@backstage/backend-plugin-api';
|
||||
import { McpServerConfig } from '../config';
|
||||
|
||||
/**
|
||||
* Legacy SSE endpoint for older clients, hopefully will not be needed for much longer.
|
||||
@@ -25,9 +26,11 @@ import { HttpAuthService } from '@backstage/backend-plugin-api';
|
||||
export const createSseRouter = ({
|
||||
mcpService,
|
||||
httpAuth,
|
||||
serverConfig,
|
||||
}: {
|
||||
mcpService: McpService;
|
||||
httpAuth: HttpAuthService;
|
||||
serverConfig?: McpServerConfig;
|
||||
}): Router => {
|
||||
const router = PromiseRouter();
|
||||
const transportsToSessionId = new Map<string, SSEServerTransport>();
|
||||
@@ -35,6 +38,7 @@ export const createSseRouter = ({
|
||||
router.get('/', async (req, res) => {
|
||||
const server = mcpService.getServer({
|
||||
credentials: await httpAuth.credentials(req),
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
const transport = new SSEServerTransport(
|
||||
|
||||
@@ -23,17 +23,20 @@ import { HttpAuthService, LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { isError } from '@backstage/errors';
|
||||
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
|
||||
import { bucketBoundaries, McpServerSessionAttributes } from '../metrics';
|
||||
import { McpServerConfig } from '../config';
|
||||
|
||||
export const createStreamableRouter = ({
|
||||
mcpService,
|
||||
httpAuth,
|
||||
logger,
|
||||
metrics,
|
||||
serverConfig,
|
||||
}: {
|
||||
mcpService: McpService;
|
||||
logger: LoggerService;
|
||||
httpAuth: HttpAuthService;
|
||||
metrics: MetricsService;
|
||||
serverConfig?: McpServerConfig;
|
||||
}): Router => {
|
||||
const router = PromiseRouter();
|
||||
|
||||
@@ -59,6 +62,7 @@ export const createStreamableRouter = ({
|
||||
try {
|
||||
const server = mcpService.getServer({
|
||||
credentials: await httpAuth.credentials(req),
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
ListToolsResultSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { InputError, NotFoundError } from '@backstage/errors';
|
||||
import { McpServerConfig, parseFilterRules } from '../config';
|
||||
import { ActionsService } from '@backstage/backend-plugin-api/alpha';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
|
||||
describe('McpService', () => {
|
||||
it('should list the available actions as tools in the mcp backend', async () => {
|
||||
@@ -93,7 +96,7 @@ describe('McpService', () => {
|
||||
required: ['input'],
|
||||
type: 'object',
|
||||
},
|
||||
name: 'mock-action',
|
||||
name: 'test:mock-action',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -193,7 +196,7 @@ describe('McpService', () => {
|
||||
const result = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: { name: 'mock-action', arguments: { input: 'test' } },
|
||||
params: { name: 'test:mock-action', arguments: { input: 'test' } },
|
||||
},
|
||||
CallToolResultSchema,
|
||||
);
|
||||
@@ -223,7 +226,7 @@ describe('McpService', () => {
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
'mcp.method.name': 'tools/call',
|
||||
'gen_ai.tool.name': 'mock-action',
|
||||
'gen_ai.tool.name': 'test:mock-action',
|
||||
'gen_ai.operation.name': 'execute_tool',
|
||||
}),
|
||||
);
|
||||
@@ -257,14 +260,14 @@ describe('McpService', () => {
|
||||
const result = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: { name: 'mock-action', arguments: { input: 'test' } },
|
||||
params: { name: 'nonexistent-action', arguments: { input: 'test' } },
|
||||
},
|
||||
CallToolResultSchema,
|
||||
);
|
||||
await expect(result).toEqual({
|
||||
content: [
|
||||
{
|
||||
text: expect.stringMatching('Action "mock-action" not found'),
|
||||
text: expect.stringMatching('Action "nonexistent-action" not found'),
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
@@ -277,7 +280,7 @@ describe('McpService', () => {
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
'mcp.method.name': 'tools/call',
|
||||
'gen_ai.tool.name': 'mock-action',
|
||||
'gen_ai.tool.name': 'nonexistent-action',
|
||||
'gen_ai.operation.name': 'execute_tool',
|
||||
'error.type': 'tool_error',
|
||||
}),
|
||||
@@ -326,7 +329,7 @@ describe('McpService', () => {
|
||||
client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: { name: 'failing-action', arguments: {} },
|
||||
params: { name: 'test:failing-action', arguments: {} },
|
||||
},
|
||||
CallToolResultSchema,
|
||||
),
|
||||
@@ -338,7 +341,7 @@ describe('McpService', () => {
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
'mcp.method.name': 'tools/call',
|
||||
'gen_ai.tool.name': 'failing-action',
|
||||
'gen_ai.tool.name': 'test:failing-action',
|
||||
'gen_ai.operation.name': 'execute_tool',
|
||||
'error.type': 'CustomError',
|
||||
}),
|
||||
@@ -385,7 +388,7 @@ describe('McpService', () => {
|
||||
const result = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: { name: 'failing-action', arguments: { value: 'test' } },
|
||||
params: { name: 'test:failing-action', arguments: { value: 'test' } },
|
||||
},
|
||||
CallToolResultSchema,
|
||||
);
|
||||
@@ -441,7 +444,7 @@ describe('McpService', () => {
|
||||
const result = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: { name: 'not-found-action', arguments: { id: 'abc' } },
|
||||
params: { name: 'test:not-found-action', arguments: { id: 'abc' } },
|
||||
},
|
||||
CallToolResultSchema,
|
||||
);
|
||||
@@ -456,4 +459,367 @@ describe('McpService', () => {
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-server filtering', () => {
|
||||
const fakeActions = [
|
||||
{
|
||||
id: 'catalog:get-entity',
|
||||
name: 'get-entity',
|
||||
title: 'Get Entity',
|
||||
description: 'Fetch an entity',
|
||||
schema: {
|
||||
input: { type: 'object' as const },
|
||||
output: { type: 'object' as const },
|
||||
},
|
||||
attributes: { destructive: false, readOnly: true, idempotent: true },
|
||||
},
|
||||
{
|
||||
id: 'catalog:delete-entity',
|
||||
name: 'delete-entity',
|
||||
title: 'Delete Entity',
|
||||
description: 'Delete an entity',
|
||||
schema: {
|
||||
input: { type: 'object' as const },
|
||||
output: { type: 'object' as const },
|
||||
},
|
||||
attributes: { destructive: true, readOnly: false, idempotent: false },
|
||||
},
|
||||
{
|
||||
id: 'scaffolder:create-app',
|
||||
name: 'create-app',
|
||||
title: 'Create App',
|
||||
description: 'Create an app',
|
||||
schema: {
|
||||
input: { type: 'object' as const },
|
||||
output: { type: 'object' as const },
|
||||
},
|
||||
attributes: { destructive: false, readOnly: false, idempotent: false },
|
||||
},
|
||||
];
|
||||
|
||||
const fakeActionsService: ActionsService = {
|
||||
list: jest.fn(async () => ({ actions: fakeActions })),
|
||||
invoke: jest.fn(async () => ({ output: {} })),
|
||||
};
|
||||
|
||||
it('should return all actions when no filter rules are set', async () => {
|
||||
const mcpService = await McpService.create({
|
||||
actions: fakeActionsService,
|
||||
metrics: metricsServiceMock.mock(),
|
||||
});
|
||||
|
||||
const serverConfig: McpServerConfig = {
|
||||
name: 'All Actions',
|
||||
includeRules: [],
|
||||
excludeRules: [],
|
||||
};
|
||||
|
||||
const server = mcpService.getServer({
|
||||
credentials: mockCredentials.user(),
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
const client = new Client({ name: 'test', version: '1.0' });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
const result = await client.request(
|
||||
{ method: 'tools/list' },
|
||||
ListToolsResultSchema,
|
||||
);
|
||||
|
||||
expect(result.tools).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should scope actions using include filter rules', async () => {
|
||||
const mcpService = await McpService.create({
|
||||
actions: fakeActionsService,
|
||||
metrics: metricsServiceMock.mock(),
|
||||
});
|
||||
|
||||
const serverConfig: McpServerConfig = {
|
||||
name: 'Catalog Only',
|
||||
includeRules: parseFilterRules(
|
||||
new ConfigReader({
|
||||
include: [{ id: 'catalog:*' }],
|
||||
}).getConfigArray('include'),
|
||||
),
|
||||
excludeRules: [],
|
||||
};
|
||||
|
||||
const server = mcpService.getServer({
|
||||
credentials: mockCredentials.user(),
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
const client = new Client({ name: 'test', version: '1.0' });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
const result = await client.request(
|
||||
{ method: 'tools/list' },
|
||||
ListToolsResultSchema,
|
||||
);
|
||||
|
||||
expect(result.tools).toHaveLength(2);
|
||||
expect(result.tools.map(t => t.name)).toEqual([
|
||||
'catalog:get-entity',
|
||||
'catalog:delete-entity',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should apply exclude filter rules to remove destructive actions', async () => {
|
||||
const mcpService = await McpService.create({
|
||||
actions: fakeActionsService,
|
||||
metrics: metricsServiceMock.mock(),
|
||||
});
|
||||
|
||||
const serverConfig: McpServerConfig = {
|
||||
name: 'Catalog',
|
||||
includeRules: parseFilterRules(
|
||||
new ConfigReader({
|
||||
include: [{ id: 'catalog:*' }],
|
||||
}).getConfigArray('include'),
|
||||
),
|
||||
excludeRules: [{ attributes: { destructive: true } }],
|
||||
};
|
||||
|
||||
const server = mcpService.getServer({
|
||||
credentials: mockCredentials.user(),
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
const client = new Client({ name: 'test', version: '1.0' });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
const result = await client.request(
|
||||
{ method: 'tools/list' },
|
||||
ListToolsResultSchema,
|
||||
);
|
||||
|
||||
expect(result.tools).toHaveLength(1);
|
||||
expect(result.tools[0].name).toBe('catalog:get-entity');
|
||||
});
|
||||
|
||||
it('should apply include filter rules with glob patterns', async () => {
|
||||
const mcpService = await McpService.create({
|
||||
actions: fakeActionsService,
|
||||
metrics: metricsServiceMock.mock(),
|
||||
});
|
||||
|
||||
const serverConfig: McpServerConfig = {
|
||||
name: 'Catalog',
|
||||
includeRules: parseFilterRules(
|
||||
new ConfigReader({
|
||||
include: [{ id: 'catalog:get-*' }],
|
||||
}).getConfigArray('include'),
|
||||
),
|
||||
excludeRules: [],
|
||||
};
|
||||
|
||||
const server = mcpService.getServer({
|
||||
credentials: mockCredentials.user(),
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
const client = new Client({ name: 'test', version: '1.0' });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
const result = await client.request(
|
||||
{ method: 'tools/list' },
|
||||
ListToolsResultSchema,
|
||||
);
|
||||
|
||||
expect(result.tools).toHaveLength(1);
|
||||
expect(result.tools[0].name).toBe('catalog:get-entity');
|
||||
});
|
||||
|
||||
it('should reject tool calls for actions outside the filtered set', async () => {
|
||||
const mcpService = await McpService.create({
|
||||
actions: fakeActionsService,
|
||||
metrics: metricsServiceMock.mock(),
|
||||
});
|
||||
|
||||
const serverConfig: McpServerConfig = {
|
||||
name: 'Scaffolder',
|
||||
includeRules: parseFilterRules(
|
||||
new ConfigReader({
|
||||
include: [{ id: 'scaffolder:*' }],
|
||||
}).getConfigArray('include'),
|
||||
),
|
||||
excludeRules: [],
|
||||
};
|
||||
|
||||
const server = mcpService.getServer({
|
||||
credentials: mockCredentials.user(),
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
const client = new Client({ name: 'test', version: '1.0' });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
const result = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: { name: 'catalog:get-entity', arguments: {} },
|
||||
},
|
||||
CallToolResultSchema,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: expect.stringContaining(
|
||||
'Action "catalog:get-entity" not found',
|
||||
),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('namespaced tool names', () => {
|
||||
it('should use action ID as tool name by default', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
mockActionsRegistry.register({
|
||||
name: 'mock-action',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
schema: {
|
||||
input: z => z.object({}),
|
||||
output: z => z.object({}),
|
||||
},
|
||||
action: async () => ({ output: {} }),
|
||||
});
|
||||
|
||||
const mcpService = await McpService.create({
|
||||
actions: mockActionsRegistry,
|
||||
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 result = await client.request(
|
||||
{ method: 'tools/list' },
|
||||
ListToolsResultSchema,
|
||||
);
|
||||
|
||||
expect(result.tools[0].name).toBe('test:mock-action');
|
||||
});
|
||||
|
||||
it('should use short action name when namespacing is disabled', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
mockActionsRegistry.register({
|
||||
name: 'mock-action',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
schema: {
|
||||
input: z => z.object({}),
|
||||
output: z => z.object({}),
|
||||
},
|
||||
action: async () => ({ output: {} }),
|
||||
});
|
||||
|
||||
const mcpService = await McpService.create({
|
||||
actions: mockActionsRegistry,
|
||||
metrics: metricsServiceMock.mock(),
|
||||
namespacedToolNames: false,
|
||||
});
|
||||
|
||||
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 result = await client.request(
|
||||
{ method: 'tools/list' },
|
||||
ListToolsResultSchema,
|
||||
);
|
||||
|
||||
expect(result.tools[0].name).toBe('mock-action');
|
||||
});
|
||||
|
||||
it('should match tool calls using the namespaced name', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
mockActionsRegistry.register({
|
||||
name: 'mock-action',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
schema: {
|
||||
input: z => z.object({}),
|
||||
output: z => z.object({}),
|
||||
},
|
||||
action: async () => ({ output: {} }),
|
||||
});
|
||||
|
||||
const mcpService = await McpService.create({
|
||||
actions: mockActionsRegistry,
|
||||
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 result = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: { name: 'test:mock-action', arguments: {} },
|
||||
},
|
||||
CallToolResultSchema,
|
||||
);
|
||||
|
||||
expect(result.isError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import {
|
||||
ActionsService,
|
||||
ActionsServiceAction,
|
||||
MetricsServiceHistogram,
|
||||
MetricsService,
|
||||
} from '@backstage/backend-plugin-api/alpha';
|
||||
@@ -31,13 +32,20 @@ import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { handleErrors } from './handleErrors';
|
||||
import { bucketBoundaries, McpServerOperationAttributes } from '../metrics';
|
||||
import { FilterRule, McpServerConfig } from '../config';
|
||||
|
||||
export class McpService {
|
||||
private readonly actions: ActionsService;
|
||||
private readonly namespacedToolNames: boolean;
|
||||
private readonly operationDuration: MetricsServiceHistogram<McpServerOperationAttributes>;
|
||||
|
||||
constructor(actions: ActionsService, metrics: MetricsService) {
|
||||
constructor(
|
||||
actions: ActionsService,
|
||||
metrics: MetricsService,
|
||||
namespacedToolNames?: boolean,
|
||||
) {
|
||||
this.actions = actions;
|
||||
this.namespacedToolNames = namespacedToolNames ?? true;
|
||||
this.operationDuration =
|
||||
metrics.createHistogram<McpServerOperationAttributes>(
|
||||
'mcp.server.operation.duration',
|
||||
@@ -52,17 +60,27 @@ export class McpService {
|
||||
static async create({
|
||||
actions,
|
||||
metrics,
|
||||
namespacedToolNames,
|
||||
}: {
|
||||
actions: ActionsService;
|
||||
metrics: MetricsService;
|
||||
namespacedToolNames?: boolean;
|
||||
}) {
|
||||
return new McpService(actions, metrics);
|
||||
return new McpService(actions, metrics, namespacedToolNames);
|
||||
}
|
||||
|
||||
getServer({ credentials }: { credentials: BackstageCredentials }) {
|
||||
getServer({
|
||||
credentials,
|
||||
serverConfig,
|
||||
}: {
|
||||
credentials: BackstageCredentials;
|
||||
serverConfig?: McpServerConfig;
|
||||
}) {
|
||||
const serverName = serverConfig?.name ?? 'backstage';
|
||||
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: 'backstage',
|
||||
name: serverName,
|
||||
// TODO: this version will most likely change in the future.
|
||||
version,
|
||||
},
|
||||
@@ -74,8 +92,12 @@ export class McpService {
|
||||
let errorType: string | undefined;
|
||||
|
||||
try {
|
||||
// TODO: switch this to be configuration based later
|
||||
const { actions } = await this.actions.list({ credentials });
|
||||
const { actions: allActions } = await this.actions.list({
|
||||
credentials,
|
||||
});
|
||||
const actions = serverConfig
|
||||
? this.filterActions(allActions, serverConfig)
|
||||
: allActions;
|
||||
|
||||
return {
|
||||
tools: actions.map(action => ({
|
||||
@@ -83,7 +105,7 @@ export class McpService {
|
||||
// todo(blam): this is unfortunately not supported by most clients yet.
|
||||
// When this is provided you need to provide structuredContent instead.
|
||||
// outputSchema: action.schema.output,
|
||||
name: action.name,
|
||||
name: this.getToolName(action),
|
||||
description: action.description,
|
||||
annotations: {
|
||||
title: action.title,
|
||||
@@ -114,8 +136,14 @@ export class McpService {
|
||||
|
||||
try {
|
||||
const result = await handleErrors(async () => {
|
||||
const { actions } = await this.actions.list({ credentials });
|
||||
const action = actions.find(a => a.name === params.name);
|
||||
const { actions: allActions } = await this.actions.list({
|
||||
credentials,
|
||||
});
|
||||
const actions = serverConfig
|
||||
? this.filterActions(allActions, serverConfig)
|
||||
: allActions;
|
||||
|
||||
const action = actions.find(a => this.getToolName(a) === params.name);
|
||||
|
||||
if (!action) {
|
||||
throw new NotFoundError(`Action "${params.name}" not found`);
|
||||
@@ -169,4 +197,53 @@ export class McpService {
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
private filterActions(
|
||||
actions: ActionsServiceAction[],
|
||||
serverConfig: McpServerConfig,
|
||||
): ActionsServiceAction[] {
|
||||
const { includeRules, excludeRules } = serverConfig;
|
||||
if (includeRules.length === 0 && excludeRules.length === 0) {
|
||||
return actions;
|
||||
}
|
||||
|
||||
return actions.filter(action => {
|
||||
if (excludeRules.some(rule => this.matchesRule(action, rule))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (includeRules.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return includeRules.some(rule => this.matchesRule(action, rule));
|
||||
});
|
||||
}
|
||||
|
||||
private getToolName(action: ActionsServiceAction): string {
|
||||
if (this.namespacedToolNames) {
|
||||
return action.id;
|
||||
}
|
||||
return action.name;
|
||||
}
|
||||
|
||||
private matchesRule(action: ActionsServiceAction, rule: FilterRule): boolean {
|
||||
if (rule.idMatcher && !rule.idMatcher.match(action.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rule.attributes) {
|
||||
for (const [key, value] of Object.entries(rule.attributes)) {
|
||||
if (
|
||||
action.attributes[
|
||||
key as 'destructive' | 'readOnly' | 'idempotent'
|
||||
] !== value
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6272,6 +6272,7 @@ __metadata:
|
||||
"@backstage/backend-test-utils": "workspace:^"
|
||||
"@backstage/catalog-client": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/config": "workspace:^"
|
||||
"@backstage/errors": "workspace:^"
|
||||
"@backstage/plugin-catalog-node": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
@@ -6281,6 +6282,7 @@ __metadata:
|
||||
"@types/supertest": "npm:^2.0.8"
|
||||
express: "npm:^4.22.0"
|
||||
express-promise-router: "npm:^4.1.0"
|
||||
minimatch: "npm:^10.2.1"
|
||||
supertest: "npm:^7.0.0"
|
||||
zod: "npm:^3.25.76"
|
||||
languageName: unknown
|
||||
|
||||
Reference in New Issue
Block a user