Improve MCP safeStringify, OIDC error messages, and catalog model reference config

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2026-05-19 18:45:06 +02:00
parent ca8951ae87
commit 3f5e7ec2b4
10 changed files with 98 additions and 63 deletions
@@ -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.
+5
View File
@@ -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.
@@ -140,7 +140,7 @@ Allows multiple panels to be open simultaneously.
<Snippet
align="center"
py={4}
height={280}
height="auto"
preview={<GroupMultipleOpen />}
code={groupMultipleOpenSnippet}
/>
@@ -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 () => {
@@ -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 {
@@ -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.<type>" 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({
@@ -28,6 +28,7 @@ export const createCatalogActions = (options: {
actionsRegistry: ActionsRegistryService;
catalog: CatalogService;
modelHolder: ModelHolder | undefined;
useExperimentalCatalogLayersDescriptions?: boolean;
}) => {
createGetCatalogModelDescriptionAction(options);
createGetCatalogEntityAction(options);
@@ -288,6 +288,10 @@ export const catalogPlugin = createBackendPlugin({
catalog,
actionsRegistry,
modelHolder,
useExperimentalCatalogLayersDescriptions:
config.getOptionalBoolean(
'catalog.actions.experimentalCatalogLayersDescriptions.enabled',
) ?? false,
});
const scmEventsMessagesCounter = metrics.createCounter<{
@@ -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);
@@ -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,