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,