feat: implementing simple mcp backend to surface actions registry actions

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-06-17 08:26:03 +02:00
parent a02fd02a0c
commit ae7209b030
11 changed files with 700 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+28
View File
@@ -0,0 +1,28 @@
# MCP Backend
This plugin backend was templated using the Backstage CLI. You should replace this text with a description of your plugin backend.
## Installation
This plugin is installed via the `@backstage/plugin-mcp-backend` package. To install it to your backend package, run the following command:
```bash
# From your root directory
yarn --cwd packages/backend add @backstage/plugin-mcp-backend
```
Then add the plugin to your backend in `packages/backend/src/index.ts`:
```ts
const backend = createBackend();
// ...
backend.add(import('@backstage/plugin-mcp-backend'));
```
## Development
This plugin backend can be started in a standalone mode from directly in this
package with `yarn start`. It is a limited setup that is most convenient when
developing the plugin backend itself.
If you want to run the entire project, including the frontend, run `yarn start` from the root directory.
+9
View File
@@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-mcp-backend
title: '@backstage/plugin-mcp-backend'
spec:
lifecycle: experimental
type: backstage-backend-plugin
owner: maintainers
+84
View File
@@ -0,0 +1,84 @@
/*
* Copyright 2025 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 { createBackend } from '@backstage/backend-defaults';
import { createBackendPlugin } from '@backstage/backend-plugin-api';
import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha';
import { mockServices } from '@backstage/backend-test-utils';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
const backend = createBackend();
backend.add(mockServices.auth.factory());
backend.add(mockServices.httpAuth.factory());
// TEMPLATE NOTE:
// Rather than using a real catalog you can use a mock with a fixed set of entities.
backend.add(
catalogServiceMock.factory({
entities: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'sample',
title: 'Sample Component',
},
spec: {
type: 'service',
},
},
],
}),
);
backend.add(
createBackendPlugin({
pluginId: 'local',
register({ registerInit }) {
registerInit({
deps: {
actionsRegistry: actionsRegistryServiceRef,
},
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'make-greeting',
title: 'Test Action',
description: 'Test Action',
schema: {
input: z =>
z.object({
name: z.string(),
}),
output: z =>
z.object({
greeting: z.string(),
}),
},
action: async ({ input }) => ({
output: {
greeting: `Hello ${input.name}!`,
},
}),
});
},
});
},
}),
);
backend.add(import('../src'));
backend.start();
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@backstage/plugin-mcp-backend",
"version": "0.1.0",
"backstage": {
"role": "backend-plugin",
"pluginId": "mcp"
},
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"private": true,
"license": "Apache-2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "backstage-cli package build",
"clean": "backstage-cli package clean",
"lint": "backstage-cli package lint",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"start": "backstage-cli package start",
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/catalog-client": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/types": "workspace:^",
"@modelcontextprotocol/sdk": "^1.12.3",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/express": "^4.17.6",
"@types/supertest": "^2.0.8",
"supertest": "^6.2.4"
}
}
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2025 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 { mcpPlugin } from './plugin';
+72
View File
@@ -0,0 +1,72 @@
/*
* Copyright 2025 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 {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { json, Router } from 'express';
import { McpService } from './services/McpService';
import { createStreamableRouter } from './routers/createStreamableRouter';
import { createSseRouter } from './routers/createSseRouter';
import {
actionsRegistryServiceRef,
actionsServiceRef,
} from '@backstage/backend-plugin-api/alpha';
/**
* mcpPlugin backend plugin
*
* @public
*/
export const mcpPlugin = createBackendPlugin({
pluginId: 'mcp',
register(env) {
env.registerInit({
deps: {
logger: coreServices.logger,
auth: coreServices.auth,
httpAuth: coreServices.httpAuth,
httpRouter: coreServices.httpRouter,
actions: actionsServiceRef,
registry: actionsRegistryServiceRef,
},
async init({ actions, logger, httpRouter, httpAuth }) {
const mcpService = await McpService.create({
actions,
});
const sseRouter = createSseRouter({
mcpService,
httpAuth,
});
const streamableRouter = createStreamableRouter({
mcpService,
httpAuth,
logger,
});
const router = Router();
router.use(json());
router.use('/sse', sseRouter);
router.use('/', streamableRouter);
httpRouter.use(router);
},
});
},
});
@@ -0,0 +1,63 @@
/*
* Copyright 2025 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 Router from 'express-promise-router';
import { McpService } from '../services/McpService';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { HttpAuthService } from '@backstage/backend-plugin-api';
/**
* Legacy SSE endpoint for older clients, hopefully will not be needed for much longer.
*/
export const createSseRouter = ({
mcpService,
httpAuth,
}: {
mcpService: McpService;
httpAuth: HttpAuthService;
}) => {
const router = Router();
const transportsToSessionId = new Map<string, SSEServerTransport>();
router.get('/', async (req, res) => {
const server = mcpService.getServer({
credentials: await httpAuth.credentials(req),
});
const transport = new SSEServerTransport(
`${req.originalUrl}/messages`,
res,
);
transportsToSessionId.set(transport.sessionId, transport);
res.on('close', () => {
transportsToSessionId.delete(transport.sessionId);
});
await server.connect(transport);
});
router.post('/messages', async (req, res) => {
const sessionId = req.query.sessionId as string;
const transport = transportsToSessionId.get(sessionId);
if (transport) {
await transport.handlePostMessage(req, res, req.body);
} else {
res.status(400).send('No transport found for sessionId');
}
});
return router;
};
@@ -0,0 +1,99 @@
/*
* Copyright 2025 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 { Router } from 'express';
import { McpService } from '../services/McpService';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { HttpAuthService, LoggerService } from '@backstage/backend-plugin-api';
import { isError } from '@backstage/errors';
export const createStreamableRouter = ({
mcpService,
httpAuth,
logger,
}: {
mcpService: McpService;
logger: LoggerService;
httpAuth: HttpAuthService;
}): Router => {
const router = Router();
router.post('/', async (req, res) => {
try {
const server = mcpService.getServer({
credentials: await httpAuth.credentials(req),
});
const transport = new StreamableHTTPServerTransport({
// stateless implementation for now, so that we can support multiple
// instances of the server backend, and avoid sticky sessions.
sessionIdGenerator: undefined,
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
res.on('close', () => {
transport.close();
server.close();
});
} catch (error) {
if (isError(error)) {
logger.error(error.message);
}
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
},
id: null,
});
}
}
});
router.get('/', async (_, res) => {
// We only support POST requests, so we return a 405 error for all other methods.
res.writeHead(405).end(
JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Method not allowed.',
},
id: null,
}),
);
});
router.delete('/', async (_, res) => {
// We only support POST requests, so we return a 405 error for all other methods.
res.writeHead(405).end(
JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Method not allowed.',
},
id: null,
}),
);
});
return router;
};
@@ -0,0 +1,193 @@
/*
* Copyright 2025 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 { mockCredentials } from '@backstage/backend-test-utils';
import { McpService } from './McpService';
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import {
CallToolResultSchema,
ListToolsResultSchema,
} from '@modelcontextprotocol/sdk/types.js';
describe('McpService', () => {
it('should list the available actions as tools in the mcp backend', async () => {
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,
});
const server = mcpService.getServer({
credentials: mockCredentials.user(),
});
const client = new Client({
name: 'test client',
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).toEqual([
{
annotations: {
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
readOnlyHint: false,
title: 'Test',
},
description: 'Test',
inputSchema: {
$schema: 'http://json-schema.org/draft-07/schema#',
additionalProperties: false,
properties: {
input: {
type: 'string',
},
},
required: ['input'],
type: 'object',
},
name: 'mock-action',
outputSchema: {
$schema: 'http://json-schema.org/draft-07/schema#',
additionalProperties: false,
properties: {
output: {
type: 'string',
},
},
required: ['output'],
type: 'object',
},
},
]);
});
it('should call the action when the tool is invoked', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockAction = jest.fn(async () => ({ output: { output: 'test' } }));
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: mockAction,
});
const mcpService = await McpService.create({
actions: mockActionsRegistry,
});
const server = mcpService.getServer({
credentials: mockCredentials.user(),
});
const client = new Client({
name: 'test client',
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: 'mock-action', arguments: { input: 'test' } },
},
CallToolResultSchema,
);
expect(mockAction).toHaveBeenCalledWith(
expect.objectContaining({
credentials: mockCredentials.user(),
input: { input: 'test' },
logger: expect.anything(),
}),
);
expect(result.structuredContent).toEqual({ output: 'test' });
});
it('should return an error when the action is not found', async () => {
const mcpService = await McpService.create({
actions: actionsRegistryServiceMock(),
});
const server = mcpService.getServer({
credentials: mockCredentials.user(),
});
const client = new Client({
name: 'test client',
version: '1.0',
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
await expect(
client.request(
{
method: 'tools/call',
params: { name: 'mock-action', arguments: { input: 'test' } },
},
CallToolResultSchema,
),
).rejects.toThrow('Action mock-action not found');
});
});
@@ -0,0 +1,87 @@
/*
* Copyright 2025 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 { BackstageCredentials } from '@backstage/backend-plugin-api';
import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { JsonObject } from '@backstage/types';
import { ActionsService } from '@backstage/backend-plugin-api/alpha';
import { version } from '@backstage/plugin-mcp-backend/package.json';
export class McpService {
constructor(private readonly actions: ActionsService) {}
static async create({ actions }: { actions: ActionsService }) {
return new McpService(actions);
}
getServer({ credentials }: { credentials: BackstageCredentials }) {
const server = new McpServer(
{
name: 'backstage',
// TODO: this version will most likely change in the future.
version,
},
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
const { actions } = await this.actions.list({ credentials });
return {
tools: actions.map(action => ({
inputSchema: action.schema.input,
outputSchema: action.schema.output,
name: action.name,
description: action.description,
annotations: {
title: action.title,
destructiveHint: action.attributes.destructive,
idempotentHint: action.attributes.idempotent,
readOnlyHint: action.attributes.readOnly,
openWorldHint: false,
},
})),
};
});
server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
const { actions } = await this.actions.list({ credentials });
const action = actions.find(a => a.name === params.name);
if (!action) {
throw new Error(`Action ${params.name} not found`);
}
const { output } = await this.actions.invoke({
id: action.id,
input: params.arguments as JsonObject,
credentials,
});
return {
// all actions need to be defined with an structured output,
// so we can return the output using this format rather than
// just text.
structuredContent: output,
};
});
return server;
}
}