Merge pull request #34016 from backstage/benjdlambert/api-v1beta2-mcp-server
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/catalog-model': minor
|
||||
---
|
||||
|
||||
Added `spec.type: 'mcp-server'` as a structured subtype of the `API` kind under `v1alpha1`/`v1beta1`. MCP server entities carry a `spec.remotes` list instead of a string `definition`, for representing Model Context Protocol servers in the catalog. See RFC [#32062](https://github.com/backstage/backstage/issues/32062). New public exports: `McpServerApiEntity`, `McpServerRemote`, `mcpServerApiEntityValidator`, and `isMcpServerApiEntity`. Also adds `addKindVersion` to `CatalogModelLayerBuilder` (alpha) so layers can add new versions or spec types to existing kinds.
|
||||
@@ -5,6 +5,7 @@ metadata:
|
||||
description: A collection of all Backstage example APIs
|
||||
spec:
|
||||
targets:
|
||||
- ./apis/backstage-mcp-server-api.yaml
|
||||
- ./apis/hello-world-api.yaml
|
||||
- ./apis/hello-world-trpc-api.yaml
|
||||
- ./apis/petstore-api.yaml
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: API
|
||||
metadata:
|
||||
name: backstage-mcp-server
|
||||
description: An MCP server that exposes tools related to the Backstage ecosystem
|
||||
tags:
|
||||
- mcp
|
||||
- ai
|
||||
spec:
|
||||
type: mcp-server
|
||||
lifecycle: experimental
|
||||
owner: team-a
|
||||
remotes:
|
||||
- type: streamable-http
|
||||
url: http://localhost:7007/api/mcp/v1
|
||||
@@ -40,6 +40,24 @@ export interface AlphaEntity extends Entity_2 {
|
||||
status?: EntityStatus;
|
||||
}
|
||||
|
||||
// @public
|
||||
interface ApiEntityV1alpha1 extends Entity {
|
||||
// (undocumented)
|
||||
apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
|
||||
// (undocumented)
|
||||
kind: 'API';
|
||||
// (undocumented)
|
||||
spec: {
|
||||
type: string;
|
||||
lifecycle: string;
|
||||
owner: string;
|
||||
definition: string;
|
||||
system?: string;
|
||||
};
|
||||
}
|
||||
export { ApiEntityV1alpha1 as ApiEntity };
|
||||
export { ApiEntityV1alpha1 };
|
||||
|
||||
// @alpha
|
||||
export type AsyncCatalogModelSourceGenerator = AsyncGenerator<
|
||||
{
|
||||
@@ -70,6 +88,12 @@ export interface CatalogModel {
|
||||
listRelations(): CatalogModelRelationSummary[];
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface CatalogModelAddKindVersionDefinition {
|
||||
kind: string;
|
||||
versions: CatalogModelKindVersionDefinition[];
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface CatalogModelAnnotationDefinition {
|
||||
description: string;
|
||||
@@ -229,6 +253,7 @@ export interface CatalogModelLayer {
|
||||
export interface CatalogModelLayerBuilder {
|
||||
addAnnotation(annotation: CatalogModelAnnotationDefinition): void;
|
||||
addKind(kind: CatalogModelKindDefinition): void;
|
||||
addKindVersion(definition: CatalogModelAddKindVersionDefinition): void;
|
||||
addLabel(label: CatalogModelLabelDefinition): void;
|
||||
addRelationPair(relation: CatalogModelRelationPairDefinition): void;
|
||||
addTag(tag: CatalogModelTagDefinition): void;
|
||||
@@ -485,6 +510,11 @@ export const isAiResourceEntity: (
|
||||
entity: Entity,
|
||||
) => entity is AiResourceEntityV1alpha1;
|
||||
|
||||
// @alpha
|
||||
export function isMcpServerApiEntity(
|
||||
entity: ApiEntityV1alpha1 | McpServerApiEntity,
|
||||
): entity is McpServerApiEntity;
|
||||
|
||||
// @alpha
|
||||
export const isRuleAiResourceEntity: (
|
||||
entity: Entity,
|
||||
@@ -500,6 +530,30 @@ export type KindValidator = {
|
||||
check(entity: Entity): Promise<boolean>;
|
||||
};
|
||||
|
||||
// @alpha
|
||||
export interface McpServerApiEntity extends Omit<ApiEntityV1alpha1, 'spec'> {
|
||||
// (undocumented)
|
||||
spec: {
|
||||
type: 'mcp-server';
|
||||
lifecycle: string;
|
||||
owner: string;
|
||||
system?: string;
|
||||
remotes: McpServerRemote[];
|
||||
};
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export const mcpServerApiEntityModel: CatalogModelLayer;
|
||||
|
||||
// @alpha
|
||||
export const mcpServerApiEntityValidator: KindValidator;
|
||||
|
||||
// @alpha
|
||||
export type McpServerRemote = {
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
// @alpha
|
||||
export interface RuleAiResourceEntityV1alpha1
|
||||
extends AiResourceEntityV1alpha1Default {
|
||||
|
||||
@@ -42,5 +42,18 @@ export {
|
||||
isRuleAiResourceEntity,
|
||||
aiResourceEntityModel,
|
||||
} from './kinds/AiResourceEntityV1alpha1';
|
||||
export type {
|
||||
ApiEntityV1alpha1 as ApiEntity,
|
||||
ApiEntityV1alpha1,
|
||||
} from './kinds/ApiEntityV1alpha1';
|
||||
export type {
|
||||
McpServerApiEntity,
|
||||
McpServerRemote,
|
||||
} from './kinds/McpServerApiEntity';
|
||||
export {
|
||||
mcpServerApiEntityValidator,
|
||||
isMcpServerApiEntity,
|
||||
mcpServerApiEntityModel,
|
||||
} from './kinds/McpServerApiEntity';
|
||||
export * from './model';
|
||||
export { defaultCatalogEntityModel } from './model/defaultCatalogEntityModel';
|
||||
|
||||
@@ -83,9 +83,7 @@ export const apiEntityModel = createCatalogModelLayer({
|
||||
defaultNamespace: 'inherit',
|
||||
},
|
||||
],
|
||||
schema: {
|
||||
jsonSchema,
|
||||
},
|
||||
schema: { jsonSchema },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ApiEntityV1alpha1 } from './ApiEntityV1alpha1';
|
||||
import {
|
||||
McpServerApiEntity,
|
||||
mcpServerApiEntityValidator,
|
||||
isMcpServerApiEntity,
|
||||
} from './McpServerApiEntity';
|
||||
|
||||
describe('mcpServerApiEntityValidator', () => {
|
||||
let entity: McpServerApiEntity;
|
||||
|
||||
beforeEach(() => {
|
||||
entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: { name: 'test-mcp' },
|
||||
spec: {
|
||||
type: 'mcp-server',
|
||||
lifecycle: 'experimental',
|
||||
owner: 'backstage',
|
||||
remotes: [
|
||||
{
|
||||
type: 'streamable-http',
|
||||
url: 'http://localhost:7007/api/mcp',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('accepts a valid mcp-server entity', async () => {
|
||||
await expect(mcpServerApiEntityValidator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('accepts v1beta1', async () => {
|
||||
entity.apiVersion = 'backstage.io/v1beta1';
|
||||
await expect(mcpServerApiEntityValidator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects wrong spec.type value', async () => {
|
||||
(entity as any).spec.type = 'openapi';
|
||||
await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow(
|
||||
/type/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects missing remotes', async () => {
|
||||
delete (entity as any).spec.remotes;
|
||||
await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow(
|
||||
/remotes/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty remotes array', async () => {
|
||||
(entity as any).spec.remotes = [];
|
||||
await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow(
|
||||
/remotes/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects remote missing url', async () => {
|
||||
(entity as any).spec.remotes[0] = { type: 'stdio' };
|
||||
await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow(
|
||||
/url/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects remote missing type', async () => {
|
||||
(entity as any).spec.remotes[0] = { url: 'http://x' };
|
||||
await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow(
|
||||
/type/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMcpServerApiEntity', () => {
|
||||
it('returns true for an mcp-server entity', () => {
|
||||
const entity: McpServerApiEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: { name: 'm' },
|
||||
spec: {
|
||||
type: 'mcp-server',
|
||||
lifecycle: 'production',
|
||||
owner: 'me',
|
||||
remotes: [{ type: 'stdio', url: 'cmd' }],
|
||||
},
|
||||
};
|
||||
expect(isMcpServerApiEntity(entity)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a non-mcp-server API entity', () => {
|
||||
const entity: ApiEntityV1alpha1 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: { name: 'a' },
|
||||
spec: {
|
||||
type: 'openapi',
|
||||
lifecycle: 'production',
|
||||
owner: 'me',
|
||||
definition: 'x',
|
||||
},
|
||||
};
|
||||
expect(isMcpServerApiEntity(entity)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createCatalogModelLayer } from '../model/createCatalogModelLayer';
|
||||
import type { ApiEntityV1alpha1 } from './ApiEntityV1alpha1';
|
||||
import mcpServerSchema from '../schema/kinds/API.v1alpha1.mcp-server.schema.json';
|
||||
import { ajvCompiledJsonSchemaValidator } from './util';
|
||||
|
||||
/**
|
||||
* An MCP (Model Context Protocol) server represented as an API entity
|
||||
* (spec.type: 'mcp-server').
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface McpServerApiEntity extends Omit<ApiEntityV1alpha1, 'spec'> {
|
||||
spec: {
|
||||
type: 'mcp-server';
|
||||
lifecycle: string;
|
||||
owner: string;
|
||||
system?: string;
|
||||
remotes: McpServerRemote[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A transport endpoint for an MCP server.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export type McpServerRemote = {
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* {@link KindValidator} for the `mcp-server` specType of API entities.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const mcpServerApiEntityValidator =
|
||||
ajvCompiledJsonSchemaValidator(mcpServerSchema);
|
||||
|
||||
/**
|
||||
* Type guard: narrows an entity to the MCP server API subtype.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export function isMcpServerApiEntity(
|
||||
entity: ApiEntityV1alpha1 | McpServerApiEntity,
|
||||
): entity is McpServerApiEntity {
|
||||
return entity.spec.type === 'mcp-server';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the API kind with the mcp-server specType.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const mcpServerApiEntityModel = createCatalogModelLayer({
|
||||
layerId: 'catalog.backstage.io/kind-api-mcp-server',
|
||||
builder: model => {
|
||||
model.addKindVersion({
|
||||
kind: 'API',
|
||||
versions: [
|
||||
{
|
||||
name: ['v1alpha1', 'v1beta1'],
|
||||
specType: 'mcp-server',
|
||||
description:
|
||||
'An MCP (Model Context Protocol) server exposed as an API entity.',
|
||||
relationFields: [
|
||||
{
|
||||
selector: { path: 'spec.owner' },
|
||||
relation: 'ownedBy',
|
||||
defaultKind: 'Group',
|
||||
defaultNamespace: 'inherit',
|
||||
allowedKinds: ['Group', 'User'],
|
||||
},
|
||||
{
|
||||
selector: { path: 'spec.system' },
|
||||
relation: 'partOf',
|
||||
defaultKind: 'System',
|
||||
defaultNamespace: 'inherit',
|
||||
},
|
||||
],
|
||||
schema: { jsonSchema: mcpServerSchema },
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import lodash from 'lodash';
|
||||
import { compileCatalogModel } from '../model/compileCatalogModel';
|
||||
import { defaultCatalogEntityModel } from '../model/defaultCatalogEntityModel';
|
||||
import { mcpServerApiEntityModel } from './McpServerApiEntity';
|
||||
|
||||
describe('apiEntityModel mcp-server dispatch', () => {
|
||||
const model = compileCatalogModel([
|
||||
defaultCatalogEntityModel,
|
||||
mcpServerApiEntityModel,
|
||||
]);
|
||||
|
||||
it('routes mcp-server and non-mcp-server v1alpha1 entities to different schemas', () => {
|
||||
const mcp = model.getKind({
|
||||
kind: 'API',
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
spec: { type: 'mcp-server' },
|
||||
});
|
||||
const openapi = model.getKind({
|
||||
kind: 'API',
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
spec: { type: 'openapi' },
|
||||
});
|
||||
|
||||
expect(mcp).toBeDefined();
|
||||
expect(openapi).toBeDefined();
|
||||
expect(mcp!.description).not.toBe(openapi!.description);
|
||||
|
||||
const mcpSpecRequired = lodash.get(
|
||||
mcp,
|
||||
'jsonSchema.properties.spec.required',
|
||||
);
|
||||
const openapiSpecRequired = lodash.get(
|
||||
openapi,
|
||||
'jsonSchema.properties.spec.required',
|
||||
);
|
||||
|
||||
expect(mcpSpecRequired).toContain('remotes');
|
||||
expect(mcpSpecRequired).not.toContain('definition');
|
||||
expect(openapiSpecRequired).toContain('definition');
|
||||
expect(openapiSpecRequired).not.toContain('remotes');
|
||||
});
|
||||
|
||||
it('routes mcp-server under v1beta1 too', () => {
|
||||
const mcp = model.getKind({
|
||||
kind: 'API',
|
||||
apiVersion: 'backstage.io/v1beta1',
|
||||
spec: { type: 'mcp-server' },
|
||||
});
|
||||
expect(mcp).toBeDefined();
|
||||
const required = lodash.get(mcp, 'jsonSchema.properties.spec.required');
|
||||
expect(required).toContain('remotes');
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
type CatalogModelKindDefinition,
|
||||
opsFromCatalogModelKind,
|
||||
} from './modelActions/addKind';
|
||||
import {
|
||||
type CatalogModelAddKindVersionDefinition,
|
||||
opsFromCatalogModelAddKindVersion,
|
||||
} from './modelActions/addKindVersion';
|
||||
import {
|
||||
type CatalogModelLabelDefinition,
|
||||
opsFromCatalogModelLabel,
|
||||
@@ -87,6 +91,10 @@ export interface CatalogModelLayerBuilder {
|
||||
* Adds a new kind to the model.
|
||||
*/
|
||||
addKind(kind: CatalogModelKindDefinition): void;
|
||||
/**
|
||||
* Adds one or more versions to an already-declared kind.
|
||||
*/
|
||||
addKindVersion(definition: CatalogModelAddKindVersionDefinition): void;
|
||||
/**
|
||||
* Updates an existing kind in the model.
|
||||
*/
|
||||
@@ -169,6 +177,11 @@ export class DefaultCatalogModelLayerBuilder
|
||||
this.#ops.push(...ops);
|
||||
}
|
||||
|
||||
addKindVersion(definition: CatalogModelAddKindVersionDefinition): void {
|
||||
const ops = opsFromCatalogModelAddKindVersion(definition);
|
||||
this.#ops.push(...ops);
|
||||
}
|
||||
|
||||
updateKind(kind: CatalogModelUpdateKindDefinition): void {
|
||||
const ops = opsFromCatalogModelUpdateKind(kind);
|
||||
this.#ops.push(...ops);
|
||||
|
||||
@@ -15,12 +15,9 @@
|
||||
*/
|
||||
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { reduceKindSchema } from '../jsonSchema/reduceKindSchema';
|
||||
import { validateKindRootSchemaSemantics } from '../jsonSchema/validateKindRootSchemaSemantics';
|
||||
import { validateMetaSchema } from '../jsonSchema/validateMetaSchema';
|
||||
import { CatalogModelOp } from '../operations';
|
||||
import { createDeclareKindOp } from '../operations/declareKind';
|
||||
import { createDeclareKindVersionOp } from '../operations/declareKindVersion';
|
||||
import { opsFromCatalogModelAddKindVersion } from './addKindVersion';
|
||||
|
||||
/**
|
||||
* The definition of a catalog model kind, roughly resembling a JSON Schema.
|
||||
@@ -168,33 +165,12 @@ export function opsFromCatalogModelKind(
|
||||
}),
|
||||
);
|
||||
|
||||
for (const version of kind.versions ?? []) {
|
||||
const jsonSchema = reduceKindSchema(version.schema.jsonSchema);
|
||||
validateMetaSchema(jsonSchema);
|
||||
validateKindRootSchemaSemantics(jsonSchema);
|
||||
const names = Array.isArray(version.name) ? version.name : [version.name];
|
||||
for (const name of names) {
|
||||
const specTypes = version.specType
|
||||
? [version.specType].flat()
|
||||
: [undefined];
|
||||
for (const specType of specTypes) {
|
||||
ops.push(
|
||||
createDeclareKindVersionOp({
|
||||
kind: kind.names.kind,
|
||||
name,
|
||||
specType: specType,
|
||||
properties: {
|
||||
description: version.description,
|
||||
relationFields: version.relationFields,
|
||||
schema: {
|
||||
jsonSchema: jsonSchema as any,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
ops.push(
|
||||
...opsFromCatalogModelAddKindVersion({
|
||||
kind: kind.names.kind,
|
||||
versions: kind.versions ?? [],
|
||||
}),
|
||||
);
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { compileCatalogModel } from '../compileCatalogModel';
|
||||
import { createCatalogModelLayer } from '../createCatalogModelLayer';
|
||||
import { opsFromCatalogModelAddKindVersion } from './addKindVersion';
|
||||
|
||||
describe('opsFromCatalogModelAddKindVersion', () => {
|
||||
it('produces declareKindVersion ops for a single version', () => {
|
||||
const ops = opsFromCatalogModelAddKindVersion({
|
||||
kind: 'API',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha2',
|
||||
description: 'A new version',
|
||||
relationFields: [
|
||||
{
|
||||
selector: { path: 'spec.owner' },
|
||||
relation: 'ownedBy',
|
||||
defaultKind: 'Group',
|
||||
defaultNamespace: 'inherit',
|
||||
},
|
||||
],
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
owner: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(ops).toEqual([
|
||||
{
|
||||
op: 'declareKindVersion.v1',
|
||||
kind: 'API',
|
||||
name: 'v1alpha2',
|
||||
specType: undefined,
|
||||
properties: {
|
||||
description: 'A new version',
|
||||
relationFields: [
|
||||
{
|
||||
selector: { path: 'spec.owner' },
|
||||
relation: 'ownedBy',
|
||||
defaultKind: 'Group',
|
||||
defaultNamespace: 'inherit',
|
||||
},
|
||||
],
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
owner: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('produces separate ops for each specType', () => {
|
||||
const ops = opsFromCatalogModelAddKindVersion({
|
||||
kind: 'API',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha2',
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: {
|
||||
type: 'object',
|
||||
properties: { definition: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'v1alpha2',
|
||||
specType: 'mcp-server',
|
||||
description: 'An MCP server',
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: {
|
||||
type: 'object',
|
||||
properties: { remotes: { type: 'array' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(ops).toHaveLength(2);
|
||||
expect(ops[0]).toMatchObject({
|
||||
op: 'declareKindVersion.v1',
|
||||
name: 'v1alpha2',
|
||||
specType: undefined,
|
||||
});
|
||||
expect(ops[1]).toMatchObject({
|
||||
op: 'declareKindVersion.v1',
|
||||
name: 'v1alpha2',
|
||||
specType: 'mcp-server',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not produce a declareKind op', () => {
|
||||
const ops = opsFromCatalogModelAddKindVersion({
|
||||
kind: 'API',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha2',
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(ops.every(op => op.op === 'declareKindVersion.v1')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an invalid JSON schema', () => {
|
||||
expect(() =>
|
||||
opsFromCatalogModelAddKindVersion({
|
||||
kind: 'API',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha2',
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: { type: 'not-a-real-type' as any },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/Invalid JSON schema/);
|
||||
});
|
||||
|
||||
it('rejects a schema that violates semantic rules', () => {
|
||||
expect(() =>
|
||||
opsFromCatalogModelAddKindVersion({
|
||||
kind: 'API',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha2',
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
allOf: [{ properties: { spec: { type: 'object' } } }],
|
||||
properties: {
|
||||
spec: { type: 'object' },
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/allOf/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addKindVersion integration with compileCatalogModel', () => {
|
||||
it('adds a new specType to an existing kind via a separate layer', () => {
|
||||
const baseLayer = createCatalogModelLayer({
|
||||
layerId: 'test/base-api',
|
||||
builder: model => {
|
||||
model.addKind({
|
||||
group: 'backstage.io',
|
||||
names: { kind: 'API', singular: 'api', plural: 'apis' },
|
||||
description: 'An API',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha1',
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: {
|
||||
type: 'object',
|
||||
required: ['definition'],
|
||||
properties: {
|
||||
definition: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const extensionLayer = createCatalogModelLayer({
|
||||
layerId: 'test/api-mcp-extension',
|
||||
builder: model => {
|
||||
model.addKindVersion({
|
||||
kind: 'API',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha1',
|
||||
specType: 'mcp-server',
|
||||
description: 'An MCP server API',
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: {
|
||||
type: 'object',
|
||||
required: ['remotes'],
|
||||
properties: {
|
||||
remotes: { type: 'array', minItems: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const model = compileCatalogModel([baseLayer, extensionLayer]);
|
||||
|
||||
const defaultKind = model.getKind({
|
||||
kind: 'API',
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
spec: { type: 'openapi' },
|
||||
});
|
||||
const mcpKind = model.getKind({
|
||||
kind: 'API',
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
spec: { type: 'mcp-server' },
|
||||
});
|
||||
|
||||
expect(defaultKind).toBeDefined();
|
||||
expect(mcpKind).toBeDefined();
|
||||
|
||||
const defaultRequired = (defaultKind!.jsonSchema.properties as any).spec
|
||||
.required as string[];
|
||||
const mcpRequired = (mcpKind!.jsonSchema.properties as any).spec
|
||||
.required as string[];
|
||||
|
||||
expect(defaultRequired).toContain('definition');
|
||||
expect(mcpRequired).toContain('remotes');
|
||||
expect(mcpRequired).not.toContain('definition');
|
||||
expect(mcpKind!.description).toBe('An MCP server API');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { reduceKindSchema } from '../jsonSchema/reduceKindSchema';
|
||||
import { validateKindRootSchemaSemantics } from '../jsonSchema/validateKindRootSchemaSemantics';
|
||||
import { validateMetaSchema } from '../jsonSchema/validateMetaSchema';
|
||||
import { CatalogModelOp } from '../operations';
|
||||
import { createDeclareKindVersionOp } from '../operations/declareKindVersion';
|
||||
import type { CatalogModelKindVersionDefinition } from './addKind';
|
||||
|
||||
/**
|
||||
* The definition for adding one or more versions to an already-declared kind.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface CatalogModelAddKindVersionDefinition {
|
||||
/**
|
||||
* The kind to add versions to, e.g. "API".
|
||||
*/
|
||||
kind: string;
|
||||
|
||||
/**
|
||||
* The versions to add.
|
||||
*/
|
||||
versions: CatalogModelKindVersionDefinition[];
|
||||
}
|
||||
|
||||
export function opsFromCatalogModelAddKindVersion(
|
||||
definition: CatalogModelAddKindVersionDefinition,
|
||||
): CatalogModelOp[] {
|
||||
const ops: CatalogModelOp[] = [];
|
||||
|
||||
for (const version of definition.versions) {
|
||||
const jsonSchema = reduceKindSchema(version.schema.jsonSchema);
|
||||
validateMetaSchema(jsonSchema);
|
||||
validateKindRootSchemaSemantics(jsonSchema);
|
||||
const names = Array.isArray(version.name) ? version.name : [version.name];
|
||||
for (const name of names) {
|
||||
const specTypes = version.specType
|
||||
? [version.specType].flat()
|
||||
: [undefined];
|
||||
for (const specType of specTypes) {
|
||||
ops.push(
|
||||
createDeclareKindVersionOp({
|
||||
kind: definition.kind,
|
||||
name,
|
||||
specType: specType,
|
||||
properties: {
|
||||
description: version.description,
|
||||
relationFields: version.relationFields,
|
||||
schema: {
|
||||
jsonSchema: jsonSchema as any,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
type CatalogModelKindRelationFieldDefinition,
|
||||
type CatalogModelKindVersionDefinition,
|
||||
} from './addKind';
|
||||
export { type CatalogModelAddKindVersionDefinition } from './addKindVersion';
|
||||
export { type CatalogModelLabelDefinition } from './addLabel';
|
||||
export { type CatalogModelRelationPairDefinition } from './addRelationPair';
|
||||
export { type CatalogModelRemoveAnnotationDefinition } from './removeAnnotation';
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "ApiV1alpha1McpServer",
|
||||
"description": "An MCP (Model Context Protocol) server exposed as an API entity. See RFC backstage/backstage#32062.",
|
||||
"examples": [
|
||||
{
|
||||
"apiVersion": "backstage.io/v1alpha1",
|
||||
"kind": "API",
|
||||
"metadata": {
|
||||
"name": "backstage-mcp-actions",
|
||||
"title": "Backstage MCP Server",
|
||||
"description": "Exposes tools related to the Backstage Ecosystem"
|
||||
},
|
||||
"spec": {
|
||||
"type": "mcp-server",
|
||||
"lifecycle": "experimental",
|
||||
"owner": "backstage",
|
||||
"remotes": [
|
||||
{
|
||||
"type": "streamable-http",
|
||||
"url": "http://internal.backstage.company:7007/api/mcp-actions/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "Entity"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["spec"],
|
||||
"properties": {
|
||||
"apiVersion": {
|
||||
"enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
|
||||
},
|
||||
"kind": {
|
||||
"enum": ["API"]
|
||||
},
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"required": ["type", "lifecycle", "owner", "remotes"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "mcp-server",
|
||||
"description": "Discriminant for the MCP server specType."
|
||||
},
|
||||
"lifecycle": {
|
||||
"type": "string",
|
||||
"description": "The lifecycle state of the MCP server.",
|
||||
"examples": ["experimental", "production", "deprecated"],
|
||||
"minLength": 1
|
||||
},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "An entity reference to the owner of the MCP server.",
|
||||
"examples": ["ai-platform-team", "user:john.johnson"],
|
||||
"minLength": 1
|
||||
},
|
||||
"system": {
|
||||
"type": "string",
|
||||
"description": "An entity reference to the system that the MCP server belongs to.",
|
||||
"minLength": 1
|
||||
},
|
||||
"remotes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "Transport endpoints for the MCP server.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["type", "url"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Transport type, e.g. streamable-http, stdio, sse.",
|
||||
"examples": ["streamable-http", "stdio", "sse"],
|
||||
"minLength": 1
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Endpoint URL for the remote. For stdio transports this may be a command string.",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { createBackendModule } from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
CatalogModelSources,
|
||||
aiResourceEntityModel,
|
||||
mcpServerApiEntityModel,
|
||||
} from '@backstage/catalog-model/alpha';
|
||||
import { catalogModelExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
|
||||
|
||||
@@ -36,7 +37,10 @@ export const catalogModuleAiResourceEntityModel = createBackendModule({
|
||||
},
|
||||
async init({ model }) {
|
||||
model.addModelSource(
|
||||
CatalogModelSources.static([aiResourceEntityModel]),
|
||||
CatalogModelSources.static([
|
||||
aiResourceEntityModel,
|
||||
mcpServerApiEntityModel,
|
||||
]),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user