diff --git a/plugins/mcp-backend/.eslintrc.js b/plugins/mcp-backend/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/mcp-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/mcp-backend/README.md b/plugins/mcp-backend/README.md new file mode 100644 index 0000000000..03d2c24449 --- /dev/null +++ b/plugins/mcp-backend/README.md @@ -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. diff --git a/plugins/mcp-backend/catalog-info.yaml b/plugins/mcp-backend/catalog-info.yaml new file mode 100644 index 0000000000..37251f25cd --- /dev/null +++ b/plugins/mcp-backend/catalog-info.yaml @@ -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 diff --git a/plugins/mcp-backend/dev/index.ts b/plugins/mcp-backend/dev/index.ts new file mode 100644 index 0000000000..74a6d814f8 --- /dev/null +++ b/plugins/mcp-backend/dev/index.ts @@ -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(); diff --git a/plugins/mcp-backend/package.json b/plugins/mcp-backend/package.json new file mode 100644 index 0000000000..f4b9f6d450 --- /dev/null +++ b/plugins/mcp-backend/package.json @@ -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" + } +} diff --git a/plugins/mcp-backend/src/index.ts b/plugins/mcp-backend/src/index.ts new file mode 100644 index 0000000000..9216a19c0c --- /dev/null +++ b/plugins/mcp-backend/src/index.ts @@ -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'; diff --git a/plugins/mcp-backend/src/plugin.ts b/plugins/mcp-backend/src/plugin.ts new file mode 100644 index 0000000000..7fdfb42550 --- /dev/null +++ b/plugins/mcp-backend/src/plugin.ts @@ -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); + }, + }); + }, +}); diff --git a/plugins/mcp-backend/src/routers/createSseRouter.ts b/plugins/mcp-backend/src/routers/createSseRouter.ts new file mode 100644 index 0000000000..05339b3844 --- /dev/null +++ b/plugins/mcp-backend/src/routers/createSseRouter.ts @@ -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(); + + 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; +}; diff --git a/plugins/mcp-backend/src/routers/createStreamableRouter.ts b/plugins/mcp-backend/src/routers/createStreamableRouter.ts new file mode 100644 index 0000000000..f373106ce2 --- /dev/null +++ b/plugins/mcp-backend/src/routers/createStreamableRouter.ts @@ -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; +}; diff --git a/plugins/mcp-backend/src/services/McpService.test.ts b/plugins/mcp-backend/src/services/McpService.test.ts new file mode 100644 index 0000000000..6478bb4f29 --- /dev/null +++ b/plugins/mcp-backend/src/services/McpService.test.ts @@ -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'); + }); +}); diff --git a/plugins/mcp-backend/src/services/McpService.ts b/plugins/mcp-backend/src/services/McpService.ts new file mode 100644 index 0000000000..5b50f0707f --- /dev/null +++ b/plugins/mcp-backend/src/services/McpService.ts @@ -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; + } +}