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,