From ae7209b0305ac73c18e33e4ec514607cfe79eacb Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Jun 2025 08:26:03 +0200 Subject: [PATCH 01/10] feat: implementing simple mcp backend to surface actions registry actions Signed-off-by: benjdlambert --- plugins/mcp-backend/.eslintrc.js | 1 + plugins/mcp-backend/README.md | 28 +++ plugins/mcp-backend/catalog-info.yaml | 9 + plugins/mcp-backend/dev/index.ts | 84 ++++++++ plugins/mcp-backend/package.json | 48 +++++ plugins/mcp-backend/src/index.ts | 16 ++ plugins/mcp-backend/src/plugin.ts | 72 +++++++ .../src/routers/createSseRouter.ts | 63 ++++++ .../src/routers/createStreamableRouter.ts | 99 +++++++++ .../src/services/McpService.test.ts | 193 ++++++++++++++++++ .../mcp-backend/src/services/McpService.ts | 87 ++++++++ 11 files changed, 700 insertions(+) create mode 100644 plugins/mcp-backend/.eslintrc.js create mode 100644 plugins/mcp-backend/README.md create mode 100644 plugins/mcp-backend/catalog-info.yaml create mode 100644 plugins/mcp-backend/dev/index.ts create mode 100644 plugins/mcp-backend/package.json create mode 100644 plugins/mcp-backend/src/index.ts create mode 100644 plugins/mcp-backend/src/plugin.ts create mode 100644 plugins/mcp-backend/src/routers/createSseRouter.ts create mode 100644 plugins/mcp-backend/src/routers/createStreamableRouter.ts create mode 100644 plugins/mcp-backend/src/services/McpService.test.ts create mode 100644 plugins/mcp-backend/src/services/McpService.ts 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; + } +} From 9d6a0fd97510468d9f13c4e396d44d56b9a8acad Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Jun 2025 08:43:42 +0200 Subject: [PATCH 02/10] feat: add some tests for streamable and sse routes Signed-off-by: benjdlambert --- packages/backend/package.json | 1 + packages/backend/src/index.ts | 1 + plugins/mcp-backend/src/plugin.test.ts | 188 +++++++++++++++ yarn.lock | 314 +++++++++++++++++++++++-- 4 files changed, 486 insertions(+), 18 deletions(-) create mode 100644 plugins/mcp-backend/src/plugin.test.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index 66cf7dccfc..9471b9f012 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -47,6 +47,7 @@ "@backstage/plugin-events-backend": "workspace:^", "@backstage/plugin-events-backend-module-google-pubsub": "workspace:^", "@backstage/plugin-kubernetes-backend": "workspace:^", + "@backstage/plugin-mcp-backend": "workspace:^", "@backstage/plugin-notifications-backend": "workspace:^", "@backstage/plugin-permission-backend": "workspace:^", "@backstage/plugin-permission-backend-module-allow-all-policy": "workspace:^", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 23d814f1ae..b068ad2711 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -62,4 +62,5 @@ backend.add(import('@backstage/plugin-notifications-backend')); backend.add(import('./instanceMetadata')); backend.add(import('@backstage/plugin-events-backend-module-google-pubsub')); +backend.add(import('@backstage/plugin-mcp-backend')); backend.start(); diff --git a/plugins/mcp-backend/src/plugin.test.ts b/plugins/mcp-backend/src/plugin.test.ts new file mode 100644 index 0000000000..58608bc409 --- /dev/null +++ b/plugins/mcp-backend/src/plugin.test.ts @@ -0,0 +1,188 @@ +/* + * 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 { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import { mcpPlugin } from './plugin'; +import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; +import { createBackendPlugin } from '@backstage/backend-plugin-api'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types'; +import { rootConfigServiceFactory } from '@backstage/backend-defaults/rootConfig'; + +describe('Mcp Backend', () => { + const mockPluginWithActions = createBackendPlugin({ + pluginId: 'local', + register({ registerInit }) { + registerInit({ + deps: { actionsRegistry: actionsRegistryServiceRef }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'make-greeting', + title: 'Make Greeting', + description: 'Make a greeting', + schema: { + input: z => z.object({ name: z.string() }), + output: z => z.object({ greeting: z.string() }), + }, + action: async ({ input }) => ({ + output: { greeting: `Hello ${input.name}!` }, + }), + }); + }, + }); + }, + }); + + const getContext = async () => { + const { server } = await startTestBackend({ + features: [ + mcpPlugin, + mockPluginWithActions, + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { + pluginSources: ['local'], + }, + }, + }, + }), + ], + }); + + const client = new Client({ + name: 'test client', + version: '1.0', + }); + + const address = server.address(); + if (typeof address !== 'object' || !('port' in address!)) { + throw new Error('server broke'); + } + + return { + client, + serverAddress: `http://localhost:${address.port}`, + }; + }; + + it('should support streamable spec', async () => { + const { client, serverAddress } = await getContext(); + const transport = new StreamableHTTPClientTransport( + new URL(`${serverAddress}/api/mcp`), + ); + + await client.connect(transport); + + const result = await client.request( + { + method: 'tools/list', + }, + ListToolsResultSchema, + ); + + expect(result.tools).toEqual([ + { + annotations: { + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + readOnlyHint: false, + title: 'Make Greeting', + }, + description: 'Make a greeting', + inputSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + name: { + type: 'string', + }, + }, + required: ['name'], + type: 'object', + }, + name: 'make-greeting', + outputSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + greeting: { + type: 'string', + }, + }, + required: ['greeting'], + type: 'object', + }, + }, + ]); + }); + + it('should support sse spec', async () => { + const { client, serverAddress } = await getContext(); + const transport = new SSEClientTransport( + new URL(`${serverAddress}/api/mcp/sse`), + ); + + await client.connect(transport); + + const result = await client.request( + { + method: 'tools/list', + }, + ListToolsResultSchema, + ); + + await client.close(); + + expect(result.tools).toEqual([ + { + annotations: { + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + readOnlyHint: false, + title: 'Make Greeting', + }, + description: 'Make a greeting', + inputSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + name: { + type: 'string', + }, + }, + required: ['name'], + type: 'object', + }, + name: 'make-greeting', + outputSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + greeting: { + type: 'string', + }, + }, + required: ['greeting'], + type: 'object', + }, + }, + ]); + }); +}); diff --git a/yarn.lock b/yarn.lock index 6bc788890f..cf827ae141 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7099,6 +7099,28 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-mcp-backend@workspace:^, @backstage/plugin-mcp-backend@workspace:plugins/mcp-backend": + version: 0.0.0-use.local + resolution: "@backstage/plugin-mcp-backend@workspace:plugins/mcp-backend" + dependencies: + "@backstage/backend-defaults": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-client": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-catalog-node": "workspace:^" + "@backstage/types": "workspace:^" + "@modelcontextprotocol/sdk": "npm:^1.12.3" + "@types/express": "npm:^4.17.6" + "@types/supertest": "npm:^2.0.8" + express: "npm:^4.17.1" + express-promise-router: "npm:^4.1.0" + supertest: "npm:^6.2.4" + zod: "npm:^3.22.4" + languageName: unknown + linkType: soft + "@backstage/plugin-notifications-backend-module-email@workspace:plugins/notifications-backend-module-email": version: 0.0.0-use.local resolution: "@backstage/plugin-notifications-backend-module-email@workspace:plugins/notifications-backend-module-email" @@ -12063,6 +12085,25 @@ __metadata: languageName: node linkType: hard +"@modelcontextprotocol/sdk@npm:^1.12.3": + version: 1.12.3 + resolution: "@modelcontextprotocol/sdk@npm:1.12.3" + dependencies: + ajv: "npm:^6.12.6" + content-type: "npm:^1.0.5" + cors: "npm:^2.8.5" + cross-spawn: "npm:^7.0.5" + eventsource: "npm:^3.0.2" + express: "npm:^5.0.1" + express-rate-limit: "npm:^7.5.0" + pkce-challenge: "npm:^5.0.0" + raw-body: "npm:^3.0.0" + zod: "npm:^3.23.8" + zod-to-json-schema: "npm:^3.24.1" + checksum: 10/abab9b3fcce45370aadc260e9f02587308455ea907cf58b5e7f2e27c0ef4d19bf591f80284592b69f3d1656e4b362b2a96788eb1492daa3e7e2129c49b3eea98 + languageName: node + linkType: hard + "@module-federation/bridge-react-webpack-plugin@npm:0.9.1": version: 0.9.1 resolution: "@module-federation/bridge-react-webpack-plugin@npm:0.9.1" @@ -24724,6 +24765,16 @@ __metadata: languageName: node linkType: hard +"accepts@npm:^2.0.0": + version: 2.0.0 + resolution: "accepts@npm:2.0.0" + dependencies: + mime-types: "npm:^3.0.0" + negotiator: "npm:^1.0.0" + checksum: 10/ea1343992b40b2bfb3a3113fa9c3c2f918ba0f9197ae565c48d3f84d44b174f6b1d5cd9989decd7655963eb03a272abc36968cc439c2907f999bd5ef8653d5a7 + languageName: node + linkType: hard + "acorn-globals@npm:^6.0.0": version: 6.0.0 resolution: "acorn-globals@npm:6.0.0" @@ -24939,7 +24990,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^6.12.2, ajv@npm:^6.12.4, ajv@npm:^6.12.5, ajv@npm:^6.5.5": +"ajv@npm:^6.12.2, ajv@npm:^6.12.4, ajv@npm:^6.12.5, ajv@npm:^6.12.6, ajv@npm:^6.5.5": version: 6.12.6 resolution: "ajv@npm:6.12.6" dependencies: @@ -26282,6 +26333,23 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:^2.2.0": + version: 2.2.0 + resolution: "body-parser@npm:2.2.0" + dependencies: + bytes: "npm:^3.1.2" + content-type: "npm:^1.0.5" + debug: "npm:^4.4.0" + http-errors: "npm:^2.0.0" + iconv-lite: "npm:^0.6.3" + on-finished: "npm:^2.4.1" + qs: "npm:^6.14.0" + raw-body: "npm:^3.0.0" + type-is: "npm:^2.0.0" + checksum: 10/e9d844b036bd15970df00a16f373c7ed28e1ef870974a0a1d4d6ef60d70e01087cc20a0dbb2081c49a88e3c08ce1d87caf1e2898c615dffa193f63e8faa8a84e + languageName: node + linkType: hard + "bonjour-service@npm:^1.2.1": version: 1.2.1 resolution: "bonjour-service@npm:1.2.1" @@ -26646,7 +26714,7 @@ __metadata: languageName: node linkType: hard -"bytes@npm:3.1.2": +"bytes@npm:3.1.2, bytes@npm:^3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388 @@ -28000,6 +28068,15 @@ __metadata: languageName: node linkType: hard +"content-disposition@npm:^1.0.0": + version: 1.0.0 + resolution: "content-disposition@npm:1.0.0" + dependencies: + safe-buffer: "npm:5.2.1" + checksum: 10/0dcc1a2d7874526b0072df3011b134857b49d97a3bc135bb464a299525d4972de6f5f464fd64da6c4d8406d26a1ffb976f62afaffef7723b1021a44498d10e08 + languageName: node + linkType: hard + "content-type@npm:^1.0.4, content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5": version: 1.0.5 resolution: "content-type@npm:1.0.5" @@ -28052,6 +28129,13 @@ __metadata: languageName: node linkType: hard +"cookie-signature@npm:^1.2.1": + version: 1.2.2 + resolution: "cookie-signature@npm:1.2.2" + checksum: 10/be44a3c9a56f3771aea3a8bd8ad8f0a8e2679bcb967478267f41a510b4eb5ec55085386ba79c706c4ac21605ca76f4251973444b90283e0eb3eeafe8a92c7708 + languageName: node + linkType: hard + "cookie@npm:0.7.1": version: 0.7.1 resolution: "cookie@npm:0.7.1" @@ -28059,7 +28143,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:0.7.2, cookie@npm:^0.7.0, cookie@npm:^0.7.2": +"cookie@npm:0.7.2, cookie@npm:^0.7.0, cookie@npm:^0.7.1, cookie@npm:^0.7.2": version: 0.7.2 resolution: "cookie@npm:0.7.2" checksum: 10/24b286c556420d4ba4e9bc09120c9d3db7d28ace2bd0f8ccee82422ce42322f73c8312441271e5eefafbead725980e5996cc02766dbb89a90ac7f5636ede608f @@ -29953,7 +30037,7 @@ __metadata: languageName: node linkType: hard -"encodeurl@npm:~2.0.0": +"encodeurl@npm:^2.0.0, encodeurl@npm:~2.0.0": version: 2.0.0 resolution: "encodeurl@npm:2.0.0" checksum: 10/abf5cd51b78082cf8af7be6785813c33b6df2068ce5191a40ca8b1afe6a86f9230af9a9ce694a5ce4665955e5c1120871826df9c128a642e09c58d592e2807fe @@ -31032,7 +31116,7 @@ __metadata: languageName: node linkType: hard -"etag@npm:~1.8.1": +"etag@npm:^1.8.1, etag@npm:~1.8.1": version: 1.8.1 resolution: "etag@npm:1.8.1" checksum: 10/571aeb3dbe0f2bbd4e4fadbdb44f325fc75335cd5f6f6b6a091e6a06a9f25ed5392f0863c5442acb0646787446e816f13cbfc6edce5b07658541dff573cab1ff @@ -31100,7 +31184,7 @@ __metadata: languageName: node linkType: hard -"eventsource@npm:^3.0.6": +"eventsource@npm:^3.0.2, eventsource@npm:^3.0.6": version: 3.0.7 resolution: "eventsource@npm:3.0.7" dependencies: @@ -31283,6 +31367,7 @@ __metadata: "@backstage/plugin-events-backend": "workspace:^" "@backstage/plugin-events-backend-module-google-pubsub": "workspace:^" "@backstage/plugin-kubernetes-backend": "workspace:^" + "@backstage/plugin-mcp-backend": "workspace:^" "@backstage/plugin-notifications-backend": "workspace:^" "@backstage/plugin-permission-backend": "workspace:^" "@backstage/plugin-permission-backend-module-allow-all-policy": "workspace:^" @@ -31516,6 +31601,41 @@ __metadata: languageName: node linkType: hard +"express@npm:^5.0.1": + version: 5.1.0 + resolution: "express@npm:5.1.0" + dependencies: + accepts: "npm:^2.0.0" + body-parser: "npm:^2.2.0" + content-disposition: "npm:^1.0.0" + content-type: "npm:^1.0.5" + cookie: "npm:^0.7.1" + cookie-signature: "npm:^1.2.1" + debug: "npm:^4.4.0" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + etag: "npm:^1.8.1" + finalhandler: "npm:^2.1.0" + fresh: "npm:^2.0.0" + http-errors: "npm:^2.0.0" + merge-descriptors: "npm:^2.0.0" + mime-types: "npm:^3.0.0" + on-finished: "npm:^2.4.1" + once: "npm:^1.4.0" + parseurl: "npm:^1.3.3" + proxy-addr: "npm:^2.0.7" + qs: "npm:^6.14.0" + range-parser: "npm:^1.2.1" + router: "npm:^2.2.0" + send: "npm:^1.1.0" + serve-static: "npm:^2.2.0" + statuses: "npm:^2.0.1" + type-is: "npm:^2.0.1" + vary: "npm:^1.1.2" + checksum: 10/6dba00bbdf308f43a84ed3f07a7e9870d5208f2a0b8f60f39459dda089750379747819863fad250849d3c9163833f33f94ce69d73938df31e0c5a430800d7e56 + languageName: node + linkType: hard + "ext@npm:^1.1.2": version: 1.7.0 resolution: "ext@npm:1.7.0" @@ -31921,6 +32041,20 @@ __metadata: languageName: node linkType: hard +"finalhandler@npm:^2.1.0": + version: 2.1.0 + resolution: "finalhandler@npm:2.1.0" + dependencies: + debug: "npm:^4.4.0" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + on-finished: "npm:^2.4.1" + parseurl: "npm:^1.3.3" + statuses: "npm:^2.0.1" + checksum: 10/b2bd68c310e2c463df0ab747ab05f8defbc540b8c3f2442f86e7d084ac8acbc31f8cae079931b7f5a406521501941e3395e963de848a0aaf45dd414adeb5ff4e + languageName: node + linkType: hard + "find-cache-dir@npm:^2.0.0": version: 2.1.0 resolution: "find-cache-dir@npm:2.1.0" @@ -32286,6 +32420,18 @@ __metadata: languageName: node linkType: hard +"formidable@npm:^2.1.2": + version: 2.1.5 + resolution: "formidable@npm:2.1.5" + dependencies: + "@paralleldrive/cuid2": "npm:^2.2.2" + dezalgo: "npm:^1.0.4" + once: "npm:^1.4.0" + qs: "npm:^6.11.0" + checksum: 10/ee96de12e91d63fe86479ffe5bf59004bb3f43e00ce7ccecd1b1ff10b5d1a89a19b1ede727e1fe57ef596c377b9f9300212a5f7bab14fd28f3c4ffe12dbb4cc7 + languageName: node + linkType: hard + "formidable@npm:^3.5.4": version: 3.5.4 resolution: "formidable@npm:3.5.4" @@ -32348,6 +32494,13 @@ __metadata: languageName: node linkType: hard +"fresh@npm:^2.0.0": + version: 2.0.0 + resolution: "fresh@npm:2.0.0" + checksum: 10/44e1468488363074641991c1340d2a10c5a6f6d7c353d89fd161c49d120c58ebf9890720f7584f509058385836e3ce50ddb60e9f017315a4ba8c6c3461813bfc + languageName: node + linkType: hard + "from2@npm:^2.3.0": version: 2.3.0 resolution: "from2@npm:2.3.0" @@ -38647,6 +38800,13 @@ __metadata: languageName: node linkType: hard +"merge-descriptors@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-descriptors@npm:2.0.0" + checksum: 10/e383332e700a94682d0125a36c8be761142a1320fc9feeb18e6e36647c9edf064271645f5669b2c21cf352116e561914fd8aa831b651f34db15ef4038c86696a + languageName: node + linkType: hard + "merge-stream@npm:^2.0.0": version: 2.0.0 resolution: "merge-stream@npm:2.0.0" @@ -39030,13 +39190,20 @@ __metadata: languageName: node linkType: hard -"mime-db@npm:1.52.0, mime-db@npm:>= 1.43.0 < 2": +"mime-db@npm:1.52.0": version: 1.52.0 resolution: "mime-db@npm:1.52.0" checksum: 10/54bb60bf39e6f8689f6622784e668a3d7f8bed6b0d886f5c3c446cb3284be28b30bf707ed05d0fe44a036f8469976b2629bbea182684977b084de9da274694d7 languageName: node linkType: hard +"mime-db@npm:>= 1.43.0 < 2, mime-db@npm:^1.54.0": + version: 1.54.0 + resolution: "mime-db@npm:1.54.0" + checksum: 10/9e7834be3d66ae7f10eaa69215732c6d389692b194f876198dca79b2b90cbf96688d9d5d05ef7987b20f749b769b11c01766564264ea5f919c88b32a29011311 + languageName: node + linkType: hard + "mime-db@npm:~1.33.0": version: 1.33.0 resolution: "mime-db@npm:1.33.0" @@ -39071,6 +39238,15 @@ __metadata: languageName: node linkType: hard +"mime-types@npm:^3.0.0, mime-types@npm:^3.0.1": + version: 3.0.1 + resolution: "mime-types@npm:3.0.1" + dependencies: + mime-db: "npm:^1.54.0" + checksum: 10/fa1d3a928363723a8046c346d87bf85d35014dae4285ad70a3ff92bd35957992b3094f8417973cfe677330916c6ef30885109624f1fb3b1e61a78af509dba120 + languageName: node + linkType: hard + "mime@npm:1.6.0, mime@npm:^1.3.4": version: 1.6.0 resolution: "mime@npm:1.6.0" @@ -42127,6 +42303,13 @@ __metadata: languageName: node linkType: hard +"pkce-challenge@npm:^5.0.0": + version: 5.0.0 + resolution: "pkce-challenge@npm:5.0.0" + checksum: 10/e60c06a0e0481cb82f80072053d5c479a7490758541c4226460450285dd5d72a995c44b3c553731ca7c2f64cc34b35f1d2e5f9de08d276b59899298f9efe1ddf + languageName: node + linkType: hard + "pkg-dir@npm:^3.0.0": version: 3.0.0 resolution: "pkg-dir@npm:3.0.0" @@ -43147,7 +43330,7 @@ __metadata: languageName: node linkType: hard -"proxy-addr@npm:~2.0.7": +"proxy-addr@npm:^2.0.7, proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" dependencies: @@ -43271,7 +43454,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.10.1, qs@npm:^6.10.3, qs@npm:^6.11.0, qs@npm:^6.12.2, qs@npm:^6.12.3, qs@npm:^6.7.0, qs@npm:^6.9.4": +"qs@npm:^6.10.1, qs@npm:^6.10.3, qs@npm:^6.11.0, qs@npm:^6.12.2, qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.7.0, qs@npm:^6.9.4": version: 6.14.0 resolution: "qs@npm:6.14.0" dependencies: @@ -43467,6 +43650,18 @@ __metadata: languageName: node linkType: hard +"raw-body@npm:^3.0.0": + version: 3.0.0 + resolution: "raw-body@npm:3.0.0" + dependencies: + bytes: "npm:3.1.2" + http-errors: "npm:2.0.0" + iconv-lite: "npm:0.6.3" + unpipe: "npm:1.0.0" + checksum: 10/2443429bbb2f9ae5c50d3d2a6c342533dfbde6b3173740b70fa0302b30914ff400c6d31a46b3ceacbe7d0925dc07d4413928278b494b04a65736fc17ca33e30c + languageName: node + linkType: hard + "raw-loader@npm:^4.0.2": version: 4.0.2 resolution: "raw-loader@npm:4.0.2" @@ -45490,6 +45685,19 @@ __metadata: languageName: unknown linkType: soft +"router@npm:^2.2.0": + version: 2.2.0 + resolution: "router@npm:2.2.0" + dependencies: + debug: "npm:^4.4.0" + depd: "npm:^2.0.0" + is-promise: "npm:^4.0.0" + parseurl: "npm:^1.3.3" + path-to-regexp: "npm:^8.0.0" + checksum: 10/8949bd1d3da5403cc024e2989fee58d7fda0f3ffe9f2dc5b8a192f295f400b3cde307b0b554f7d44851077640f36962ca469a766b3d57410d7d96245a7ba6c91 + languageName: node + linkType: hard + "rrweb-cssom@npm:^0.7.1": version: 0.7.1 resolution: "rrweb-cssom@npm:0.7.1" @@ -45873,6 +46081,25 @@ __metadata: languageName: node linkType: hard +"send@npm:^1.1.0, send@npm:^1.2.0": + version: 1.2.0 + resolution: "send@npm:1.2.0" + dependencies: + debug: "npm:^4.3.5" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + etag: "npm:^1.8.1" + fresh: "npm:^2.0.0" + http-errors: "npm:^2.0.0" + mime-types: "npm:^3.0.1" + ms: "npm:^2.1.3" + on-finished: "npm:^2.4.1" + range-parser: "npm:^1.2.1" + statuses: "npm:^2.0.1" + checksum: 10/9fa3b1a3b9a06b7b4ab00c25e8228326d9665a9745753a34d1ffab8ac63c7c206727331d1dc5be73647f1b658d259a1aa8e275b0e0eee51349370af02e9da506 + languageName: node + linkType: hard + "seq-queue@npm:^0.0.5": version: 0.0.5 resolution: "seq-queue@npm:0.0.5" @@ -45949,6 +46176,18 @@ __metadata: languageName: node linkType: hard +"serve-static@npm:^2.2.0": + version: 2.2.0 + resolution: "serve-static@npm:2.2.0" + dependencies: + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + parseurl: "npm:^1.3.3" + send: "npm:^1.2.0" + checksum: 10/9f1a900738c5bb02258275ce3bd1273379c4c3072b622e15d44e8f47d89a1ba2d639ec2d63b11c263ca936096b40758acb7a0d989cd6989018a65a12f9433ada + languageName: node + linkType: hard + "set-blocking@npm:^2.0.0": version: 2.0.0 resolution: "set-blocking@npm:2.0.0" @@ -47492,6 +47731,34 @@ __metadata: languageName: node linkType: hard +"superagent@npm:^8.1.2": + version: 8.1.2 + resolution: "superagent@npm:8.1.2" + dependencies: + component-emitter: "npm:^1.3.0" + cookiejar: "npm:^2.1.4" + debug: "npm:^4.3.4" + fast-safe-stringify: "npm:^2.1.1" + form-data: "npm:^4.0.0" + formidable: "npm:^2.1.2" + methods: "npm:^1.1.2" + mime: "npm:2.6.0" + qs: "npm:^6.11.0" + semver: "npm:^7.3.8" + checksum: 10/33d0072e051baf91c7d68131c70682a0650dd1bd0b8dfb6f88e5bdfcb02e18cc2b42a66e44b32fd405ac6bcf5fd57c6e267bf80e2a8ce57a18166a9d3a78f57d + languageName: node + linkType: hard + +"supertest@npm:^6.2.4": + version: 6.3.4 + resolution: "supertest@npm:6.3.4" + dependencies: + methods: "npm:^1.1.2" + superagent: "npm:^8.1.2" + checksum: 10/93015318f5a90398915a032747973d9eacf9aebec3f07b413eba9d8b3db83ff48fbf6f5a92f9526578cae50153b0f76a37de197141030d856db4371a711b86ee + languageName: node + linkType: hard + "supertest@npm:^7.0.0": version: 7.1.1 resolution: "supertest@npm:7.1.1" @@ -48692,6 +48959,17 @@ __metadata: languageName: node linkType: hard +"type-is@npm:^2.0.0, type-is@npm:^2.0.1": + version: 2.0.1 + resolution: "type-is@npm:2.0.1" + dependencies: + content-type: "npm:^1.0.5" + media-typer: "npm:^1.1.0" + mime-types: "npm:^3.0.0" + checksum: 10/bacdb23c872dacb7bd40fbd9095e6b2fca2895eedbb689160c05534d7d4810a7f4b3fd1ae87e96133c505958f6d602967a68db5ff577b85dd6be76eaa75d58af + languageName: node + linkType: hard + "type@npm:^1.0.1": version: 1.2.0 resolution: "type@npm:1.2.0" @@ -50827,12 +51105,12 @@ __metadata: languageName: node linkType: hard -"zod-to-json-schema@npm:^3.20.4, zod-to-json-schema@npm:^3.21.4": - version: 3.23.5 - resolution: "zod-to-json-schema@npm:3.23.5" +"zod-to-json-schema@npm:^3.20.4, zod-to-json-schema@npm:^3.21.4, zod-to-json-schema@npm:^3.24.1": + version: 3.24.5 + resolution: "zod-to-json-schema@npm:3.24.5" peerDependencies: - zod: ^3.23.3 - checksum: 10/53d07a419f0f194e0b96711dc11e7e6fa52a366b0ed5fceb405dc55f13252a1bf433712e4fb496c2a5fdc851018ee1acba7b39c2265c43d6fbb180e12c110c3b + zod: ^3.24.1 + checksum: 10/1af291b4c429945c9568c2e924bdb7c66ab8d139cbeb9a99b6e9fc9e1b02863f85d07759b9303714f07ceda3993dcaf0ebcb80d2c18bb2aaf5502b2c1016affd languageName: node linkType: hard @@ -50845,10 +51123,10 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.22.4": - version: 3.24.4 - resolution: "zod@npm:3.24.4" - checksum: 10/3d545792fa54bb27ee5dbc34a5709e81f603185fcc94c8204b5d95c20dc4c81d870ff9c51f3884a30ef05cdc601449f4c4df254ac4783f0827b1faed7c1cdb48 +"zod@npm:^3.22.4, zod@npm:^3.23.8": + version: 3.25.67 + resolution: "zod@npm:3.25.67" + checksum: 10/0e35432dcca7f053e63f5dd491a87c78abe0d981817547252c3b6d05f0f58788695d1a69724759c6501dff3fd62929be24c9f314a3625179bee889150f7a61fa languageName: node linkType: hard From 4ed0fb6d2947354a22a982e0eec51c2d6dcebd78 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Jun 2025 08:45:04 +0200 Subject: [PATCH 03/10] chore: add changesfet Signed-off-by: benjdlambert --- .changeset/lovely-lands-clap.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lovely-lands-clap.md diff --git a/.changeset/lovely-lands-clap.md b/.changeset/lovely-lands-clap.md new file mode 100644 index 0000000000..33a4af9403 --- /dev/null +++ b/.changeset/lovely-lands-clap.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-mcp-backend': patch +--- + +Initial implementation of an `mcp` backend From 974d1ddebcb416a1ecbb79784d3bdae6d59e99b1 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Jun 2025 09:18:08 +0200 Subject: [PATCH 04/10] chore: cleanup Signed-off-by: benjdlambert --- .changeset/lovely-lands-clap.md | 2 +- plugins/mcp-backend/package.json | 7 ++--- plugins/mcp-backend/src/plugin.test.ts | 1 - yarn.lock | 42 -------------------------- 4 files changed, 3 insertions(+), 49 deletions(-) diff --git a/.changeset/lovely-lands-clap.md b/.changeset/lovely-lands-clap.md index 33a4af9403..949161943b 100644 --- a/.changeset/lovely-lands-clap.md +++ b/.changeset/lovely-lands-clap.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-mcp-backend': patch +'@backstage/plugin-mcp-backend': minor --- Initial implementation of an `mcp` backend diff --git a/plugins/mcp-backend/package.json b/plugins/mcp-backend/package.json index f4b9f6d450..d4bafb5a4d 100644 --- a/plugins/mcp-backend/package.json +++ b/plugins/mcp-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-mcp-backend", - "version": "0.1.0", + "version": "0.0.0", "backstage": { "role": "backend-plugin", "pluginId": "mcp" @@ -10,7 +10,6 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, - "private": true, "license": "Apache-2.0", "main": "src/index.ts", "types": "src/index.ts", @@ -41,8 +40,6 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@types/express": "^4.17.6", - "@types/supertest": "^2.0.8", - "supertest": "^6.2.4" + "@types/express": "^4.17.6" } } diff --git a/plugins/mcp-backend/src/plugin.test.ts b/plugins/mcp-backend/src/plugin.test.ts index 58608bc409..14d4aa8bf2 100644 --- a/plugins/mcp-backend/src/plugin.test.ts +++ b/plugins/mcp-backend/src/plugin.test.ts @@ -21,7 +21,6 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types'; -import { rootConfigServiceFactory } from '@backstage/backend-defaults/rootConfig'; describe('Mcp Backend', () => { const mockPluginWithActions = createBackendPlugin({ diff --git a/yarn.lock b/yarn.lock index cf827ae141..6bb3e487a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7113,10 +7113,8 @@ __metadata: "@backstage/types": "workspace:^" "@modelcontextprotocol/sdk": "npm:^1.12.3" "@types/express": "npm:^4.17.6" - "@types/supertest": "npm:^2.0.8" express: "npm:^4.17.1" express-promise-router: "npm:^4.1.0" - supertest: "npm:^6.2.4" zod: "npm:^3.22.4" languageName: unknown linkType: soft @@ -32420,18 +32418,6 @@ __metadata: languageName: node linkType: hard -"formidable@npm:^2.1.2": - version: 2.1.5 - resolution: "formidable@npm:2.1.5" - dependencies: - "@paralleldrive/cuid2": "npm:^2.2.2" - dezalgo: "npm:^1.0.4" - once: "npm:^1.4.0" - qs: "npm:^6.11.0" - checksum: 10/ee96de12e91d63fe86479ffe5bf59004bb3f43e00ce7ccecd1b1ff10b5d1a89a19b1ede727e1fe57ef596c377b9f9300212a5f7bab14fd28f3c4ffe12dbb4cc7 - languageName: node - linkType: hard - "formidable@npm:^3.5.4": version: 3.5.4 resolution: "formidable@npm:3.5.4" @@ -47731,34 +47717,6 @@ __metadata: languageName: node linkType: hard -"superagent@npm:^8.1.2": - version: 8.1.2 - resolution: "superagent@npm:8.1.2" - dependencies: - component-emitter: "npm:^1.3.0" - cookiejar: "npm:^2.1.4" - debug: "npm:^4.3.4" - fast-safe-stringify: "npm:^2.1.1" - form-data: "npm:^4.0.0" - formidable: "npm:^2.1.2" - methods: "npm:^1.1.2" - mime: "npm:2.6.0" - qs: "npm:^6.11.0" - semver: "npm:^7.3.8" - checksum: 10/33d0072e051baf91c7d68131c70682a0650dd1bd0b8dfb6f88e5bdfcb02e18cc2b42a66e44b32fd405ac6bcf5fd57c6e267bf80e2a8ce57a18166a9d3a78f57d - languageName: node - linkType: hard - -"supertest@npm:^6.2.4": - version: 6.3.4 - resolution: "supertest@npm:6.3.4" - dependencies: - methods: "npm:^1.1.2" - superagent: "npm:^8.1.2" - checksum: 10/93015318f5a90398915a032747973d9eacf9aebec3f07b413eba9d8b3db83ff48fbf6f5a92f9526578cae50153b0f76a37de197141030d856db4371a711b86ee - languageName: node - linkType: hard - "supertest@npm:^7.0.0": version: 7.1.1 resolution: "supertest@npm:7.1.1" From 398f9e9245d718d67655d1f09e2b208e1874c65c Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Jun 2025 09:22:16 +0200 Subject: [PATCH 05/10] chore: update api reports and fix some code review comments Signed-off-by: benjdlambert --- plugins/mcp-backend/report.api.md | 13 +++++++++++++ plugins/mcp-backend/src/index.ts | 2 +- plugins/mcp-backend/src/routers/createSseRouter.ts | 7 ++++--- .../src/routers/createStreamableRouter.ts | 3 ++- 4 files changed, 20 insertions(+), 5 deletions(-) create mode 100644 plugins/mcp-backend/report.api.md diff --git a/plugins/mcp-backend/report.api.md b/plugins/mcp-backend/report.api.md new file mode 100644 index 0000000000..5df9a077c6 --- /dev/null +++ b/plugins/mcp-backend/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/plugin-mcp-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @public +const mcpPlugin: BackendFeature; +export default mcpPlugin; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/mcp-backend/src/index.ts b/plugins/mcp-backend/src/index.ts index 9216a19c0c..75fa097c85 100644 --- a/plugins/mcp-backend/src/index.ts +++ b/plugins/mcp-backend/src/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { mcpPlugin } from './plugin'; +export { mcpPlugin as default } from './plugin'; diff --git a/plugins/mcp-backend/src/routers/createSseRouter.ts b/plugins/mcp-backend/src/routers/createSseRouter.ts index 05339b3844..64aa363416 100644 --- a/plugins/mcp-backend/src/routers/createSseRouter.ts +++ b/plugins/mcp-backend/src/routers/createSseRouter.ts @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import Router from 'express-promise-router'; +import PromiseRouter from 'express-promise-router'; +import { Router } from 'express'; import { McpService } from '../services/McpService'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { HttpAuthService } from '@backstage/backend-plugin-api'; @@ -27,8 +28,8 @@ export const createSseRouter = ({ }: { mcpService: McpService; httpAuth: HttpAuthService; -}) => { - const router = Router(); +}): Router => { + const router = PromiseRouter(); const transportsToSessionId = new Map(); router.get('/', async (req, res) => { diff --git a/plugins/mcp-backend/src/routers/createStreamableRouter.ts b/plugins/mcp-backend/src/routers/createStreamableRouter.ts index f373106ce2..9d6fd1840b 100644 --- a/plugins/mcp-backend/src/routers/createStreamableRouter.ts +++ b/plugins/mcp-backend/src/routers/createStreamableRouter.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import PromiseRouter from 'express-promise-router'; import { Router } from 'express'; import { McpService } from '../services/McpService'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; @@ -28,7 +29,7 @@ export const createStreamableRouter = ({ logger: LoggerService; httpAuth: HttpAuthService; }): Router => { - const router = Router(); + const router = PromiseRouter(); router.post('/', async (req, res) => { try { From 2c4e277c447cee9849cbc3b1457f24d8a5318bf0 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Jun 2025 09:44:05 +0200 Subject: [PATCH 06/10] chore: fix publishing Signed-off-by: benjdlambert --- plugins/mcp-backend/package.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/mcp-backend/package.json b/plugins/mcp-backend/package.json index d4bafb5a4d..2fe1705db4 100644 --- a/plugins/mcp-backend/package.json +++ b/plugins/mcp-backend/package.json @@ -3,13 +3,21 @@ "version": "0.0.0", "backstage": { "role": "backend-plugin", - "pluginId": "mcp" + "pluginId": "mcp", + "pluginPackages": [ + "@backstage/plugin-mcp-backend" + ] }, "publishConfig": { "access": "public", "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/mcp-backend" + }, "license": "Apache-2.0", "main": "src/index.ts", "types": "src/index.ts", From 976951d012471ad09a578f33395247e0a6bd1ce5 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Jun 2025 10:17:24 +0200 Subject: [PATCH 07/10] chore: rename package Signed-off-by: benjdlambert --- .changeset/lovely-lands-clap.md | 2 +- packages/backend/package.json | 2 +- packages/backend/src/index.ts | 2 +- plugins/{mcp-backend => mcp-actions-backend}/.eslintrc.js | 0 plugins/{mcp-backend => mcp-actions-backend}/README.md | 6 +++--- .../{mcp-backend => mcp-actions-backend}/catalog-info.yaml | 4 ++-- plugins/{mcp-backend => mcp-actions-backend}/dev/index.ts | 0 plugins/{mcp-backend => mcp-actions-backend}/package.json | 6 +++--- plugins/{mcp-backend => mcp-actions-backend}/report.api.md | 2 +- plugins/{mcp-backend => mcp-actions-backend}/src/index.ts | 0 .../{mcp-backend => mcp-actions-backend}/src/plugin.test.ts | 4 ++-- plugins/{mcp-backend => mcp-actions-backend}/src/plugin.ts | 6 +++--- .../src/routers/createSseRouter.ts | 0 .../src/routers/createStreamableRouter.ts | 0 .../src/services/McpService.test.ts | 0 .../src/services/McpService.ts | 2 +- yarn.lock | 6 +++--- 17 files changed, 21 insertions(+), 21 deletions(-) rename plugins/{mcp-backend => mcp-actions-backend}/.eslintrc.js (100%) rename plugins/{mcp-backend => mcp-actions-backend}/README.md (69%) rename plugins/{mcp-backend => mcp-actions-backend}/catalog-info.yaml (60%) rename plugins/{mcp-backend => mcp-actions-backend}/dev/index.ts (100%) rename plugins/{mcp-backend => mcp-actions-backend}/package.json (90%) rename plugins/{mcp-backend => mcp-actions-backend}/report.api.md (82%) rename plugins/{mcp-backend => mcp-actions-backend}/src/index.ts (100%) rename plugins/{mcp-backend => mcp-actions-backend}/src/plugin.test.ts (97%) rename plugins/{mcp-backend => mcp-actions-backend}/src/plugin.ts (94%) rename plugins/{mcp-backend => mcp-actions-backend}/src/routers/createSseRouter.ts (100%) rename plugins/{mcp-backend => mcp-actions-backend}/src/routers/createStreamableRouter.ts (100%) rename plugins/{mcp-backend => mcp-actions-backend}/src/services/McpService.test.ts (100%) rename plugins/{mcp-backend => mcp-actions-backend}/src/services/McpService.ts (97%) diff --git a/.changeset/lovely-lands-clap.md b/.changeset/lovely-lands-clap.md index 949161943b..61715c1cc5 100644 --- a/.changeset/lovely-lands-clap.md +++ b/.changeset/lovely-lands-clap.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-mcp-backend': minor +'@backstage/plugin-mcp-actions-backend': minor --- Initial implementation of an `mcp` backend diff --git a/packages/backend/package.json b/packages/backend/package.json index 9471b9f012..af0382116b 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -47,7 +47,7 @@ "@backstage/plugin-events-backend": "workspace:^", "@backstage/plugin-events-backend-module-google-pubsub": "workspace:^", "@backstage/plugin-kubernetes-backend": "workspace:^", - "@backstage/plugin-mcp-backend": "workspace:^", + "@backstage/plugin-mcp-actions-backend": "workspace:^", "@backstage/plugin-notifications-backend": "workspace:^", "@backstage/plugin-permission-backend": "workspace:^", "@backstage/plugin-permission-backend-module-allow-all-policy": "workspace:^", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b068ad2711..f46e947b0d 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -62,5 +62,5 @@ backend.add(import('@backstage/plugin-notifications-backend')); backend.add(import('./instanceMetadata')); backend.add(import('@backstage/plugin-events-backend-module-google-pubsub')); -backend.add(import('@backstage/plugin-mcp-backend')); +backend.add(import('@backstage/plugin-mcp-actions-backend')); backend.start(); diff --git a/plugins/mcp-backend/.eslintrc.js b/plugins/mcp-actions-backend/.eslintrc.js similarity index 100% rename from plugins/mcp-backend/.eslintrc.js rename to plugins/mcp-actions-backend/.eslintrc.js diff --git a/plugins/mcp-backend/README.md b/plugins/mcp-actions-backend/README.md similarity index 69% rename from plugins/mcp-backend/README.md rename to plugins/mcp-actions-backend/README.md index 03d2c24449..5e1ab57904 100644 --- a/plugins/mcp-backend/README.md +++ b/plugins/mcp-actions-backend/README.md @@ -4,11 +4,11 @@ This plugin backend was templated using the Backstage CLI. You should replace th ## Installation -This plugin is installed via the `@backstage/plugin-mcp-backend` package. To install it to your backend package, run the following command: +This plugin is installed via the `@backstage/plugin-mcp-actions-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 +yarn --cwd packages/backend add @backstage/plugin-mcp-actions-backend ``` Then add the plugin to your backend in `packages/backend/src/index.ts`: @@ -16,7 +16,7 @@ Then add the plugin to your backend in `packages/backend/src/index.ts`: ```ts const backend = createBackend(); // ... -backend.add(import('@backstage/plugin-mcp-backend')); +backend.add(import('@backstage/plugin-mcp-actions-backend')); ``` ## Development diff --git a/plugins/mcp-backend/catalog-info.yaml b/plugins/mcp-actions-backend/catalog-info.yaml similarity index 60% rename from plugins/mcp-backend/catalog-info.yaml rename to plugins/mcp-actions-backend/catalog-info.yaml index 37251f25cd..1a0199210e 100644 --- a/plugins/mcp-backend/catalog-info.yaml +++ b/plugins/mcp-actions-backend/catalog-info.yaml @@ -1,8 +1,8 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: - name: backstage-plugin-mcp-backend - title: '@backstage/plugin-mcp-backend' + name: backstage-plugin-mcp-actions-backend + title: '@backstage/plugin-mcp-actions-backend' spec: lifecycle: experimental type: backstage-backend-plugin diff --git a/plugins/mcp-backend/dev/index.ts b/plugins/mcp-actions-backend/dev/index.ts similarity index 100% rename from plugins/mcp-backend/dev/index.ts rename to plugins/mcp-actions-backend/dev/index.ts diff --git a/plugins/mcp-backend/package.json b/plugins/mcp-actions-backend/package.json similarity index 90% rename from plugins/mcp-backend/package.json rename to plugins/mcp-actions-backend/package.json index 2fe1705db4..cd6dfccc82 100644 --- a/plugins/mcp-backend/package.json +++ b/plugins/mcp-actions-backend/package.json @@ -1,11 +1,11 @@ { - "name": "@backstage/plugin-mcp-backend", + "name": "@backstage/plugin-mcp-actions-backend", "version": "0.0.0", "backstage": { "role": "backend-plugin", "pluginId": "mcp", "pluginPackages": [ - "@backstage/plugin-mcp-backend" + "@backstage/plugin-mcp-actions-backend" ] }, "publishConfig": { @@ -16,7 +16,7 @@ "repository": { "type": "git", "url": "https://github.com/backstage/backstage", - "directory": "plugins/mcp-backend" + "directory": "plugins/mcp-actions-backend" }, "license": "Apache-2.0", "main": "src/index.ts", diff --git a/plugins/mcp-backend/report.api.md b/plugins/mcp-actions-backend/report.api.md similarity index 82% rename from plugins/mcp-backend/report.api.md rename to plugins/mcp-actions-backend/report.api.md index 5df9a077c6..2f1b9fcbf0 100644 --- a/plugins/mcp-backend/report.api.md +++ b/plugins/mcp-actions-backend/report.api.md @@ -1,4 +1,4 @@ -## API Report File for "@backstage/plugin-mcp-backend" +## API Report File for "@backstage/plugin-mcp-actions-backend" > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). diff --git a/plugins/mcp-backend/src/index.ts b/plugins/mcp-actions-backend/src/index.ts similarity index 100% rename from plugins/mcp-backend/src/index.ts rename to plugins/mcp-actions-backend/src/index.ts diff --git a/plugins/mcp-backend/src/plugin.test.ts b/plugins/mcp-actions-backend/src/plugin.test.ts similarity index 97% rename from plugins/mcp-backend/src/plugin.test.ts rename to plugins/mcp-actions-backend/src/plugin.test.ts index 14d4aa8bf2..9ff6aa5085 100644 --- a/plugins/mcp-backend/src/plugin.test.ts +++ b/plugins/mcp-actions-backend/src/plugin.test.ts @@ -82,7 +82,7 @@ describe('Mcp Backend', () => { it('should support streamable spec', async () => { const { client, serverAddress } = await getContext(); const transport = new StreamableHTTPClientTransport( - new URL(`${serverAddress}/api/mcp`), + new URL(`${serverAddress}/api/mcp-actions/v1`), ); await client.connect(transport); @@ -134,7 +134,7 @@ describe('Mcp Backend', () => { it('should support sse spec', async () => { const { client, serverAddress } = await getContext(); const transport = new SSEClientTransport( - new URL(`${serverAddress}/api/mcp/sse`), + new URL(`${serverAddress}/api/mcp-actions/v1/sse`), ); await client.connect(transport); diff --git a/plugins/mcp-backend/src/plugin.ts b/plugins/mcp-actions-backend/src/plugin.ts similarity index 94% rename from plugins/mcp-backend/src/plugin.ts rename to plugins/mcp-actions-backend/src/plugin.ts index 7fdfb42550..f1e89417e9 100644 --- a/plugins/mcp-backend/src/plugin.ts +++ b/plugins/mcp-actions-backend/src/plugin.ts @@ -32,7 +32,7 @@ import { * @public */ export const mcpPlugin = createBackendPlugin({ - pluginId: 'mcp', + pluginId: 'mcp-actions', register(env) { env.registerInit({ deps: { @@ -62,8 +62,8 @@ export const mcpPlugin = createBackendPlugin({ const router = Router(); router.use(json()); - router.use('/sse', sseRouter); - router.use('/', streamableRouter); + router.use('/v1/sse', sseRouter); + router.use('/v1', streamableRouter); httpRouter.use(router); }, diff --git a/plugins/mcp-backend/src/routers/createSseRouter.ts b/plugins/mcp-actions-backend/src/routers/createSseRouter.ts similarity index 100% rename from plugins/mcp-backend/src/routers/createSseRouter.ts rename to plugins/mcp-actions-backend/src/routers/createSseRouter.ts diff --git a/plugins/mcp-backend/src/routers/createStreamableRouter.ts b/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts similarity index 100% rename from plugins/mcp-backend/src/routers/createStreamableRouter.ts rename to plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts diff --git a/plugins/mcp-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts similarity index 100% rename from plugins/mcp-backend/src/services/McpService.test.ts rename to plugins/mcp-actions-backend/src/services/McpService.test.ts diff --git a/plugins/mcp-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts similarity index 97% rename from plugins/mcp-backend/src/services/McpService.ts rename to plugins/mcp-actions-backend/src/services/McpService.ts index 5b50f0707f..a99842fe3a 100644 --- a/plugins/mcp-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -21,7 +21,7 @@ import { } 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'; +import { version } from '@backstage/plugin-mcp-actions-backend/package.json'; export class McpService { constructor(private readonly actions: ActionsService) {} diff --git a/yarn.lock b/yarn.lock index 6bb3e487a5..1acc719959 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7099,9 +7099,9 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-mcp-backend@workspace:^, @backstage/plugin-mcp-backend@workspace:plugins/mcp-backend": +"@backstage/plugin-mcp-actions-backend@workspace:^, @backstage/plugin-mcp-actions-backend@workspace:plugins/mcp-actions-backend": version: 0.0.0-use.local - resolution: "@backstage/plugin-mcp-backend@workspace:plugins/mcp-backend" + resolution: "@backstage/plugin-mcp-actions-backend@workspace:plugins/mcp-actions-backend" dependencies: "@backstage/backend-defaults": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" @@ -31365,7 +31365,7 @@ __metadata: "@backstage/plugin-events-backend": "workspace:^" "@backstage/plugin-events-backend-module-google-pubsub": "workspace:^" "@backstage/plugin-kubernetes-backend": "workspace:^" - "@backstage/plugin-mcp-backend": "workspace:^" + "@backstage/plugin-mcp-actions-backend": "workspace:^" "@backstage/plugin-notifications-backend": "workspace:^" "@backstage/plugin-permission-backend": "workspace:^" "@backstage/plugin-permission-backend-module-allow-all-policy": "workspace:^" From 57c747fad211e1ca3093cb4a97b17feb35dc662d Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Jun 2025 10:36:22 +0200 Subject: [PATCH 08/10] chore: code review comments Signed-off-by: benjdlambert --- .../mcp-actions-backend/src/routers/createSseRouter.ts | 8 +++++++- .../mcp-actions-backend/src/services/McpService.test.ts | 2 +- plugins/mcp-actions-backend/src/services/McpService.ts | 3 ++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/plugins/mcp-actions-backend/src/routers/createSseRouter.ts b/plugins/mcp-actions-backend/src/routers/createSseRouter.ts index 64aa363416..aae9407256 100644 --- a/plugins/mcp-actions-backend/src/routers/createSseRouter.ts +++ b/plugins/mcp-actions-backend/src/routers/createSseRouter.ts @@ -53,11 +53,17 @@ export const createSseRouter = ({ router.post('/messages', async (req, res) => { const sessionId = req.query.sessionId as string; + + if (!sessionId) { + res.status(400).send('sessionId is required'); + return; + } + const transport = transportsToSessionId.get(sessionId); if (transport) { await transport.handlePostMessage(req, res, req.body); } else { - res.status(400).send('No transport found for sessionId'); + res.status(400).send(`No transport found for sessionId "${sessionId}"`); } }); return router; diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index 6478bb4f29..052d981a46 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -188,6 +188,6 @@ describe('McpService', () => { }, CallToolResultSchema, ), - ).rejects.toThrow('Action mock-action not found'); + ).rejects.toThrow('Action "mock-action" not found'); }); }); diff --git a/plugins/mcp-actions-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts index a99842fe3a..ce31aa62bb 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -22,6 +22,7 @@ import { import { JsonObject } from '@backstage/types'; import { ActionsService } from '@backstage/backend-plugin-api/alpha'; import { version } from '@backstage/plugin-mcp-actions-backend/package.json'; +import { NotFoundError } from '@backstage/errors'; export class McpService { constructor(private readonly actions: ActionsService) {} @@ -65,7 +66,7 @@ export class McpService { const action = actions.find(a => a.name === params.name); if (!action) { - throw new Error(`Action ${params.name} not found`); + throw new NotFoundError(`Action "${params.name}" not found`); } const { output } = await this.actions.invoke({ From 22c8b3d5df74567b029493e974ec25da3bd7b331 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Jun 2025 11:53:30 +0200 Subject: [PATCH 09/10] chore: don't use send Signed-off-by: benjdlambert --- .../mcp-actions-backend/src/routers/createSseRouter.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/mcp-actions-backend/src/routers/createSseRouter.ts b/plugins/mcp-actions-backend/src/routers/createSseRouter.ts index aae9407256..0356da18bf 100644 --- a/plugins/mcp-actions-backend/src/routers/createSseRouter.ts +++ b/plugins/mcp-actions-backend/src/routers/createSseRouter.ts @@ -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 { InputError, NotFoundError } from '@backstage/errors'; /** * Legacy SSE endpoint for older clients, hopefully will not be needed for much longer. @@ -55,15 +56,16 @@ export const createSseRouter = ({ const sessionId = req.query.sessionId as string; if (!sessionId) { - res.status(400).send('sessionId is required'); - return; + throw new InputError('sessionId is required'); } const transport = transportsToSessionId.get(sessionId); if (transport) { await transport.handlePostMessage(req, res, req.body); } else { - res.status(400).send(`No transport found for sessionId "${sessionId}"`); + throw new NotFoundError( + `No transport found for sessionId "${sessionId}"`, + ); } }); return router; From 19774b2a55bb1a40602295a987e9c339046ae0ea Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Jun 2025 11:56:46 +0200 Subject: [PATCH 10/10] chore: code reivew comments Signed-off-by: benjdlambert --- .changeset/lovely-lands-clap.md | 2 +- plugins/mcp-actions-backend/package.json | 2 +- .../src/routers/createSseRouter.ts | 11 ++++++----- .../mcp-actions-backend/src/services/McpService.ts | 1 + 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.changeset/lovely-lands-clap.md b/.changeset/lovely-lands-clap.md index 61715c1cc5..18f60c444f 100644 --- a/.changeset/lovely-lands-clap.md +++ b/.changeset/lovely-lands-clap.md @@ -2,4 +2,4 @@ '@backstage/plugin-mcp-actions-backend': minor --- -Initial implementation of an `mcp` backend +Initial implementation of an `mcp-actions` backend diff --git a/plugins/mcp-actions-backend/package.json b/plugins/mcp-actions-backend/package.json index cd6dfccc82..65f1c3fd68 100644 --- a/plugins/mcp-actions-backend/package.json +++ b/plugins/mcp-actions-backend/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "backstage": { "role": "backend-plugin", - "pluginId": "mcp", + "pluginId": "mcp-actions", "pluginPackages": [ "@backstage/plugin-mcp-actions-backend" ] diff --git a/plugins/mcp-actions-backend/src/routers/createSseRouter.ts b/plugins/mcp-actions-backend/src/routers/createSseRouter.ts index 0356da18bf..19bcf288e9 100644 --- a/plugins/mcp-actions-backend/src/routers/createSseRouter.ts +++ b/plugins/mcp-actions-backend/src/routers/createSseRouter.ts @@ -18,7 +18,6 @@ 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 { InputError, NotFoundError } from '@backstage/errors'; /** * Legacy SSE endpoint for older clients, hopefully will not be needed for much longer. @@ -56,16 +55,18 @@ export const createSseRouter = ({ const sessionId = req.query.sessionId as string; if (!sessionId) { - throw new InputError('sessionId is required'); + res.status(400).contentType('text/plain').write('sessionId is required'); + return; } const transport = transportsToSessionId.get(sessionId); if (transport) { await transport.handlePostMessage(req, res, req.body); } else { - throw new NotFoundError( - `No transport found for sessionId "${sessionId}"`, - ); + res + .status(400) + .contentType('text/plain') + .write(`No transport found for sessionId "${sessionId}"`); } }); return router; diff --git a/plugins/mcp-actions-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts index ce31aa62bb..08d0668566 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -42,6 +42,7 @@ export class McpService { ); server.setRequestHandler(ListToolsRequestSchema, async () => { + // TODO: switch this to be configuration based later const { actions } = await this.actions.list({ credentials }); return {