diff --git a/.changeset/icy-camels-throw.md b/.changeset/icy-camels-throw.md new file mode 100644 index 0000000000..db0521711c --- /dev/null +++ b/.changeset/icy-camels-throw.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-mcp-actions-backend': patch +--- + +The MCP backend will now convert known Backstage errors into textual responses with `isError: true`. +The error message can be useful for an LLM to understand and maybe give back to the user. +Previously all errors where thrown out to `@modelcontextprotocol/sdk` which causes a generic 500. diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index e3339be1d5..cd1c98f5b1 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -178,14 +178,21 @@ describe('McpService', () => { server.connect(serverTransport), ]); - await expect( - client.request( + const result = await client.request( + { + method: 'tools/call', + params: { name: 'mock-action', arguments: { input: 'test' } }, + }, + CallToolResultSchema, + ); + await expect(result).toEqual({ + content: [ { - method: 'tools/call', - params: { name: 'mock-action', arguments: { input: 'test' } }, + text: expect.stringMatching('Action "mock-action" not found'), + type: 'text', }, - CallToolResultSchema, - ), - ).rejects.toThrow('Action "mock-action" not found'); + ], + isError: true, + }); }); }); diff --git a/plugins/mcp-actions-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts index 9c44e6d4c1..f308395aac 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -24,6 +24,8 @@ import { ActionsService } from '@backstage/backend-plugin-api/alpha'; import { version } from '@backstage/plugin-mcp-actions-backend/package.json'; import { NotFoundError } from '@backstage/errors'; +import { handleErrors } from './handleErrors'; + export class McpService { constructor(private readonly actions: ActionsService) {} @@ -65,32 +67,34 @@ export class McpService { }); server.setRequestHandler(CallToolRequestSchema, async ({ params }) => { - const { actions } = await this.actions.list({ credentials }); - const action = actions.find(a => a.name === params.name); + return handleErrors(async () => { + const { actions } = await this.actions.list({ credentials }); + const action = actions.find(a => a.name === params.name); - if (!action) { - throw new NotFoundError(`Action "${params.name}" not found`); - } + if (!action) { + throw new NotFoundError(`Action "${params.name}" not found`); + } - const { output } = await this.actions.invoke({ - id: action.id, - input: params.arguments as JsonObject, - credentials, + const { output } = await this.actions.invoke({ + id: action.id, + input: params.arguments as JsonObject, + credentials, + }); + + return { + // todo(blam): unfortunately structuredContent is not supported by most clients yet. + // so the validation for the output happens in the default actions registry + // and we return it as json text instead for now. + content: [ + { + type: 'text', + text: ['```json', JSON.stringify(output, null, 2), '```'].join( + '\n', + ), + }, + ], + }; }); - - return { - // todo(blam): unfortunately structuredContent is not supported by most clients yet. - // so the validation for the output happens in the default actions registry - // and we return it as json text instead for now. - content: [ - { - type: 'text', - text: ['```json', JSON.stringify(output, null, 2), '```'].join( - '\n', - ), - }, - ], - }; }); return server; diff --git a/plugins/mcp-actions-backend/src/services/handleErrors.ts b/plugins/mcp-actions-backend/src/services/handleErrors.ts new file mode 100644 index 0000000000..67d92664eb --- /dev/null +++ b/plugins/mcp-actions-backend/src/services/handleErrors.ts @@ -0,0 +1,90 @@ +/* + * 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 { + ErrorLike, + ForwardedError, + isError, + serializeError, +} from '@backstage/errors'; + +import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js'; + +const knownErrors = new Set([ + 'InputError', + 'AuthenticationError', + 'NotAllowedError', + 'NotFoundError', + 'ConflictError', + 'NotModifiedError', + 'NotImplementedError', + 'ResponseError', +]); + +// Extracts the cause error, if the provided error is `ResponseError` or +// `ForwardedError` with a cause. +function extractCause(err: ErrorLike): ErrorLike { + if ( + (err.name === 'ResponseError' || err instanceof ForwardedError) && + isError(err.cause) + ) { + return err.cause; + } + return err; +} + +/** + * Takes a value expected to be an object, and returns a description of the + * error to return to the MCP client, if the error is a known Backstage error. + * + * Re-throws the original error otherwise + */ +function describeError(err: unknown): string { + if (err instanceof Error) { + const serialized = serializeError(err); + + const { name, message } = extractCause(serialized); + + if (knownErrors.has(name)) { + return `${name}: ${message}`; + } + } + + throw err; +} + +type RequestResultType = ReturnType< + Parameters[1] +>; +/** + * Wraps a request function with an error handler that turns known Backstage + * errors into user-friendly messages, instead of failing the request + * generically with a 500. + */ +export async function handleErrors( + fn: () => RequestResultType | Promise, +): Promise { + try { + return await fn(); + } catch (err) { + // This will rethrow if the error is not a known Backstage error + const description = describeError(err); + return { + content: [{ type: 'text', text: description }], + isError: true, + }; + } +}