From ca8951ae87be4945dfbdffdb4b4d732783516dc3 Mon Sep 17 00:00:00 2001 From: Love Kumar Chauhan Date: Sat, 25 Apr 2026 23:52:49 +0530 Subject: [PATCH 1/2] fix(mcp-actions-backend): return structured content and remove markdown formatting in tool responses This change aligns the MCP tool execution responses with the specification by returning plain text in the 'content' array and providing the raw JSON in a new 'structuredContent' field. It also removes the unnecessary Markdown code block formatting that was previously added to tool results. Fixes #34052 Signed-off-by: Love Kumar Chauhan Co-authored-by: benjdlambert <3645856+benjdlambert@users.noreply.github.com> Signed-off-by: benjdlambert --- .changeset/mcp-actions-backend-md-result-fix.md | 5 +++++ docs-ui/src/app/components/accordion/page.mdx | 2 +- plugins/mcp-actions-backend/README.md | 4 ++++ .../src/services/McpService.test.ts | 7 ++----- .../src/services/McpService.ts | 17 ++--------------- 5 files changed, 14 insertions(+), 21 deletions(-) create mode 100644 .changeset/mcp-actions-backend-md-result-fix.md diff --git a/.changeset/mcp-actions-backend-md-result-fix.md b/.changeset/mcp-actions-backend-md-result-fix.md new file mode 100644 index 0000000000..63b651bf34 --- /dev/null +++ b/.changeset/mcp-actions-backend-md-result-fix.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-mcp-actions-backend': patch +--- + +Fixed an issue where actions returned Markdown-formatted JSON instead of plain JSON and a `structuredContent` field for model context protocol responses. diff --git a/docs-ui/src/app/components/accordion/page.mdx b/docs-ui/src/app/components/accordion/page.mdx index 90c573b087..dc9b87b55d 100644 --- a/docs-ui/src/app/components/accordion/page.mdx +++ b/docs-ui/src/app/components/accordion/page.mdx @@ -140,7 +140,7 @@ Allows multiple panels to be open simultaneously. } code={groupMultipleOpenSnippet} /> diff --git a/plugins/mcp-actions-backend/README.md b/plugins/mcp-actions-backend/README.md index d496cf0f03..b3f6895078 100644 --- a/plugins/mcp-actions-backend/README.md +++ b/plugins/mcp-actions-backend/README.md @@ -135,6 +135,10 @@ When errors are thrown from MCP actions, the backend will handle and surface err See [Backstage Errors](https://backstage.io/docs/reference/errors/) for a full list of supported errors. +### Response Format + +Tool execution results are returned in a format compliant with the MCP specification, including both a plain-text representation in the `content` array and the raw JSON result in the `structuredContent` field. This ensures that AI clients can process the data either as text or as structured data for more precise tool use. + When writing MCP tools, use the appropriate error from `@backstage/errors` when applicable: ```ts diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index d96e9c7285..8aa1bcb22f 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -216,13 +216,10 @@ describe('McpService', () => { expect(result.content).toEqual([ { type: 'text', - text: [ - '```json', - JSON.stringify({ output: 'test' }, null, 2), - '```', - ].join('\n'), + text: JSON.stringify({ output: 'test' }), }, ]); + expect((result as any).structuredContent).toEqual({ output: 'test' }); const histogram = mockMetrics.createHistogram.mock.results[0]?.value; expect(histogram.record).toHaveBeenCalledTimes(1); diff --git a/plugins/mcp-actions-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts index 9d907f8258..735bce6f55 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -135,7 +135,6 @@ export class McpService { const server = new McpServer( { name: serverConfig?.name ?? 'backstage', - // TODO: this version will most likely change in the future. version, ...(serverConfig?.description && { description: serverConfig.description, @@ -159,9 +158,6 @@ export class McpService { return { tools: actions.map(action => ({ inputSchema: action.schema.input, - // todo(blam): this is unfortunately not supported by most clients yet. - // When this is provided you need to provide structuredContent instead. - // outputSchema: action.schema.output, name: this.getToolName(action), description: action.description, annotations: { @@ -238,9 +234,6 @@ export class McpService { credentials, }); - // Record the structured action output directly rather than the - // CallToolResult envelope below, which wraps an already- - // stringified markdown-fenced JSON block. if (this.captureToolPayloads) { span.setAttribute( 'gen_ai.tool.call.result', @@ -249,19 +242,13 @@ export class McpService { } 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'), + text: JSON.stringify(output), }, ], + structuredContent: output, }; }); From 3f5e7ec2b4781fd4980e4274aff8a0bd1dfe1c59 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 19 May 2026 18:45:06 +0200 Subject: [PATCH 2/2] Improve MCP safeStringify, OIDC error messages, and catalog model reference config Signed-off-by: benjdlambert --- ...atalog-experimental-layers-descriptions.md | 5 + .changeset/oidc-error-messages.md | 5 + docs-ui/src/app/components/accordion/page.mdx | 2 +- .../src/service/OidcService.test.ts | 8 +- .../auth-backend/src/service/OidcService.ts | 16 ++- .../createQueryCatalogEntitiesAction.ts | 114 ++++++++++-------- plugins/catalog-backend/src/actions/index.ts | 1 + .../src/service/CatalogPlugin.ts | 4 + .../src/services/McpService.test.ts | 2 +- .../src/services/McpService.ts | 4 +- 10 files changed, 98 insertions(+), 63 deletions(-) create mode 100644 .changeset/catalog-experimental-layers-descriptions.md create mode 100644 .changeset/oidc-error-messages.md diff --git a/.changeset/catalog-experimental-layers-descriptions.md b/.changeset/catalog-experimental-layers-descriptions.md new file mode 100644 index 0000000000..a2f6ee8100 --- /dev/null +++ b/.changeset/catalog-experimental-layers-descriptions.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Added `catalog.actions.experimentalCatalogLayersDescriptions.enabled` config option. When enabled, the `query-catalog-entities` action description references `get-catalog-model-description` for field information instead of embedding a static model description. diff --git a/.changeset/oidc-error-messages.md b/.changeset/oidc-error-messages.md new file mode 100644 index 0000000000..8e622aa1b7 --- /dev/null +++ b/.changeset/oidc-error-messages.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Improved OIDC error messages to include the rejected redirect URI or client ID, making it easier to debug client registration failures. diff --git a/docs-ui/src/app/components/accordion/page.mdx b/docs-ui/src/app/components/accordion/page.mdx index dc9b87b55d..90c573b087 100644 --- a/docs-ui/src/app/components/accordion/page.mdx +++ b/docs-ui/src/app/components/accordion/page.mdx @@ -140,7 +140,7 @@ Allows multiple panels to be open simultaneously. } code={groupMultipleOpenSnippet} /> diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts index 999fc166c0..2138395297 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -1197,7 +1197,7 @@ describe('OidcService', () => { redirectUri: 'http://unauthorized.com/callback', responseType: 'code', }), - ).rejects.toThrow('Redirect URI not registered'); + ).rejects.toThrow('not registered in client metadata'); }); it('should throw error when redirect_uri does not match allowedRedirectUriPatterns', async () => { @@ -1284,7 +1284,7 @@ describe('OidcService', () => { responseType: 'code', ...pkceParams, }), - ).rejects.toThrow('Redirect URI not registered'); + ).rejects.toThrow('not registered in client metadata'); }); it('should accept IPv6 loopback redirect_uri with a different port per RFC 8252', async () => { @@ -1389,7 +1389,7 @@ describe('OidcService', () => { responseType: 'code', ...pkceParams, }), - ).rejects.toThrow('Redirect URI not registered'); + ).rejects.toThrow('not registered in client metadata'); }); it('should reject redirect_uri not exactly matching CIMD metadata', async () => { @@ -1413,7 +1413,7 @@ describe('OidcService', () => { responseType: 'code', ...pkceParams, }), - ).rejects.toThrow('Redirect URI not registered'); + ).rejects.toThrow('not registered in client metadata'); }); it('should require PKCE for CIMD clients', async () => { diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index ee78f7d564..4babac121c 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -41,7 +41,7 @@ function validateRedirectUri( const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`; if (!allowedPatterns.some(pattern => matcher.isMatch(normalized, pattern))) { - throw new InputError('Invalid redirect_uri'); + throw new InputError(`Invalid redirect_uri '${normalized}'`); } } @@ -221,7 +221,11 @@ export class OidcService { const allowedRedirectUriPatterns = this.config.getOptionalStringArray( 'auth.experimentalDynamicClientRegistration.allowedRedirectUriPatterns', - ) ?? ['cursor://*', ...LOOPBACK_REDIRECT_PATTERNS]; + ) ?? [ + 'cursor://*', + 'https://www.cursor.com/*', + ...LOOPBACK_REDIRECT_PATTERNS, + ]; for (const redirectUri of opts.redirectUris ?? []) { validateRedirectUri(redirectUri, allowedRedirectUriPatterns); @@ -357,7 +361,7 @@ export class OidcService { matcher.isMatch(opts.clientId, pattern), ) ) { - throw new InputError('Invalid client_id'); + throw new InputError(`Invalid client_id '${opts.clientId}'`); } const cimdClient = await fetchCimdMetadata({ @@ -369,7 +373,9 @@ export class OidcService { validateRedirectUri(opts.redirectUri, cimd.allowedRedirectUriPatterns); if (!matchesRedirectUri(opts.redirectUri, cimdClient.redirectUris)) { - throw new InputError('Redirect URI not registered'); + throw new InputError( + `Invalid redirect_uri '${opts.redirectUri}', not registered in client metadata`, + ); } } @@ -390,7 +396,7 @@ export class OidcService { } if (opts.redirectUri && !client.redirectUris.includes(opts.redirectUri)) { - throw new InputError('Invalid redirect_uri'); + throw new InputError(`Invalid redirect_uri '${opts.redirectUri}'`); } return { diff --git a/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts b/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts index 6bf4171163..addd02320e 100644 --- a/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts +++ b/plugins/catalog-backend/src/actions/createQueryCatalogEntitiesAction.ts @@ -17,22 +17,45 @@ import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; import { CatalogService } from '@backstage/plugin-catalog-node'; import { createZodV3FilterPredicateSchema } from '@backstage/filter-predicates'; -export const createQueryCatalogEntitiesAction = ({ - catalog, - actionsRegistry, -}: { - catalog: CatalogService; - actionsRegistry: ActionsRegistryService; -}) => { - actionsRegistry.register({ - name: 'query-catalog-entities', - title: 'Query Catalog Entities', - attributes: { - destructive: false, - readOnly: true, - idempotent: true, - }, - description: ` +const QUERY_SYNTAX = ` +## Query Syntax + +The query uses predicate expressions with dot-notation field paths. + +Simple matching: + { query: { kind: "Component" } } + { query: { kind: "Component", "spec.type": "service" } } + +Value operators: + { query: { kind: { "$in": ["API", "Component"] } } } + { query: { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } } } + { query: { "metadata.tags": { "$contains": "java" } } } + { query: { "metadata.name": { "$hasPrefix": "team-" } } } + +Logical operators: + { query: { "$all": [{ kind: "Component" }, { "spec.lifecycle": "production" }] } } + { query: { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] } } + { query: { "$not": { kind: "Group" } } } + +Querying relations - find all entities owned by a specific group: + { query: { "relations.ownedby": "group:default/team-alpha" } } + +Combined example - find production services or websites with TechDocs: + { query: { "$all": [ + { kind: "Component", "spec.lifecycle": "production" }, + { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] }, + { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } } + ] } } + +## Other Options + +Limit returned fields: { fields: ["kind", "metadata.name", "metadata.namespace"] } +Sort results: { orderFields: { field: "metadata.name", order: "asc" } } +Full text search: { fullTextFilter: { term: "auth", fields: ["metadata.name", "metadata.title"] } } +Pagination: Use limit (e.g. 20) and the returned nextPageCursor for subsequent requests via cursor. +`; + +const INLINE_MODEL_DESCRIPTION = ` Query entities from the Backstage Software Catalog using predicate filters. ## Catalog Model @@ -76,43 +99,34 @@ Entities have bidirectional relations stored in the "relations" array. Common re Relations can be queried via "relations." e.g. "relations.ownedby: user:default/jane-doe". The value there must always be a valid entity reference. When querying for entity relationships, prefer using relations over spec fields. For example, use "relations.ownedby" instead of "spec.owner" to find entities owned by a particular group or user. +${QUERY_SYNTAX}`; -## Query Syntax +const MODEL_REFERENCE_DESCRIPTION = ` +Query entities from the Backstage Software Catalog using predicate filters. -The query uses predicate expressions with dot-notation field paths. +For a complete list of entity kinds, fields, relations, and other queryable attributes available in the catalog, use \`get-catalog-model-description\`. +${QUERY_SYNTAX}`; -Simple matching: - { query: { kind: "Component" } } - { query: { kind: "Component", "spec.type": "service" } } - -Value operators: - { query: { kind: { "$in": ["API", "Component"] } } } - { query: { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } } } - { query: { "metadata.tags": { "$contains": "java" } } } - { query: { "metadata.name": { "$hasPrefix": "team-" } } } - -Logical operators: - { query: { "$all": [{ kind: "Component" }, { "spec.lifecycle": "production" }] } } - { query: { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] } } - { query: { "$not": { kind: "Group" } } } - -Querying relations - find all entities owned by a specific group: - { query: { "relations.ownedby": "group:default/team-alpha" } } - -Combined example - find production services or websites with TechDocs: - { query: { "$all": [ - { kind: "Component", "spec.lifecycle": "production" }, - { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] }, - { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } } - ] } } - -## Other Options - -Limit returned fields: { fields: ["kind", "metadata.name", "metadata.namespace"] } -Sort results: { orderFields: { field: "metadata.name", order: "asc" } } -Full text search: { fullTextFilter: { term: "auth", fields: ["metadata.name", "metadata.title"] } } -Pagination: Use limit (e.g. 20) and the returned nextPageCursor for subsequent requests via cursor. - `, +export const createQueryCatalogEntitiesAction = ({ + catalog, + actionsRegistry, + useExperimentalCatalogLayersDescriptions, +}: { + catalog: CatalogService; + actionsRegistry: ActionsRegistryService; + useExperimentalCatalogLayersDescriptions?: boolean; +}) => { + actionsRegistry.register({ + name: 'query-catalog-entities', + title: 'Query Catalog Entities', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, + description: useExperimentalCatalogLayersDescriptions + ? MODEL_REFERENCE_DESCRIPTION + : INLINE_MODEL_DESCRIPTION, schema: { input: z => z.object({ diff --git a/plugins/catalog-backend/src/actions/index.ts b/plugins/catalog-backend/src/actions/index.ts index 2d6fb58f87..5b5bb056ce 100644 --- a/plugins/catalog-backend/src/actions/index.ts +++ b/plugins/catalog-backend/src/actions/index.ts @@ -28,6 +28,7 @@ export const createCatalogActions = (options: { actionsRegistry: ActionsRegistryService; catalog: CatalogService; modelHolder: ModelHolder | undefined; + useExperimentalCatalogLayersDescriptions?: boolean; }) => { createGetCatalogModelDescriptionAction(options); createGetCatalogEntityAction(options); diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index 84e7fd8cdb..a2e5da9170 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -288,6 +288,10 @@ export const catalogPlugin = createBackendPlugin({ catalog, actionsRegistry, modelHolder, + useExperimentalCatalogLayersDescriptions: + config.getOptionalBoolean( + 'catalog.actions.experimentalCatalogLayersDescriptions.enabled', + ) ?? false, }); const scmEventsMessagesCounter = metrics.createCounter<{ diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index 8aa1bcb22f..845d46042d 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -219,7 +219,7 @@ describe('McpService', () => { text: JSON.stringify({ output: 'test' }), }, ]); - expect((result as any).structuredContent).toEqual({ output: 'test' }); + expect(result).toHaveProperty('structuredContent', { output: 'test' }); const histogram = mockMetrics.createHistogram.mock.results[0]?.value; expect(histogram.record).toHaveBeenCalledTimes(1); diff --git a/plugins/mcp-actions-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts index 735bce6f55..1f2f38ff76 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -37,7 +37,7 @@ import { FilterRule, McpServerConfig } from '../config'; function safeStringify(value: unknown): string { try { - return JSON.stringify(value); + return JSON.stringify(value) ?? String(value); } catch { return String(value); } @@ -245,7 +245,7 @@ export class McpService { content: [ { type: 'text', - text: JSON.stringify(output), + text: safeStringify(output), }, ], structuredContent: output,