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/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/.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/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/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..845d46042d 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).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 9d907f8258..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); } @@ -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: safeStringify(output), }, ], + structuredContent: output, }; });