Improve MCP safeStringify, OIDC error messages, and catalog model reference config
Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user