From 27aee3a9b14d527bc3cfdbc0801b25921e7f9222 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 21 Apr 2026 10:58:24 +0200 Subject: [PATCH] feat(catalog-model): add API v1beta2 with mcp-server subtype Signed-off-by: benjdlambert --- .changeset/api-entity-v1beta2.md | 5 + .../src/kinds/ApiEntityV1alpha1.ts | 51 ++++-- .../src/kinds/ApiEntityV1beta2.test.ts | 161 ++++++++++++++++++ .../src/kinds/ApiEntityV1beta2.ts | 104 +++++++++++ .../src/kinds/apiEntityModel.test.ts | 67 ++++++++ packages/catalog-model/src/kinds/index.ts | 11 ++ 6 files changed, 381 insertions(+), 18 deletions(-) create mode 100644 .changeset/api-entity-v1beta2.md create mode 100644 packages/catalog-model/src/kinds/ApiEntityV1beta2.test.ts create mode 100644 packages/catalog-model/src/kinds/ApiEntityV1beta2.ts create mode 100644 packages/catalog-model/src/kinds/apiEntityModel.test.ts diff --git a/.changeset/api-entity-v1beta2.md b/.changeset/api-entity-v1beta2.md new file mode 100644 index 0000000000..f26d46f035 --- /dev/null +++ b/.changeset/api-entity-v1beta2.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': minor +--- + +Added `backstage.io/v1beta2` of the `API` kind. It behaves identically to `v1alpha1` / `v1beta1` for the existing string-`definition` shape, and additionally supports a new `spec.type: 'mcp-server'` subtype that carries a structured `spec.remotes` list for representing Model Context Protocol (MCP) servers in the catalog. See RFC [#32062](https://github.com/backstage/backstage/issues/32062). New public exports: `ApiEntityV1beta2`, `ApiEntityV1beta2Default`, `McpServerApiEntityV1beta2`, `McpServerRemote`, `apiEntityV1beta2Validator`, `mcpServerApiEntityV1beta2Validator`, and the `isMcpServerApiEntity` type guard. diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index 565266e219..0ddf108253 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -17,6 +17,8 @@ import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; import jsonSchema from '../schema/kinds/API.v1alpha1.schema.json'; +import defaultSchemaV1beta2 from '../schema/kinds/API.v1beta2.schema.json'; +import mcpServerSchemaV1beta2 from '../schema/kinds/API.v1beta2.mcp-server.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; /** @@ -48,6 +50,22 @@ export interface ApiEntityV1alpha1 extends Entity { export const apiEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(jsonSchema); +const apiRelationFields = [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit' as const, + allowedKinds: ['Group', 'User'], + }, + { + selector: { path: 'spec.system' }, + relation: 'partOf', + defaultKind: 'System', + defaultNamespace: 'inherit' as const, + }, +]; + /** * Extends the catalog model with the API kind. * @@ -68,24 +86,21 @@ export const apiEntityModel = createCatalogModelLayer({ versions: [ { name: ['v1alpha1', 'v1beta1'], - 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, - }, + relationFields: apiRelationFields, + schema: { jsonSchema }, + }, + { + name: 'v1beta2', + relationFields: apiRelationFields, + schema: { jsonSchema: defaultSchemaV1beta2 }, + }, + { + name: 'v1beta2', + specType: 'mcp-server', + description: + 'An MCP (Model Context Protocol) server exposed as an API entity.', + relationFields: apiRelationFields, + schema: { jsonSchema: mcpServerSchemaV1beta2 }, }, ], }); diff --git a/packages/catalog-model/src/kinds/ApiEntityV1beta2.test.ts b/packages/catalog-model/src/kinds/ApiEntityV1beta2.test.ts new file mode 100644 index 0000000000..d4dd4f21cb --- /dev/null +++ b/packages/catalog-model/src/kinds/ApiEntityV1beta2.test.ts @@ -0,0 +1,161 @@ +/* + * 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 { + ApiEntityV1beta2Default, + McpServerApiEntityV1beta2, + apiEntityV1beta2Validator, + mcpServerApiEntityV1beta2Validator, + isMcpServerApiEntity, +} from './ApiEntityV1beta2'; + +describe('apiEntityV1beta2Validator (default specType)', () => { + let entity: ApiEntityV1beta2Default; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1beta2', + kind: 'API', + metadata: { name: 'test' }, + spec: { + type: 'openapi', + lifecycle: 'production', + owner: 'me', + definition: 'openapi: "3.0.0"', + system: 'system', + }, + }; + }); + + it('accepts a valid v1beta2 string-definition entity', async () => { + await expect(apiEntityV1beta2Validator.check(entity)).resolves.toBe(true); + }); + + it('ignores v1alpha1', async () => { + (entity as any).apiVersion = 'backstage.io/v1alpha1'; + await expect(apiEntityV1beta2Validator.check(entity)).resolves.toBe(false); + }); + + it('rejects missing definition', async () => { + delete (entity as any).spec.definition; + await expect(apiEntityV1beta2Validator.check(entity)).rejects.toThrow( + /definition/, + ); + }); + + it('rejects missing lifecycle', async () => { + delete (entity as any).spec.lifecycle; + await expect(apiEntityV1beta2Validator.check(entity)).rejects.toThrow( + /lifecycle/, + ); + }); +}); + +describe('mcpServerApiEntityV1beta2Validator', () => { + let entity: McpServerApiEntityV1beta2; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1beta2', + 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( + mcpServerApiEntityV1beta2Validator.check(entity), + ).resolves.toBe(true); + }); + + it('rejects wrong spec.type value', async () => { + (entity as any).spec.type = 'openapi'; + await expect( + mcpServerApiEntityV1beta2Validator.check(entity), + ).rejects.toThrow(/type/); + }); + + it('rejects missing remotes', async () => { + delete (entity as any).spec.remotes; + await expect( + mcpServerApiEntityV1beta2Validator.check(entity), + ).rejects.toThrow(/remotes/); + }); + + it('rejects empty remotes array', async () => { + (entity as any).spec.remotes = []; + await expect( + mcpServerApiEntityV1beta2Validator.check(entity), + ).rejects.toThrow(/remotes/); + }); + + it('rejects remote missing url', async () => { + (entity as any).spec.remotes[0] = { type: 'stdio' }; + await expect( + mcpServerApiEntityV1beta2Validator.check(entity), + ).rejects.toThrow(/url/); + }); + + it('rejects remote missing type', async () => { + (entity as any).spec.remotes[0] = { url: 'http://x' }; + await expect( + mcpServerApiEntityV1beta2Validator.check(entity), + ).rejects.toThrow(/type/); + }); +}); + +describe('isMcpServerApiEntity', () => { + it('returns true for an mcp-server entity', () => { + const entity: McpServerApiEntityV1beta2 = { + apiVersion: 'backstage.io/v1beta2', + 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 default entity', () => { + const entity: ApiEntityV1beta2Default = { + apiVersion: 'backstage.io/v1beta2', + kind: 'API', + metadata: { name: 'a' }, + spec: { + type: 'openapi', + lifecycle: 'production', + owner: 'me', + definition: 'x', + }, + }; + expect(isMcpServerApiEntity(entity)).toBe(false); + }); +}); diff --git a/packages/catalog-model/src/kinds/ApiEntityV1beta2.ts b/packages/catalog-model/src/kinds/ApiEntityV1beta2.ts new file mode 100644 index 0000000000..8eafedffb7 --- /dev/null +++ b/packages/catalog-model/src/kinds/ApiEntityV1beta2.ts @@ -0,0 +1,104 @@ +/* + * 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 { Entity } from '../entity/Entity'; +import defaultSchema from '../schema/kinds/API.v1beta2.schema.json'; +import mcpServerSchema from '../schema/kinds/API.v1beta2.mcp-server.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; + +/** + * Backstage API kind entity, v1beta2. Introduces structured subtypes via + * spec.type, starting with 'mcp-server'. Other values of spec.type continue + * to use the string-definition shape. + * + * @public + */ +export type ApiEntityV1beta2 = + | ApiEntityV1beta2Default + | McpServerApiEntityV1beta2; + +/** + * The default (string-definition) shape for v1beta2 API entities. Applies + * when spec.type is anything other than a declared structured subtype. + * + * @public + */ +export interface ApiEntityV1beta2Default extends Entity { + apiVersion: 'backstage.io/v1beta2'; + kind: 'API'; + spec: { + type: string; + lifecycle: string; + owner: string; + system?: string; + definition: string; + }; +} + +/** + * An MCP (Model Context Protocol) server represented as an API entity + * (v1beta2, spec.type: 'mcp-server'). + * + * @public + */ +export interface McpServerApiEntityV1beta2 extends Entity { + apiVersion: 'backstage.io/v1beta2'; + kind: 'API'; + spec: { + type: 'mcp-server'; + lifecycle: string; + owner: string; + system?: string; + remotes: McpServerRemote[]; + }; +} + +/** + * A transport endpoint for an MCP server. + * + * @public + */ +export type McpServerRemote = { + type: string; + url: string; +}; + +/** + * {@link KindValidator} for the default specType of {@link ApiEntityV1beta2}. + * + * @public + */ +export const apiEntityV1beta2Validator = + ajvCompiledJsonSchemaValidator(defaultSchema); + +/** + * {@link KindValidator} for the `mcp-server` specType of {@link ApiEntityV1beta2}. + * + * @public + */ +export const mcpServerApiEntityV1beta2Validator = + ajvCompiledJsonSchemaValidator(mcpServerSchema); + +/** + * Type guard: narrows a v1beta2 API entity to the MCP server subtype. + * + * @public + */ +export function isMcpServerApiEntity( + entity: ApiEntityV1beta2, +): entity is McpServerApiEntityV1beta2 { + return entity.spec.type === 'mcp-server'; +} diff --git a/packages/catalog-model/src/kinds/apiEntityModel.test.ts b/packages/catalog-model/src/kinds/apiEntityModel.test.ts new file mode 100644 index 0000000000..c78f162c3e --- /dev/null +++ b/packages/catalog-model/src/kinds/apiEntityModel.test.ts @@ -0,0 +1,67 @@ +/* + * 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 '../model/compileCatalogModel'; +import { defaultCatalogEntityModel } from '../model/defaultCatalogEntityModel'; + +describe('apiEntityModel v1beta2 dispatch', () => { + const model = compileCatalogModel([defaultCatalogEntityModel]); + + it('routes mcp-server and non-mcp-server v1beta2 entities to different schemas', () => { + const mcp = model.getKind({ + kind: 'API', + apiVersion: 'backstage.io/v1beta2', + spec: { type: 'mcp-server' }, + }); + const openapi = model.getKind({ + kind: 'API', + apiVersion: 'backstage.io/v1beta2', + spec: { type: 'openapi' }, + }); + + expect(mcp).toBeDefined(); + expect(openapi).toBeDefined(); + + // The mcp-server specType entry has a distinct description; the default + // entry falls back to the kind-level description. If these are equal, + // dispatch is broken. + expect(mcp!.description).not.toBe(openapi!.description); + + // The mcp-server schema requires spec.remotes; the default schema requires + // spec.definition. Inspect the compiled JSON Schema directly. + const mcpSpecRequired = (mcp!.jsonSchema.properties as any).spec + .required as string[]; + const openapiSpecRequired = (openapi!.jsonSchema.properties as any).spec + .required as string[]; + + expect(mcpSpecRequired).toContain('remotes'); + expect(mcpSpecRequired).not.toContain('definition'); + expect(openapiSpecRequired).toContain('definition'); + expect(openapiSpecRequired).not.toContain('remotes'); + }); + + it('routes a v1alpha1 entity to the existing v1alpha1 schema', () => { + const kind = model.getKind({ + kind: 'API', + apiVersion: 'backstage.io/v1alpha1', + spec: { type: 'openapi' }, + }); + expect(kind).toBeDefined(); + const required = (kind!.jsonSchema.properties as any).spec + .required as string[]; + expect(required).toContain('definition'); + }); +}); diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index 674e4eef30..1898c7900e 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -19,6 +19,17 @@ export type { ApiEntityV1alpha1 as ApiEntity, ApiEntityV1alpha1, } from './ApiEntityV1alpha1'; +export { + apiEntityV1beta2Validator, + isMcpServerApiEntity, + mcpServerApiEntityV1beta2Validator, +} from './ApiEntityV1beta2'; +export type { + ApiEntityV1beta2, + ApiEntityV1beta2Default, + McpServerApiEntityV1beta2, + McpServerRemote, +} from './ApiEntityV1beta2'; export { componentEntityV1alpha1Validator } from './ComponentEntityV1alpha1'; export type { ComponentEntityV1alpha1 as ComponentEntity,