From 9e0572b88c63dada712e611fdbca1ef2bc9d393a Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 18 May 2026 17:38:27 +0200 Subject: [PATCH 01/17] refactor: move AiResource types to @backstage/catalog-model alpha Signed-off-by: benjdlambert --- packages/catalog-model/report-alpha.api.md | 31 ++++------------------ packages/catalog-model/src/alpha.ts | 13 +++++++++ 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/packages/catalog-model/report-alpha.api.md b/packages/catalog-model/report-alpha.api.md index d1f5e4ef61..550a79c45c 100644 --- a/packages/catalog-model/report-alpha.api.md +++ b/packages/catalog-model/report-alpha.api.md @@ -14,8 +14,7 @@ export const aiResourceEntityModel: CatalogModelLayer; // @alpha export type AiResourceEntityV1alpha1 = | AiResourceEntityV1alpha1Default - | SkillAiResourceEntityV1alpha1 - | RuleAiResourceEntityV1alpha1; + | SkillAiResourceEntityV1alpha1; // @alpha export interface AiResourceEntityV1alpha1Default extends Entity { @@ -485,11 +484,6 @@ export const isAiResourceEntity: ( entity: Entity, ) => entity is AiResourceEntityV1alpha1; -// @alpha -export const isRuleAiResourceEntity: ( - entity: Entity, -) => entity is RuleAiResourceEntityV1alpha1; - // @alpha export const isSkillAiResourceEntity: ( entity: Entity, @@ -501,26 +495,11 @@ export type KindValidator = { }; // @alpha -export interface RuleAiResourceEntityV1alpha1 - extends AiResourceEntityV1alpha1Default { +export interface SkillAiResourceEntityV1alpha1 extends Entity { // (undocumented) - spec: { - type: 'rule'; - lifecycle: string; - owner: string; - system?: string; - disciplines?: string[]; - category: string; - rationale: string; - }; -} - -// @alpha -export const ruleAiResourceEntityV1alpha1Validator: KindValidator; - -// @alpha -export interface SkillAiResourceEntityV1alpha1 - extends AiResourceEntityV1alpha1Default { + apiVersion: 'backstage.io/v1alpha1'; + // (undocumented) + kind: 'AiResource'; // (undocumented) spec: { type: 'skill'; diff --git a/packages/catalog-model/src/alpha.ts b/packages/catalog-model/src/alpha.ts index 219513f167..9478b18522 100644 --- a/packages/catalog-model/src/alpha.ts +++ b/packages/catalog-model/src/alpha.ts @@ -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'; From 163561085bbd64e4162ea988ee7f17e465195c0d Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 21 Apr 2026 10:55:27 +0200 Subject: [PATCH 02/17] feat(catalog-model): add API v1beta2 default and mcp-server schemas Signed-off-by: benjdlambert --- .../kinds/API.v1beta2.mcp-server.schema.json | 93 +++++++++++++++++++ .../src/schema/kinds/API.v1beta2.schema.json | 79 ++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 packages/catalog-model/src/schema/kinds/API.v1beta2.mcp-server.schema.json create mode 100644 packages/catalog-model/src/schema/kinds/API.v1beta2.schema.json diff --git a/packages/catalog-model/src/schema/kinds/API.v1beta2.mcp-server.schema.json b/packages/catalog-model/src/schema/kinds/API.v1beta2.mcp-server.schema.json new file mode 100644 index 0000000000..0567a9d0c5 --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/API.v1beta2.mcp-server.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "ApiV1beta2McpServer", + "description": "An MCP (Model Context Protocol) server exposed as an API entity. See RFC backstage/backstage#32062.", + "examples": [ + { + "apiVersion": "backstage.io/v1beta2", + "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/v1beta2"] + }, + "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 + } + } + } + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/src/schema/kinds/API.v1beta2.schema.json b/packages/catalog-model/src/schema/kinds/API.v1beta2.schema.json new file mode 100644 index 0000000000..2ce7b5bc0e --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/API.v1beta2.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "ApiV1beta2", + "description": "An API describes an interface that can be exposed by a component. The API can be defined in different formats, like OpenAPI, AsyncAPI, GraphQL, gRPC, or other formats.", + "examples": [ + { + "apiVersion": "backstage.io/v1beta2", + "kind": "API", + "metadata": { + "name": "artist-api", + "description": "Retrieve artist details", + "labels": { + "product_name": "Random value Generator" + }, + "annotations": { + "docs": "https://github.com/..../tree/develop/doc" + } + }, + "spec": { + "type": "openapi", + "lifecycle": "production", + "owner": "artist-relations-team", + "system": "artist-engagement-portal", + "definition": "openapi: \"3.0.0\"\ninfo:..." + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1beta2"] + }, + "kind": { + "enum": ["API"] + }, + "spec": { + "type": "object", + "required": ["type", "lifecycle", "owner", "definition"], + "properties": { + "type": { + "type": "string", + "description": "The type of the API definition.", + "examples": ["openapi", "asyncapi", "graphql", "grpc", "trpc"], + "minLength": 1 + }, + "lifecycle": { + "type": "string", + "description": "The lifecycle state of the API.", + "examples": ["experimental", "production", "deprecated"], + "minLength": 1 + }, + "owner": { + "type": "string", + "description": "An entity reference to the owner of the API.", + "examples": ["artist-relations-team", "user:john.johnson"], + "minLength": 1 + }, + "system": { + "type": "string", + "description": "An entity reference to the system that the API belongs to.", + "minLength": 1 + }, + "definition": { + "type": "string", + "description": "The definition of the API, based on the format defined by the type.", + "minLength": 1 + } + } + } + } + } + ] +} From 27aee3a9b14d527bc3cfdbc0801b25921e7f9222 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 21 Apr 2026 10:58:24 +0200 Subject: [PATCH 03/17] 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, From dddcc0b3827d3684e7941d05bcc78645b55f0470 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 21 Apr 2026 11:00:32 +0200 Subject: [PATCH 04/17] chore(catalog-model): regenerate API report for v1beta2 additions Signed-off-by: benjdlambert --- packages/catalog-model/report.api.md | 54 ++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/catalog-model/report.api.md b/packages/catalog-model/report.api.md index ec03ce41e1..bcb161bdff 100644 --- a/packages/catalog-model/report.api.md +++ b/packages/catalog-model/report.api.md @@ -53,6 +53,30 @@ export { ApiEntityV1alpha1 }; // @public export const apiEntityV1alpha1Validator: KindValidator; +// @public +export type ApiEntityV1beta2 = + | ApiEntityV1beta2Default + | McpServerApiEntityV1beta2; + +// @public +export interface ApiEntityV1beta2Default extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1beta2'; + // (undocumented) + kind: 'API'; + // (undocumented) + spec: { + type: string; + lifecycle: string; + owner: string; + system?: string; + definition: string; + }; +} + +// @public +export const apiEntityV1beta2Validator: KindValidator; + // @public export class CommonValidatorFunctions { static isJsonSafe(value: unknown): boolean; @@ -273,6 +297,11 @@ export function isLocationEntity( entity: Entity, ): entity is LocationEntityV1alpha1; +// @public +export function isMcpServerApiEntity( + entity: ApiEntityV1beta2, +): entity is McpServerApiEntityV1beta2; + // @public (undocumented) export function isResourceEntity( entity: Entity, @@ -332,6 +361,31 @@ export const locationEntityV1alpha1Validator: KindValidator; // @public export function makeValidator(overrides?: Partial): Validators; +// @public +export interface McpServerApiEntityV1beta2 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1beta2'; + // (undocumented) + kind: 'API'; + // (undocumented) + spec: { + type: 'mcp-server'; + lifecycle: string; + owner: string; + system?: string; + remotes: McpServerRemote[]; + }; +} + +// @public +export const mcpServerApiEntityV1beta2Validator: KindValidator; + +// @public +export type McpServerRemote = { + type: string; + url: string; +}; + // @public export class NoForeignRootFieldsEntityPolicy implements EntityPolicy { constructor(knownFields?: string[]); From 2cf453f32835dd979b85429e6a602926c71f6875 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 21 Apr 2026 11:13:27 +0200 Subject: [PATCH 05/17] refactor(catalog-model): rename API v1beta2 to v1alpha2 Signed-off-by: benjdlambert --- .changeset/api-entity-v1alpha2.md | 5 ++ .changeset/api-entity-v1beta2.md | 5 -- packages/catalog-model/report.api.md | 22 ++++---- .../src/kinds/ApiEntityV1alpha1.ts | 12 ++--- ...eta2.test.ts => ApiEntityV1alpha2.test.ts} | 52 +++++++++---------- ...iEntityV1beta2.ts => ApiEntityV1alpha2.ts} | 38 +++++++------- .../src/kinds/apiEntityModel.test.ts | 8 +-- packages/catalog-model/src/kinds/index.ts | 14 ++--- ...on => API.v1alpha2.mcp-server.schema.json} | 6 +-- ...2.schema.json => API.v1alpha2.schema.json} | 6 +-- 10 files changed, 84 insertions(+), 84 deletions(-) create mode 100644 .changeset/api-entity-v1alpha2.md delete mode 100644 .changeset/api-entity-v1beta2.md rename packages/catalog-model/src/kinds/{ApiEntityV1beta2.test.ts => ApiEntityV1alpha2.test.ts} (70%) rename packages/catalog-model/src/kinds/{ApiEntityV1beta2.ts => ApiEntityV1alpha2.ts} (66%) rename packages/catalog-model/src/schema/kinds/{API.v1beta2.mcp-server.schema.json => API.v1alpha2.mcp-server.schema.json} (95%) rename packages/catalog-model/src/schema/kinds/{API.v1beta2.schema.json => API.v1alpha2.schema.json} (95%) diff --git a/.changeset/api-entity-v1alpha2.md b/.changeset/api-entity-v1alpha2.md new file mode 100644 index 0000000000..49e5455a16 --- /dev/null +++ b/.changeset/api-entity-v1alpha2.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': minor +--- + +Added `backstage.io/v1alpha2` 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: `ApiEntityV1alpha2`, `ApiEntityV1alpha2Default`, `McpServerApiEntityV1alpha2`, `McpServerRemote`, `apiEntityV1alpha2Validator`, `mcpServerApiEntityV1alpha2Validator`, and the `isMcpServerApiEntity` type guard. diff --git a/.changeset/api-entity-v1beta2.md b/.changeset/api-entity-v1beta2.md deleted file mode 100644 index f26d46f035..0000000000 --- a/.changeset/api-entity-v1beta2.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@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/report.api.md b/packages/catalog-model/report.api.md index bcb161bdff..1f1716136a 100644 --- a/packages/catalog-model/report.api.md +++ b/packages/catalog-model/report.api.md @@ -54,14 +54,14 @@ export { ApiEntityV1alpha1 }; export const apiEntityV1alpha1Validator: KindValidator; // @public -export type ApiEntityV1beta2 = - | ApiEntityV1beta2Default - | McpServerApiEntityV1beta2; +export type ApiEntityV1alpha2 = + | ApiEntityV1alpha2Default + | McpServerApiEntityV1alpha2; // @public -export interface ApiEntityV1beta2Default extends Entity { +export interface ApiEntityV1alpha2Default extends Entity { // (undocumented) - apiVersion: 'backstage.io/v1beta2'; + apiVersion: 'backstage.io/v1alpha2'; // (undocumented) kind: 'API'; // (undocumented) @@ -75,7 +75,7 @@ export interface ApiEntityV1beta2Default extends Entity { } // @public -export const apiEntityV1beta2Validator: KindValidator; +export const apiEntityV1alpha2Validator: KindValidator; // @public export class CommonValidatorFunctions { @@ -299,8 +299,8 @@ export function isLocationEntity( // @public export function isMcpServerApiEntity( - entity: ApiEntityV1beta2, -): entity is McpServerApiEntityV1beta2; + entity: ApiEntityV1alpha2, +): entity is McpServerApiEntityV1alpha2; // @public (undocumented) export function isResourceEntity( @@ -362,9 +362,9 @@ export const locationEntityV1alpha1Validator: KindValidator; export function makeValidator(overrides?: Partial): Validators; // @public -export interface McpServerApiEntityV1beta2 extends Entity { +export interface McpServerApiEntityV1alpha2 extends Entity { // (undocumented) - apiVersion: 'backstage.io/v1beta2'; + apiVersion: 'backstage.io/v1alpha2'; // (undocumented) kind: 'API'; // (undocumented) @@ -378,7 +378,7 @@ export interface McpServerApiEntityV1beta2 extends Entity { } // @public -export const mcpServerApiEntityV1beta2Validator: KindValidator; +export const mcpServerApiEntityV1alpha2Validator: KindValidator; // @public export type McpServerRemote = { diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index 0ddf108253..2208bd66ec 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -17,8 +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 defaultSchemaV1alpha2 from '../schema/kinds/API.v1alpha2.schema.json'; +import mcpServerSchemaV1alpha2 from '../schema/kinds/API.v1alpha2.mcp-server.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; /** @@ -90,17 +90,17 @@ export const apiEntityModel = createCatalogModelLayer({ schema: { jsonSchema }, }, { - name: 'v1beta2', + name: 'v1alpha2', relationFields: apiRelationFields, - schema: { jsonSchema: defaultSchemaV1beta2 }, + schema: { jsonSchema: defaultSchemaV1alpha2 }, }, { - name: 'v1beta2', + name: 'v1alpha2', specType: 'mcp-server', description: 'An MCP (Model Context Protocol) server exposed as an API entity.', relationFields: apiRelationFields, - schema: { jsonSchema: mcpServerSchemaV1beta2 }, + schema: { jsonSchema: mcpServerSchemaV1alpha2 }, }, ], }); diff --git a/packages/catalog-model/src/kinds/ApiEntityV1beta2.test.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha2.test.ts similarity index 70% rename from packages/catalog-model/src/kinds/ApiEntityV1beta2.test.ts rename to packages/catalog-model/src/kinds/ApiEntityV1alpha2.test.ts index d4dd4f21cb..acf7ccffb8 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1beta2.test.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha2.test.ts @@ -15,19 +15,19 @@ */ import { - ApiEntityV1beta2Default, - McpServerApiEntityV1beta2, - apiEntityV1beta2Validator, - mcpServerApiEntityV1beta2Validator, + ApiEntityV1alpha2Default, + McpServerApiEntityV1alpha2, + apiEntityV1alpha2Validator, + mcpServerApiEntityV1alpha2Validator, isMcpServerApiEntity, -} from './ApiEntityV1beta2'; +} from './ApiEntityV1alpha2'; -describe('apiEntityV1beta2Validator (default specType)', () => { - let entity: ApiEntityV1beta2Default; +describe('apiEntityV1alpha2Validator (default specType)', () => { + let entity: ApiEntityV1alpha2Default; beforeEach(() => { entity = { - apiVersion: 'backstage.io/v1beta2', + apiVersion: 'backstage.io/v1alpha2', kind: 'API', metadata: { name: 'test' }, spec: { @@ -40,36 +40,36 @@ describe('apiEntityV1beta2Validator (default specType)', () => { }; }); - it('accepts a valid v1beta2 string-definition entity', async () => { - await expect(apiEntityV1beta2Validator.check(entity)).resolves.toBe(true); + it('accepts a valid v1alpha2 string-definition entity', async () => { + await expect(apiEntityV1alpha2Validator.check(entity)).resolves.toBe(true); }); it('ignores v1alpha1', async () => { (entity as any).apiVersion = 'backstage.io/v1alpha1'; - await expect(apiEntityV1beta2Validator.check(entity)).resolves.toBe(false); + await expect(apiEntityV1alpha2Validator.check(entity)).resolves.toBe(false); }); it('rejects missing definition', async () => { delete (entity as any).spec.definition; - await expect(apiEntityV1beta2Validator.check(entity)).rejects.toThrow( + await expect(apiEntityV1alpha2Validator.check(entity)).rejects.toThrow( /definition/, ); }); it('rejects missing lifecycle', async () => { delete (entity as any).spec.lifecycle; - await expect(apiEntityV1beta2Validator.check(entity)).rejects.toThrow( + await expect(apiEntityV1alpha2Validator.check(entity)).rejects.toThrow( /lifecycle/, ); }); }); -describe('mcpServerApiEntityV1beta2Validator', () => { - let entity: McpServerApiEntityV1beta2; +describe('mcpServerApiEntityV1alpha2Validator', () => { + let entity: McpServerApiEntityV1alpha2; beforeEach(() => { entity = { - apiVersion: 'backstage.io/v1beta2', + apiVersion: 'backstage.io/v1alpha2', kind: 'API', metadata: { name: 'test-mcp' }, spec: { @@ -88,50 +88,50 @@ describe('mcpServerApiEntityV1beta2Validator', () => { it('accepts a valid mcp-server entity', async () => { await expect( - mcpServerApiEntityV1beta2Validator.check(entity), + mcpServerApiEntityV1alpha2Validator.check(entity), ).resolves.toBe(true); }); it('rejects wrong spec.type value', async () => { (entity as any).spec.type = 'openapi'; await expect( - mcpServerApiEntityV1beta2Validator.check(entity), + mcpServerApiEntityV1alpha2Validator.check(entity), ).rejects.toThrow(/type/); }); it('rejects missing remotes', async () => { delete (entity as any).spec.remotes; await expect( - mcpServerApiEntityV1beta2Validator.check(entity), + mcpServerApiEntityV1alpha2Validator.check(entity), ).rejects.toThrow(/remotes/); }); it('rejects empty remotes array', async () => { (entity as any).spec.remotes = []; await expect( - mcpServerApiEntityV1beta2Validator.check(entity), + mcpServerApiEntityV1alpha2Validator.check(entity), ).rejects.toThrow(/remotes/); }); it('rejects remote missing url', async () => { (entity as any).spec.remotes[0] = { type: 'stdio' }; await expect( - mcpServerApiEntityV1beta2Validator.check(entity), + mcpServerApiEntityV1alpha2Validator.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), + mcpServerApiEntityV1alpha2Validator.check(entity), ).rejects.toThrow(/type/); }); }); describe('isMcpServerApiEntity', () => { it('returns true for an mcp-server entity', () => { - const entity: McpServerApiEntityV1beta2 = { - apiVersion: 'backstage.io/v1beta2', + const entity: McpServerApiEntityV1alpha2 = { + apiVersion: 'backstage.io/v1alpha2', kind: 'API', metadata: { name: 'm' }, spec: { @@ -145,8 +145,8 @@ describe('isMcpServerApiEntity', () => { }); it('returns false for a default entity', () => { - const entity: ApiEntityV1beta2Default = { - apiVersion: 'backstage.io/v1beta2', + const entity: ApiEntityV1alpha2Default = { + apiVersion: 'backstage.io/v1alpha2', kind: 'API', metadata: { name: 'a' }, spec: { diff --git a/packages/catalog-model/src/kinds/ApiEntityV1beta2.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha2.ts similarity index 66% rename from packages/catalog-model/src/kinds/ApiEntityV1beta2.ts rename to packages/catalog-model/src/kinds/ApiEntityV1alpha2.ts index 8eafedffb7..08be91243f 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1beta2.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha2.ts @@ -15,29 +15,29 @@ */ 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 defaultSchema from '../schema/kinds/API.v1alpha2.schema.json'; +import mcpServerSchema from '../schema/kinds/API.v1alpha2.mcp-server.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; /** - * Backstage API kind entity, v1beta2. Introduces structured subtypes via + * Backstage API kind entity, v1alpha2. 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; +export type ApiEntityV1alpha2 = + | ApiEntityV1alpha2Default + | McpServerApiEntityV1alpha2; /** - * The default (string-definition) shape for v1beta2 API entities. Applies + * The default (string-definition) shape for v1alpha2 API entities. Applies * when spec.type is anything other than a declared structured subtype. * * @public */ -export interface ApiEntityV1beta2Default extends Entity { - apiVersion: 'backstage.io/v1beta2'; +export interface ApiEntityV1alpha2Default extends Entity { + apiVersion: 'backstage.io/v1alpha2'; kind: 'API'; spec: { type: string; @@ -50,12 +50,12 @@ export interface ApiEntityV1beta2Default extends Entity { /** * An MCP (Model Context Protocol) server represented as an API entity - * (v1beta2, spec.type: 'mcp-server'). + * (v1alpha2, spec.type: 'mcp-server'). * * @public */ -export interface McpServerApiEntityV1beta2 extends Entity { - apiVersion: 'backstage.io/v1beta2'; +export interface McpServerApiEntityV1alpha2 extends Entity { + apiVersion: 'backstage.io/v1alpha2'; kind: 'API'; spec: { type: 'mcp-server'; @@ -77,28 +77,28 @@ export type McpServerRemote = { }; /** - * {@link KindValidator} for the default specType of {@link ApiEntityV1beta2}. + * {@link KindValidator} for the default specType of {@link ApiEntityV1alpha2}. * * @public */ -export const apiEntityV1beta2Validator = +export const apiEntityV1alpha2Validator = ajvCompiledJsonSchemaValidator(defaultSchema); /** - * {@link KindValidator} for the `mcp-server` specType of {@link ApiEntityV1beta2}. + * {@link KindValidator} for the `mcp-server` specType of {@link ApiEntityV1alpha2}. * * @public */ -export const mcpServerApiEntityV1beta2Validator = +export const mcpServerApiEntityV1alpha2Validator = ajvCompiledJsonSchemaValidator(mcpServerSchema); /** - * Type guard: narrows a v1beta2 API entity to the MCP server subtype. + * Type guard: narrows a v1alpha2 API entity to the MCP server subtype. * * @public */ export function isMcpServerApiEntity( - entity: ApiEntityV1beta2, -): entity is McpServerApiEntityV1beta2 { + entity: ApiEntityV1alpha2, +): entity is McpServerApiEntityV1alpha2 { 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 index c78f162c3e..7e58b6bd2d 100644 --- a/packages/catalog-model/src/kinds/apiEntityModel.test.ts +++ b/packages/catalog-model/src/kinds/apiEntityModel.test.ts @@ -17,18 +17,18 @@ import { compileCatalogModel } from '../model/compileCatalogModel'; import { defaultCatalogEntityModel } from '../model/defaultCatalogEntityModel'; -describe('apiEntityModel v1beta2 dispatch', () => { +describe('apiEntityModel v1alpha2 dispatch', () => { const model = compileCatalogModel([defaultCatalogEntityModel]); - it('routes mcp-server and non-mcp-server v1beta2 entities to different schemas', () => { + it('routes mcp-server and non-mcp-server v1alpha2 entities to different schemas', () => { const mcp = model.getKind({ kind: 'API', - apiVersion: 'backstage.io/v1beta2', + apiVersion: 'backstage.io/v1alpha2', spec: { type: 'mcp-server' }, }); const openapi = model.getKind({ kind: 'API', - apiVersion: 'backstage.io/v1beta2', + apiVersion: 'backstage.io/v1alpha2', spec: { type: 'openapi' }, }); diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index 1898c7900e..596671e327 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -20,16 +20,16 @@ export type { ApiEntityV1alpha1, } from './ApiEntityV1alpha1'; export { - apiEntityV1beta2Validator, + apiEntityV1alpha2Validator, isMcpServerApiEntity, - mcpServerApiEntityV1beta2Validator, -} from './ApiEntityV1beta2'; + mcpServerApiEntityV1alpha2Validator, +} from './ApiEntityV1alpha2'; export type { - ApiEntityV1beta2, - ApiEntityV1beta2Default, - McpServerApiEntityV1beta2, + ApiEntityV1alpha2, + ApiEntityV1alpha2Default, + McpServerApiEntityV1alpha2, McpServerRemote, -} from './ApiEntityV1beta2'; +} from './ApiEntityV1alpha2'; export { componentEntityV1alpha1Validator } from './ComponentEntityV1alpha1'; export type { ComponentEntityV1alpha1 as ComponentEntity, diff --git a/packages/catalog-model/src/schema/kinds/API.v1beta2.mcp-server.schema.json b/packages/catalog-model/src/schema/kinds/API.v1alpha2.mcp-server.schema.json similarity index 95% rename from packages/catalog-model/src/schema/kinds/API.v1beta2.mcp-server.schema.json rename to packages/catalog-model/src/schema/kinds/API.v1alpha2.mcp-server.schema.json index 0567a9d0c5..7155171e83 100644 --- a/packages/catalog-model/src/schema/kinds/API.v1beta2.mcp-server.schema.json +++ b/packages/catalog-model/src/schema/kinds/API.v1alpha2.mcp-server.schema.json @@ -1,10 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ApiV1beta2McpServer", + "$id": "ApiV1alpha2McpServer", "description": "An MCP (Model Context Protocol) server exposed as an API entity. See RFC backstage/backstage#32062.", "examples": [ { - "apiVersion": "backstage.io/v1beta2", + "apiVersion": "backstage.io/v1alpha2", "kind": "API", "metadata": { "name": "backstage-mcp-actions", @@ -33,7 +33,7 @@ "required": ["spec"], "properties": { "apiVersion": { - "enum": ["backstage.io/v1beta2"] + "enum": ["backstage.io/v1alpha2"] }, "kind": { "enum": ["API"] diff --git a/packages/catalog-model/src/schema/kinds/API.v1beta2.schema.json b/packages/catalog-model/src/schema/kinds/API.v1alpha2.schema.json similarity index 95% rename from packages/catalog-model/src/schema/kinds/API.v1beta2.schema.json rename to packages/catalog-model/src/schema/kinds/API.v1alpha2.schema.json index 2ce7b5bc0e..848af6627e 100644 --- a/packages/catalog-model/src/schema/kinds/API.v1beta2.schema.json +++ b/packages/catalog-model/src/schema/kinds/API.v1alpha2.schema.json @@ -1,10 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ApiV1beta2", + "$id": "ApiV1alpha2", "description": "An API describes an interface that can be exposed by a component. The API can be defined in different formats, like OpenAPI, AsyncAPI, GraphQL, gRPC, or other formats.", "examples": [ { - "apiVersion": "backstage.io/v1beta2", + "apiVersion": "backstage.io/v1alpha2", "kind": "API", "metadata": { "name": "artist-api", @@ -34,7 +34,7 @@ "required": ["spec"], "properties": { "apiVersion": { - "enum": ["backstage.io/v1beta2"] + "enum": ["backstage.io/v1alpha2"] }, "kind": { "enum": ["API"] From fc407b805ef3fa50384814ded667431f98539bbd Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 12 May 2026 08:07:22 +0200 Subject: [PATCH 06/17] chore: remove explanatory comments from apiEntityModel test Signed-off-by: benjdlambert --- packages/catalog-model/src/kinds/apiEntityModel.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/catalog-model/src/kinds/apiEntityModel.test.ts b/packages/catalog-model/src/kinds/apiEntityModel.test.ts index 7e58b6bd2d..9189d0a29a 100644 --- a/packages/catalog-model/src/kinds/apiEntityModel.test.ts +++ b/packages/catalog-model/src/kinds/apiEntityModel.test.ts @@ -34,14 +34,8 @@ describe('apiEntityModel v1alpha2 dispatch', () => { 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 From 8dd6dce9edd9a903d5efb1e8915f7b7cba1adc6d Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 18 May 2026 14:54:41 +0200 Subject: [PATCH 07/17] feat(catalog-model): add addKindVersion builder method and move v1alpha2 to own layer Adds addKindVersion to CatalogModelLayerBuilder so layers can add versions to an existing kind without re-declaring its metadata. Moves v1alpha2 API registration into ApiEntityV1alpha2.ts as a separate model layer using the new method. Signed-off-by: benjdlambert --- packages/catalog-model/report-alpha.api.md | 7 + .../src/kinds/ApiEntityV1alpha1.ts | 47 +-- .../src/kinds/ApiEntityV1alpha2.ts | 46 +++ .../model/createCatalogModelLayerBuilder.ts | 13 + .../src/model/defaultCatalogEntityModel.ts | 2 + .../src/model/modelActions/addKind.ts | 38 +-- .../model/modelActions/addKindVersion.test.ts | 291 ++++++++++++++++++ .../src/model/modelActions/addKindVersion.ts | 75 +++++ .../src/model/modelActions/index.ts | 1 + 9 files changed, 457 insertions(+), 63 deletions(-) create mode 100644 packages/catalog-model/src/model/modelActions/addKindVersion.test.ts create mode 100644 packages/catalog-model/src/model/modelActions/addKindVersion.ts diff --git a/packages/catalog-model/report-alpha.api.md b/packages/catalog-model/report-alpha.api.md index 550a79c45c..b2861ea5c9 100644 --- a/packages/catalog-model/report-alpha.api.md +++ b/packages/catalog-model/report-alpha.api.md @@ -69,6 +69,12 @@ export interface CatalogModel { listRelations(): CatalogModelRelationSummary[]; } +// @alpha +export interface CatalogModelAddKindVersionDefinition { + kind: string; + versions: CatalogModelKindVersionDefinition[]; +} + // @alpha export interface CatalogModelAnnotationDefinition { description: string; @@ -228,6 +234,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; diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index 2208bd66ec..e735a9eae8 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -17,8 +17,6 @@ import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; import jsonSchema from '../schema/kinds/API.v1alpha1.schema.json'; -import defaultSchemaV1alpha2 from '../schema/kinds/API.v1alpha2.schema.json'; -import mcpServerSchemaV1alpha2 from '../schema/kinds/API.v1alpha2.mcp-server.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; /** @@ -50,22 +48,6 @@ 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. * @@ -86,22 +68,23 @@ export const apiEntityModel = createCatalogModelLayer({ versions: [ { name: ['v1alpha1', 'v1beta1'], - relationFields: apiRelationFields, + 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 }, }, - { - name: 'v1alpha2', - relationFields: apiRelationFields, - schema: { jsonSchema: defaultSchemaV1alpha2 }, - }, - { - name: 'v1alpha2', - specType: 'mcp-server', - description: - 'An MCP (Model Context Protocol) server exposed as an API entity.', - relationFields: apiRelationFields, - schema: { jsonSchema: mcpServerSchemaV1alpha2 }, - }, ], }); }, diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha2.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha2.ts index 08be91243f..ce2296982f 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha2.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha2.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; import type { Entity } from '../entity/Entity'; import defaultSchema from '../schema/kinds/API.v1alpha2.schema.json'; import mcpServerSchema from '../schema/kinds/API.v1alpha2.mcp-server.schema.json'; @@ -102,3 +103,48 @@ export function isMcpServerApiEntity( ): entity is McpServerApiEntityV1alpha2 { return entity.spec.type === 'mcp-server'; } + +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 v1alpha2 versions of the API kind. + * + * @alpha + */ +export const apiEntityV1alpha2Model = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/kind-api-v1alpha2', + builder: model => { + model.addKindVersion({ + kind: 'API', + versions: [ + { + name: 'v1alpha2', + relationFields: apiRelationFields, + schema: { jsonSchema: defaultSchema }, + }, + { + name: 'v1alpha2', + specType: 'mcp-server', + description: + 'An MCP (Model Context Protocol) server exposed as an API entity.', + relationFields: apiRelationFields, + schema: { jsonSchema: mcpServerSchema }, + }, + ], + }); + }, +}); diff --git a/packages/catalog-model/src/model/createCatalogModelLayerBuilder.ts b/packages/catalog-model/src/model/createCatalogModelLayerBuilder.ts index 98df29b2b3..9b3e2b4d96 100644 --- a/packages/catalog-model/src/model/createCatalogModelLayerBuilder.ts +++ b/packages/catalog-model/src/model/createCatalogModelLayerBuilder.ts @@ -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); diff --git a/packages/catalog-model/src/model/defaultCatalogEntityModel.ts b/packages/catalog-model/src/model/defaultCatalogEntityModel.ts index f997a78707..a54b0cde54 100644 --- a/packages/catalog-model/src/model/defaultCatalogEntityModel.ts +++ b/packages/catalog-model/src/model/defaultCatalogEntityModel.ts @@ -15,6 +15,7 @@ */ import { apiEntityModel } from '../kinds/ApiEntityV1alpha1'; +import { apiEntityV1alpha2Model } from '../kinds/ApiEntityV1alpha2'; import { componentEntityModel } from '../kinds/ComponentEntityV1alpha1'; import { domainEntityModel } from '../kinds/DomainEntityV1alpha1'; import { groupEntityModel } from '../kinds/GroupEntityV1alpha1'; @@ -36,6 +37,7 @@ export const defaultCatalogEntityModel = createCatalogModelLayer({ layerId: 'catalog.backstage.io/default-entity-model', builder: model => { model.import(apiEntityModel); + model.import(apiEntityV1alpha2Model); model.import(componentEntityModel); model.import(domainEntityModel); model.import(groupEntityModel); diff --git a/packages/catalog-model/src/model/modelActions/addKind.ts b/packages/catalog-model/src/model/modelActions/addKind.ts index 00ca3a8802..2cdc3bf87a 100644 --- a/packages/catalog-model/src/model/modelActions/addKind.ts +++ b/packages/catalog-model/src/model/modelActions/addKind.ts @@ -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; } diff --git a/packages/catalog-model/src/model/modelActions/addKindVersion.test.ts b/packages/catalog-model/src/model/modelActions/addKindVersion.test.ts new file mode 100644 index 0000000000..a48557bd6f --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/addKindVersion.test.ts @@ -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'); + }); +}); diff --git a/packages/catalog-model/src/model/modelActions/addKindVersion.ts b/packages/catalog-model/src/model/modelActions/addKindVersion.ts new file mode 100644 index 0000000000..e8f64a7224 --- /dev/null +++ b/packages/catalog-model/src/model/modelActions/addKindVersion.ts @@ -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; +} diff --git a/packages/catalog-model/src/model/modelActions/index.ts b/packages/catalog-model/src/model/modelActions/index.ts index ca2a6fd539..bb340a561b 100644 --- a/packages/catalog-model/src/model/modelActions/index.ts +++ b/packages/catalog-model/src/model/modelActions/index.ts @@ -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'; From 421af6a8d1714719e2a530936483c1ef88d07f3d Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 18 May 2026 15:37:35 +0200 Subject: [PATCH 08/17] chore: add example MCP server API entity Signed-off-by: benjdlambert --- packages/catalog-model/examples/all-apis.yaml | 1 + .../examples/apis/backstage-mcp-server-api.yaml | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml diff --git a/packages/catalog-model/examples/all-apis.yaml b/packages/catalog-model/examples/all-apis.yaml index 47c6f95539..5b571cd74b 100644 --- a/packages/catalog-model/examples/all-apis.yaml +++ b/packages/catalog-model/examples/all-apis.yaml @@ -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 diff --git a/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml b/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml new file mode 100644 index 0000000000..e4ab5478f3 --- /dev/null +++ b/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml @@ -0,0 +1,15 @@ +apiVersion: backstage.io/v1alpha2 +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 From 664d1da60fa9d94dec08f83f4ec377ecbc4f2901 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 18 May 2026 15:49:30 +0200 Subject: [PATCH 09/17] refactor(catalog-model): move mcp-server to v1alpha1 specType, drop v1alpha2 Registers mcp-server as a specType on v1alpha1/v1beta1 instead of introducing a new v1alpha2 apiVersion. Adds addKindVersion to the builder so separate layers can extend existing kinds. Signed-off-by: benjdlambert --- .changeset/api-entity-v1alpha2.md | 2 +- .../apis/backstage-mcp-server-api.yaml | 2 +- packages/catalog-model/report.api.md | 34 +--- .../src/kinds/ApiEntityV1alpha2.test.ts | 161 ------------------ .../src/kinds/ApiEntityV1alpha2.ts | 150 ---------------- .../src/kinds/McpServerApiEntity.test.ts | 132 ++++++++++++++ .../src/kinds/McpServerApiEntity.ts | 108 ++++++++++++ .../src/kinds/apiEntityModel.test.ts | 22 +-- packages/catalog-model/src/kinds/index.ts | 12 +- .../src/model/defaultCatalogEntityModel.ts | 4 +- ...on => API.v1alpha1.mcp-server.schema.json} | 6 +- .../src/schema/kinds/API.v1alpha2.schema.json | 79 --------- 12 files changed, 266 insertions(+), 446 deletions(-) delete mode 100644 packages/catalog-model/src/kinds/ApiEntityV1alpha2.test.ts delete mode 100644 packages/catalog-model/src/kinds/ApiEntityV1alpha2.ts create mode 100644 packages/catalog-model/src/kinds/McpServerApiEntity.test.ts create mode 100644 packages/catalog-model/src/kinds/McpServerApiEntity.ts rename packages/catalog-model/src/schema/kinds/{API.v1alpha2.mcp-server.schema.json => API.v1alpha1.mcp-server.schema.json} (95%) delete mode 100644 packages/catalog-model/src/schema/kinds/API.v1alpha2.schema.json diff --git a/.changeset/api-entity-v1alpha2.md b/.changeset/api-entity-v1alpha2.md index 49e5455a16..070ecbfcde 100644 --- a/.changeset/api-entity-v1alpha2.md +++ b/.changeset/api-entity-v1alpha2.md @@ -2,4 +2,4 @@ '@backstage/catalog-model': minor --- -Added `backstage.io/v1alpha2` 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: `ApiEntityV1alpha2`, `ApiEntityV1alpha2Default`, `McpServerApiEntityV1alpha2`, `McpServerRemote`, `apiEntityV1alpha2Validator`, `mcpServerApiEntityV1alpha2Validator`, and the `isMcpServerApiEntity` type guard. +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. diff --git a/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml b/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml index e4ab5478f3..9a4a986118 100644 --- a/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml +++ b/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml @@ -1,4 +1,4 @@ -apiVersion: backstage.io/v1alpha2 +apiVersion: backstage.io/v1alpha1 kind: API metadata: name: backstage-mcp-server diff --git a/packages/catalog-model/report.api.md b/packages/catalog-model/report.api.md index 1f1716136a..be42c33e7c 100644 --- a/packages/catalog-model/report.api.md +++ b/packages/catalog-model/report.api.md @@ -53,30 +53,6 @@ export { ApiEntityV1alpha1 }; // @public export const apiEntityV1alpha1Validator: KindValidator; -// @public -export type ApiEntityV1alpha2 = - | ApiEntityV1alpha2Default - | McpServerApiEntityV1alpha2; - -// @public -export interface ApiEntityV1alpha2Default extends Entity { - // (undocumented) - apiVersion: 'backstage.io/v1alpha2'; - // (undocumented) - kind: 'API'; - // (undocumented) - spec: { - type: string; - lifecycle: string; - owner: string; - system?: string; - definition: string; - }; -} - -// @public -export const apiEntityV1alpha2Validator: KindValidator; - // @public export class CommonValidatorFunctions { static isJsonSafe(value: unknown): boolean; @@ -299,8 +275,8 @@ export function isLocationEntity( // @public export function isMcpServerApiEntity( - entity: ApiEntityV1alpha2, -): entity is McpServerApiEntityV1alpha2; + entity: Entity, +): entity is McpServerApiEntity; // @public (undocumented) export function isResourceEntity( @@ -362,9 +338,9 @@ export const locationEntityV1alpha1Validator: KindValidator; export function makeValidator(overrides?: Partial): Validators; // @public -export interface McpServerApiEntityV1alpha2 extends Entity { +export interface McpServerApiEntity extends Entity { // (undocumented) - apiVersion: 'backstage.io/v1alpha2'; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; // (undocumented) kind: 'API'; // (undocumented) @@ -378,7 +354,7 @@ export interface McpServerApiEntityV1alpha2 extends Entity { } // @public -export const mcpServerApiEntityV1alpha2Validator: KindValidator; +export const mcpServerApiEntityValidator: KindValidator; // @public export type McpServerRemote = { diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha2.test.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha2.test.ts deleted file mode 100644 index acf7ccffb8..0000000000 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha2.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* - * 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 { - ApiEntityV1alpha2Default, - McpServerApiEntityV1alpha2, - apiEntityV1alpha2Validator, - mcpServerApiEntityV1alpha2Validator, - isMcpServerApiEntity, -} from './ApiEntityV1alpha2'; - -describe('apiEntityV1alpha2Validator (default specType)', () => { - let entity: ApiEntityV1alpha2Default; - - beforeEach(() => { - entity = { - apiVersion: 'backstage.io/v1alpha2', - kind: 'API', - metadata: { name: 'test' }, - spec: { - type: 'openapi', - lifecycle: 'production', - owner: 'me', - definition: 'openapi: "3.0.0"', - system: 'system', - }, - }; - }); - - it('accepts a valid v1alpha2 string-definition entity', async () => { - await expect(apiEntityV1alpha2Validator.check(entity)).resolves.toBe(true); - }); - - it('ignores v1alpha1', async () => { - (entity as any).apiVersion = 'backstage.io/v1alpha1'; - await expect(apiEntityV1alpha2Validator.check(entity)).resolves.toBe(false); - }); - - it('rejects missing definition', async () => { - delete (entity as any).spec.definition; - await expect(apiEntityV1alpha2Validator.check(entity)).rejects.toThrow( - /definition/, - ); - }); - - it('rejects missing lifecycle', async () => { - delete (entity as any).spec.lifecycle; - await expect(apiEntityV1alpha2Validator.check(entity)).rejects.toThrow( - /lifecycle/, - ); - }); -}); - -describe('mcpServerApiEntityV1alpha2Validator', () => { - let entity: McpServerApiEntityV1alpha2; - - beforeEach(() => { - entity = { - apiVersion: 'backstage.io/v1alpha2', - 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( - mcpServerApiEntityV1alpha2Validator.check(entity), - ).resolves.toBe(true); - }); - - it('rejects wrong spec.type value', async () => { - (entity as any).spec.type = 'openapi'; - await expect( - mcpServerApiEntityV1alpha2Validator.check(entity), - ).rejects.toThrow(/type/); - }); - - it('rejects missing remotes', async () => { - delete (entity as any).spec.remotes; - await expect( - mcpServerApiEntityV1alpha2Validator.check(entity), - ).rejects.toThrow(/remotes/); - }); - - it('rejects empty remotes array', async () => { - (entity as any).spec.remotes = []; - await expect( - mcpServerApiEntityV1alpha2Validator.check(entity), - ).rejects.toThrow(/remotes/); - }); - - it('rejects remote missing url', async () => { - (entity as any).spec.remotes[0] = { type: 'stdio' }; - await expect( - mcpServerApiEntityV1alpha2Validator.check(entity), - ).rejects.toThrow(/url/); - }); - - it('rejects remote missing type', async () => { - (entity as any).spec.remotes[0] = { url: 'http://x' }; - await expect( - mcpServerApiEntityV1alpha2Validator.check(entity), - ).rejects.toThrow(/type/); - }); -}); - -describe('isMcpServerApiEntity', () => { - it('returns true for an mcp-server entity', () => { - const entity: McpServerApiEntityV1alpha2 = { - apiVersion: 'backstage.io/v1alpha2', - 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: ApiEntityV1alpha2Default = { - apiVersion: 'backstage.io/v1alpha2', - 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/ApiEntityV1alpha2.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha2.ts deleted file mode 100644 index ce2296982f..0000000000 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha2.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* - * 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 { Entity } from '../entity/Entity'; -import defaultSchema from '../schema/kinds/API.v1alpha2.schema.json'; -import mcpServerSchema from '../schema/kinds/API.v1alpha2.mcp-server.schema.json'; -import { ajvCompiledJsonSchemaValidator } from './util'; - -/** - * Backstage API kind entity, v1alpha2. 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 ApiEntityV1alpha2 = - | ApiEntityV1alpha2Default - | McpServerApiEntityV1alpha2; - -/** - * The default (string-definition) shape for v1alpha2 API entities. Applies - * when spec.type is anything other than a declared structured subtype. - * - * @public - */ -export interface ApiEntityV1alpha2Default extends Entity { - apiVersion: 'backstage.io/v1alpha2'; - kind: 'API'; - spec: { - type: string; - lifecycle: string; - owner: string; - system?: string; - definition: string; - }; -} - -/** - * An MCP (Model Context Protocol) server represented as an API entity - * (v1alpha2, spec.type: 'mcp-server'). - * - * @public - */ -export interface McpServerApiEntityV1alpha2 extends Entity { - apiVersion: 'backstage.io/v1alpha2'; - 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 ApiEntityV1alpha2}. - * - * @public - */ -export const apiEntityV1alpha2Validator = - ajvCompiledJsonSchemaValidator(defaultSchema); - -/** - * {@link KindValidator} for the `mcp-server` specType of {@link ApiEntityV1alpha2}. - * - * @public - */ -export const mcpServerApiEntityV1alpha2Validator = - ajvCompiledJsonSchemaValidator(mcpServerSchema); - -/** - * Type guard: narrows a v1alpha2 API entity to the MCP server subtype. - * - * @public - */ -export function isMcpServerApiEntity( - entity: ApiEntityV1alpha2, -): entity is McpServerApiEntityV1alpha2 { - return entity.spec.type === 'mcp-server'; -} - -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 v1alpha2 versions of the API kind. - * - * @alpha - */ -export const apiEntityV1alpha2Model = createCatalogModelLayer({ - layerId: 'catalog.backstage.io/kind-api-v1alpha2', - builder: model => { - model.addKindVersion({ - kind: 'API', - versions: [ - { - name: 'v1alpha2', - relationFields: apiRelationFields, - schema: { jsonSchema: defaultSchema }, - }, - { - name: 'v1alpha2', - specType: 'mcp-server', - description: - 'An MCP (Model Context Protocol) server exposed as an API entity.', - relationFields: apiRelationFields, - schema: { jsonSchema: mcpServerSchema }, - }, - ], - }); - }, -}); diff --git a/packages/catalog-model/src/kinds/McpServerApiEntity.test.ts b/packages/catalog-model/src/kinds/McpServerApiEntity.test.ts new file mode 100644 index 0000000000..60498b1c3d --- /dev/null +++ b/packages/catalog-model/src/kinds/McpServerApiEntity.test.ts @@ -0,0 +1,132 @@ +/* + * 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 { + 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', () => { + expect( + isMcpServerApiEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { name: 'a' }, + spec: { + type: 'openapi', + lifecycle: 'production', + owner: 'me', + definition: 'x', + }, + }), + ).toBe(false); + }); + + it('returns false for a non-API entity', () => { + expect( + isMcpServerApiEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'c' }, + spec: { type: 'service', lifecycle: 'production', owner: 'me' }, + }), + ).toBe(false); + }); +}); diff --git a/packages/catalog-model/src/kinds/McpServerApiEntity.ts b/packages/catalog-model/src/kinds/McpServerApiEntity.ts new file mode 100644 index 0000000000..4d6ea662f7 --- /dev/null +++ b/packages/catalog-model/src/kinds/McpServerApiEntity.ts @@ -0,0 +1,108 @@ +/* + * 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 { Entity } from '../entity/Entity'; +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'). + * + * @public + */ +export interface McpServerApiEntity extends Entity { + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + 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 `mcp-server` specType of API entities. + * + * @public + */ +export const mcpServerApiEntityValidator = + ajvCompiledJsonSchemaValidator(mcpServerSchema); + +/** + * Type guard: narrows an entity to the MCP server API subtype. + * + * @public + */ +export function isMcpServerApiEntity( + entity: Entity, +): entity is McpServerApiEntity { + return ( + entity.kind === 'API' && + (entity as McpServerApiEntity).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' as const, + allowedKinds: ['Group', 'User'], + }, + { + selector: { path: 'spec.system' }, + relation: 'partOf', + defaultKind: 'System', + defaultNamespace: 'inherit' as const, + }, + ], + schema: { jsonSchema: mcpServerSchema }, + }, + ], + }); + }, +}); diff --git a/packages/catalog-model/src/kinds/apiEntityModel.test.ts b/packages/catalog-model/src/kinds/apiEntityModel.test.ts index 9189d0a29a..3df75fdd53 100644 --- a/packages/catalog-model/src/kinds/apiEntityModel.test.ts +++ b/packages/catalog-model/src/kinds/apiEntityModel.test.ts @@ -17,18 +17,18 @@ import { compileCatalogModel } from '../model/compileCatalogModel'; import { defaultCatalogEntityModel } from '../model/defaultCatalogEntityModel'; -describe('apiEntityModel v1alpha2 dispatch', () => { +describe('apiEntityModel mcp-server dispatch', () => { const model = compileCatalogModel([defaultCatalogEntityModel]); - it('routes mcp-server and non-mcp-server v1alpha2 entities to different schemas', () => { + it('routes mcp-server and non-mcp-server v1alpha1 entities to different schemas', () => { const mcp = model.getKind({ kind: 'API', - apiVersion: 'backstage.io/v1alpha2', + apiVersion: 'backstage.io/v1alpha1', spec: { type: 'mcp-server' }, }); const openapi = model.getKind({ kind: 'API', - apiVersion: 'backstage.io/v1alpha2', + apiVersion: 'backstage.io/v1alpha1', spec: { type: 'openapi' }, }); @@ -47,15 +47,15 @@ describe('apiEntityModel v1alpha2 dispatch', () => { expect(openapiSpecRequired).not.toContain('remotes'); }); - it('routes a v1alpha1 entity to the existing v1alpha1 schema', () => { - const kind = model.getKind({ + it('routes mcp-server under v1beta1 too', () => { + const mcp = model.getKind({ kind: 'API', - apiVersion: 'backstage.io/v1alpha1', - spec: { type: 'openapi' }, + apiVersion: 'backstage.io/v1beta1', + spec: { type: 'mcp-server' }, }); - expect(kind).toBeDefined(); - const required = (kind!.jsonSchema.properties as any).spec + expect(mcp).toBeDefined(); + const required = (mcp!.jsonSchema.properties as any).spec .required as string[]; - expect(required).toContain('definition'); + expect(required).toContain('remotes'); }); }); diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index 596671e327..d8b70832f0 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -20,16 +20,10 @@ export type { ApiEntityV1alpha1, } from './ApiEntityV1alpha1'; export { - apiEntityV1alpha2Validator, isMcpServerApiEntity, - mcpServerApiEntityV1alpha2Validator, -} from './ApiEntityV1alpha2'; -export type { - ApiEntityV1alpha2, - ApiEntityV1alpha2Default, - McpServerApiEntityV1alpha2, - McpServerRemote, -} from './ApiEntityV1alpha2'; + mcpServerApiEntityValidator, +} from './McpServerApiEntity'; +export type { McpServerApiEntity, McpServerRemote } from './McpServerApiEntity'; export { componentEntityV1alpha1Validator } from './ComponentEntityV1alpha1'; export type { ComponentEntityV1alpha1 as ComponentEntity, diff --git a/packages/catalog-model/src/model/defaultCatalogEntityModel.ts b/packages/catalog-model/src/model/defaultCatalogEntityModel.ts index a54b0cde54..2d0c1f7f7b 100644 --- a/packages/catalog-model/src/model/defaultCatalogEntityModel.ts +++ b/packages/catalog-model/src/model/defaultCatalogEntityModel.ts @@ -15,7 +15,7 @@ */ import { apiEntityModel } from '../kinds/ApiEntityV1alpha1'; -import { apiEntityV1alpha2Model } from '../kinds/ApiEntityV1alpha2'; +import { mcpServerApiEntityModel } from '../kinds/McpServerApiEntity'; import { componentEntityModel } from '../kinds/ComponentEntityV1alpha1'; import { domainEntityModel } from '../kinds/DomainEntityV1alpha1'; import { groupEntityModel } from '../kinds/GroupEntityV1alpha1'; @@ -37,7 +37,7 @@ export const defaultCatalogEntityModel = createCatalogModelLayer({ layerId: 'catalog.backstage.io/default-entity-model', builder: model => { model.import(apiEntityModel); - model.import(apiEntityV1alpha2Model); + model.import(mcpServerApiEntityModel); model.import(componentEntityModel); model.import(domainEntityModel); model.import(groupEntityModel); diff --git a/packages/catalog-model/src/schema/kinds/API.v1alpha2.mcp-server.schema.json b/packages/catalog-model/src/schema/kinds/API.v1alpha1.mcp-server.schema.json similarity index 95% rename from packages/catalog-model/src/schema/kinds/API.v1alpha2.mcp-server.schema.json rename to packages/catalog-model/src/schema/kinds/API.v1alpha1.mcp-server.schema.json index 7155171e83..062a7fb929 100644 --- a/packages/catalog-model/src/schema/kinds/API.v1alpha2.mcp-server.schema.json +++ b/packages/catalog-model/src/schema/kinds/API.v1alpha1.mcp-server.schema.json @@ -1,10 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ApiV1alpha2McpServer", + "$id": "ApiV1alpha1McpServer", "description": "An MCP (Model Context Protocol) server exposed as an API entity. See RFC backstage/backstage#32062.", "examples": [ { - "apiVersion": "backstage.io/v1alpha2", + "apiVersion": "backstage.io/v1alpha1", "kind": "API", "metadata": { "name": "backstage-mcp-actions", @@ -33,7 +33,7 @@ "required": ["spec"], "properties": { "apiVersion": { - "enum": ["backstage.io/v1alpha2"] + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] }, "kind": { "enum": ["API"] diff --git a/packages/catalog-model/src/schema/kinds/API.v1alpha2.schema.json b/packages/catalog-model/src/schema/kinds/API.v1alpha2.schema.json deleted file mode 100644 index 848af6627e..0000000000 --- a/packages/catalog-model/src/schema/kinds/API.v1alpha2.schema.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ApiV1alpha2", - "description": "An API describes an interface that can be exposed by a component. The API can be defined in different formats, like OpenAPI, AsyncAPI, GraphQL, gRPC, or other formats.", - "examples": [ - { - "apiVersion": "backstage.io/v1alpha2", - "kind": "API", - "metadata": { - "name": "artist-api", - "description": "Retrieve artist details", - "labels": { - "product_name": "Random value Generator" - }, - "annotations": { - "docs": "https://github.com/..../tree/develop/doc" - } - }, - "spec": { - "type": "openapi", - "lifecycle": "production", - "owner": "artist-relations-team", - "system": "artist-engagement-portal", - "definition": "openapi: \"3.0.0\"\ninfo:..." - } - } - ], - "allOf": [ - { - "$ref": "Entity" - }, - { - "type": "object", - "required": ["spec"], - "properties": { - "apiVersion": { - "enum": ["backstage.io/v1alpha2"] - }, - "kind": { - "enum": ["API"] - }, - "spec": { - "type": "object", - "required": ["type", "lifecycle", "owner", "definition"], - "properties": { - "type": { - "type": "string", - "description": "The type of the API definition.", - "examples": ["openapi", "asyncapi", "graphql", "grpc", "trpc"], - "minLength": 1 - }, - "lifecycle": { - "type": "string", - "description": "The lifecycle state of the API.", - "examples": ["experimental", "production", "deprecated"], - "minLength": 1 - }, - "owner": { - "type": "string", - "description": "An entity reference to the owner of the API.", - "examples": ["artist-relations-team", "user:john.johnson"], - "minLength": 1 - }, - "system": { - "type": "string", - "description": "An entity reference to the system that the API belongs to.", - "minLength": 1 - }, - "definition": { - "type": "string", - "description": "The definition of the API, based on the format defined by the type.", - "minLength": 1 - } - } - } - } - } - ] -} From 2a640474c252c8f8ba10ace289198c40c6cc23ca Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 18 May 2026 18:28:17 +0200 Subject: [PATCH 10/17] chore: add invalid mcp-server example for validation demo Signed-off-by: benjdlambert --- .../examples/apis/backstage-mcp-server-api.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml b/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml index 9a4a986118..88b6aa1100 100644 --- a/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml +++ b/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml @@ -13,3 +13,13 @@ spec: remotes: - type: streamable-http url: http://localhost:7007/api/mcp/v1 +--- +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: invalid-mcp-server + description: This entity is missing remotes and should fail validation +spec: + type: mcp-server + lifecycle: experimental + owner: team-a From 2003dcb4dbe57066dea2245a4b44abd12a9a3ebf Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 18 May 2026 19:06:34 +0200 Subject: [PATCH 11/17] refactor(catalog-model): move mcp-server model to opt-in backend module Moves the mcp-server specType registration out of the default catalog entity model into a separate backend module following the same pattern as the AiResource module. Types and validators are now alpha exports. Signed-off-by: benjdlambert --- .changeset/mcp-server-model-module.md | 5 ++ packages/catalog-model/report-alpha.api.md | 33 ++++++++++++ packages/catalog-model/report.api.md | 30 ----------- .../src/kinds/McpServerApiEntity.ts | 8 +-- .../src/kinds/apiEntityModel.test.ts | 6 ++- packages/catalog-model/src/kinds/index.ts | 5 -- .../src/model/defaultCatalogEntityModel.ts | 2 - .../.eslintrc.js | 1 + .../catalog-info.yaml | 10 ++++ .../package.json | 53 +++++++++++++++++++ .../report.api.md | 11 ++++ .../src/index.ts | 23 ++++++++ .../src/module.ts | 44 +++++++++++++++ yarn.lock | 12 +++++ 14 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 .changeset/mcp-server-model-module.md create mode 100644 plugins/catalog-backend-module-mcp-server-model/.eslintrc.js create mode 100644 plugins/catalog-backend-module-mcp-server-model/catalog-info.yaml create mode 100644 plugins/catalog-backend-module-mcp-server-model/package.json create mode 100644 plugins/catalog-backend-module-mcp-server-model/report.api.md create mode 100644 plugins/catalog-backend-module-mcp-server-model/src/index.ts create mode 100644 plugins/catalog-backend-module-mcp-server-model/src/module.ts diff --git a/.changeset/mcp-server-model-module.md b/.changeset/mcp-server-model-module.md new file mode 100644 index 0000000000..273e03d142 --- /dev/null +++ b/.changeset/mcp-server-model-module.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-mcp-server-model': patch +--- + +New backend module that registers support for the `mcp-server` API spec type in the catalog. Install with `backend.add(import('@backstage/plugin-catalog-backend-module-mcp-server-model'))`. diff --git a/packages/catalog-model/report-alpha.api.md b/packages/catalog-model/report-alpha.api.md index b2861ea5c9..c00f6ac675 100644 --- a/packages/catalog-model/report-alpha.api.md +++ b/packages/catalog-model/report-alpha.api.md @@ -491,6 +491,11 @@ export const isAiResourceEntity: ( entity: Entity, ) => entity is AiResourceEntityV1alpha1; +// @alpha +export function isMcpServerApiEntity( + entity: Entity, +): entity is McpServerApiEntity; + // @alpha export const isSkillAiResourceEntity: ( entity: Entity, @@ -501,6 +506,34 @@ export type KindValidator = { check(entity: Entity): Promise; }; +// @alpha +export interface McpServerApiEntity extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + // (undocumented) + kind: 'API'; + // (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 SkillAiResourceEntityV1alpha1 extends Entity { // (undocumented) diff --git a/packages/catalog-model/report.api.md b/packages/catalog-model/report.api.md index be42c33e7c..ec03ce41e1 100644 --- a/packages/catalog-model/report.api.md +++ b/packages/catalog-model/report.api.md @@ -273,11 +273,6 @@ export function isLocationEntity( entity: Entity, ): entity is LocationEntityV1alpha1; -// @public -export function isMcpServerApiEntity( - entity: Entity, -): entity is McpServerApiEntity; - // @public (undocumented) export function isResourceEntity( entity: Entity, @@ -337,31 +332,6 @@ export const locationEntityV1alpha1Validator: KindValidator; // @public export function makeValidator(overrides?: Partial): Validators; -// @public -export interface McpServerApiEntity extends Entity { - // (undocumented) - apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; - // (undocumented) - kind: 'API'; - // (undocumented) - spec: { - type: 'mcp-server'; - lifecycle: string; - owner: string; - system?: string; - remotes: McpServerRemote[]; - }; -} - -// @public -export const mcpServerApiEntityValidator: KindValidator; - -// @public -export type McpServerRemote = { - type: string; - url: string; -}; - // @public export class NoForeignRootFieldsEntityPolicy implements EntityPolicy { constructor(knownFields?: string[]); diff --git a/packages/catalog-model/src/kinds/McpServerApiEntity.ts b/packages/catalog-model/src/kinds/McpServerApiEntity.ts index 4d6ea662f7..a81de0cc41 100644 --- a/packages/catalog-model/src/kinds/McpServerApiEntity.ts +++ b/packages/catalog-model/src/kinds/McpServerApiEntity.ts @@ -23,7 +23,7 @@ import { ajvCompiledJsonSchemaValidator } from './util'; * An MCP (Model Context Protocol) server represented as an API entity * (spec.type: 'mcp-server'). * - * @public + * @alpha */ export interface McpServerApiEntity extends Entity { apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; @@ -40,7 +40,7 @@ export interface McpServerApiEntity extends Entity { /** * A transport endpoint for an MCP server. * - * @public + * @alpha */ export type McpServerRemote = { type: string; @@ -50,7 +50,7 @@ export type McpServerRemote = { /** * {@link KindValidator} for the `mcp-server` specType of API entities. * - * @public + * @alpha */ export const mcpServerApiEntityValidator = ajvCompiledJsonSchemaValidator(mcpServerSchema); @@ -58,7 +58,7 @@ export const mcpServerApiEntityValidator = /** * Type guard: narrows an entity to the MCP server API subtype. * - * @public + * @alpha */ export function isMcpServerApiEntity( entity: Entity, diff --git a/packages/catalog-model/src/kinds/apiEntityModel.test.ts b/packages/catalog-model/src/kinds/apiEntityModel.test.ts index 3df75fdd53..09373490fd 100644 --- a/packages/catalog-model/src/kinds/apiEntityModel.test.ts +++ b/packages/catalog-model/src/kinds/apiEntityModel.test.ts @@ -16,9 +16,13 @@ import { compileCatalogModel } from '../model/compileCatalogModel'; import { defaultCatalogEntityModel } from '../model/defaultCatalogEntityModel'; +import { mcpServerApiEntityModel } from './McpServerApiEntity'; describe('apiEntityModel mcp-server dispatch', () => { - const model = compileCatalogModel([defaultCatalogEntityModel]); + const model = compileCatalogModel([ + defaultCatalogEntityModel, + mcpServerApiEntityModel, + ]); it('routes mcp-server and non-mcp-server v1alpha1 entities to different schemas', () => { const mcp = model.getKind({ diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index d8b70832f0..674e4eef30 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -19,11 +19,6 @@ export type { ApiEntityV1alpha1 as ApiEntity, ApiEntityV1alpha1, } from './ApiEntityV1alpha1'; -export { - isMcpServerApiEntity, - mcpServerApiEntityValidator, -} from './McpServerApiEntity'; -export type { McpServerApiEntity, McpServerRemote } from './McpServerApiEntity'; export { componentEntityV1alpha1Validator } from './ComponentEntityV1alpha1'; export type { ComponentEntityV1alpha1 as ComponentEntity, diff --git a/packages/catalog-model/src/model/defaultCatalogEntityModel.ts b/packages/catalog-model/src/model/defaultCatalogEntityModel.ts index 2d0c1f7f7b..f997a78707 100644 --- a/packages/catalog-model/src/model/defaultCatalogEntityModel.ts +++ b/packages/catalog-model/src/model/defaultCatalogEntityModel.ts @@ -15,7 +15,6 @@ */ import { apiEntityModel } from '../kinds/ApiEntityV1alpha1'; -import { mcpServerApiEntityModel } from '../kinds/McpServerApiEntity'; import { componentEntityModel } from '../kinds/ComponentEntityV1alpha1'; import { domainEntityModel } from '../kinds/DomainEntityV1alpha1'; import { groupEntityModel } from '../kinds/GroupEntityV1alpha1'; @@ -37,7 +36,6 @@ export const defaultCatalogEntityModel = createCatalogModelLayer({ layerId: 'catalog.backstage.io/default-entity-model', builder: model => { model.import(apiEntityModel); - model.import(mcpServerApiEntityModel); model.import(componentEntityModel); model.import(domainEntityModel); model.import(groupEntityModel); diff --git a/plugins/catalog-backend-module-mcp-server-model/.eslintrc.js b/plugins/catalog-backend-module-mcp-server-model/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/catalog-backend-module-mcp-server-model/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/catalog-backend-module-mcp-server-model/catalog-info.yaml b/plugins/catalog-backend-module-mcp-server-model/catalog-info.yaml new file mode 100644 index 0000000000..5ea5697284 --- /dev/null +++ b/plugins/catalog-backend-module-mcp-server-model/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-catalog-backend-module-mcp-server-model + title: '@backstage/plugin-catalog-backend-module-mcp-server-model' + description: Adds support for the mcp-server API spec type to the catalog backend plugin. +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers diff --git a/plugins/catalog-backend-module-mcp-server-model/package.json b/plugins/catalog-backend-module-mcp-server-model/package.json new file mode 100644 index 0000000000..d074f7d488 --- /dev/null +++ b/plugins/catalog-backend-module-mcp-server-model/package.json @@ -0,0 +1,53 @@ +{ + "name": "@backstage/plugin-catalog-backend-module-mcp-server-model", + "version": "0.1.0", + "description": "Adds support for the mcp-server API spec type to the catalog backend plugin.", + "backstage": { + "role": "backend-plugin-module", + "pluginId": "catalog", + "pluginPackage": "@backstage/plugin-catalog-backend" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/catalog-backend-module-mcp-server-model" + }, + "license": "Apache-2.0", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "typesVersions": { + "*": { + "package.json": [ + "package.json" + ] + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/plugin-catalog-node": "workspace:^" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^" + } +} diff --git a/plugins/catalog-backend-module-mcp-server-model/report.api.md b/plugins/catalog-backend-module-mcp-server-model/report.api.md new file mode 100644 index 0000000000..a3f872c435 --- /dev/null +++ b/plugins/catalog-backend-module-mcp-server-model/report.api.md @@ -0,0 +1,11 @@ +## API Report File for "@backstage/plugin-catalog-backend-module-mcp-server-model" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @public +const catalogModuleMcpServerModel: BackendFeature; +export default catalogModuleMcpServerModel; +``` diff --git a/plugins/catalog-backend-module-mcp-server-model/src/index.ts b/plugins/catalog-backend-module-mcp-server-model/src/index.ts new file mode 100644 index 0000000000..2f3e927e16 --- /dev/null +++ b/plugins/catalog-backend-module-mcp-server-model/src/index.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * Adds support for the mcp-server API spec type to the catalog backend plugin. + * + * @packageDocumentation + */ + +export { catalogModuleMcpServerModel as default } from './module'; diff --git a/plugins/catalog-backend-module-mcp-server-model/src/module.ts b/plugins/catalog-backend-module-mcp-server-model/src/module.ts new file mode 100644 index 0000000000..0a9c1eca00 --- /dev/null +++ b/plugins/catalog-backend-module-mcp-server-model/src/module.ts @@ -0,0 +1,44 @@ +/* + * 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 { createBackendModule } from '@backstage/backend-plugin-api'; +import { + CatalogModelSources, + mcpServerApiEntityModel, +} from '@backstage/catalog-model/alpha'; +import { catalogModelExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; + +/** + * Registers support for the mcp-server API spec type in the catalog. + * + * @public + */ +export const catalogModuleMcpServerModel = createBackendModule({ + pluginId: 'catalog', + moduleId: 'mcp-server-model', + register(reg) { + reg.registerInit({ + deps: { + model: catalogModelExtensionPoint, + }, + async init({ model }) { + model.addModelSource( + CatalogModelSources.static([mcpServerApiEntityModel]), + ); + }, + }); + }, +}); diff --git a/yarn.lock b/yarn.lock index 6655516238..5df632d1bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5068,6 +5068,18 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-catalog-backend-module-mcp-server-model@workspace:plugins/catalog-backend-module-mcp-server-model": + version: 0.0.0-use.local + resolution: "@backstage/plugin-catalog-backend-module-mcp-server-model@workspace:plugins/catalog-backend-module-mcp-server-model" + dependencies: + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/plugin-catalog-node": "workspace:^" + languageName: unknown + linkType: soft + "@backstage/plugin-catalog-backend-module-msgraph-incremental@workspace:plugins/catalog-backend-module-msgraph-incremental": version: 0.0.0-use.local resolution: "@backstage/plugin-catalog-backend-module-msgraph-incremental@workspace:plugins/catalog-backend-module-msgraph-incremental" From 888955697b40b4a83a833e8bdb20ead4bd371100 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 18 May 2026 19:08:14 +0200 Subject: [PATCH 12/17] refactor: register mcp-server model in ai-model module, remove separate module Signed-off-by: benjdlambert --- .changeset/mcp-server-model-module.md | 5 -- .../src/module.ts | 6 ++- .../.eslintrc.js | 1 - .../catalog-info.yaml | 10 ---- .../package.json | 53 ------------------- .../report.api.md | 11 ---- .../src/index.ts | 23 -------- .../src/module.ts | 44 --------------- yarn.lock | 12 ----- 9 files changed, 5 insertions(+), 160 deletions(-) delete mode 100644 .changeset/mcp-server-model-module.md delete mode 100644 plugins/catalog-backend-module-mcp-server-model/.eslintrc.js delete mode 100644 plugins/catalog-backend-module-mcp-server-model/catalog-info.yaml delete mode 100644 plugins/catalog-backend-module-mcp-server-model/package.json delete mode 100644 plugins/catalog-backend-module-mcp-server-model/report.api.md delete mode 100644 plugins/catalog-backend-module-mcp-server-model/src/index.ts delete mode 100644 plugins/catalog-backend-module-mcp-server-model/src/module.ts diff --git a/.changeset/mcp-server-model-module.md b/.changeset/mcp-server-model-module.md deleted file mode 100644 index 273e03d142..0000000000 --- a/.changeset/mcp-server-model-module.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend-module-mcp-server-model': patch ---- - -New backend module that registers support for the `mcp-server` API spec type in the catalog. Install with `backend.add(import('@backstage/plugin-catalog-backend-module-mcp-server-model'))`. diff --git a/plugins/catalog-backend-module-ai-model/src/module.ts b/plugins/catalog-backend-module-ai-model/src/module.ts index f2807a1cab..0a69a8a6de 100644 --- a/plugins/catalog-backend-module-ai-model/src/module.ts +++ b/plugins/catalog-backend-module-ai-model/src/module.ts @@ -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, + ]), ); }, }); diff --git a/plugins/catalog-backend-module-mcp-server-model/.eslintrc.js b/plugins/catalog-backend-module-mcp-server-model/.eslintrc.js deleted file mode 100644 index e2a53a6ad2..0000000000 --- a/plugins/catalog-backend-module-mcp-server-model/.eslintrc.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/catalog-backend-module-mcp-server-model/catalog-info.yaml b/plugins/catalog-backend-module-mcp-server-model/catalog-info.yaml deleted file mode 100644 index 5ea5697284..0000000000 --- a/plugins/catalog-backend-module-mcp-server-model/catalog-info.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: backstage-plugin-catalog-backend-module-mcp-server-model - title: '@backstage/plugin-catalog-backend-module-mcp-server-model' - description: Adds support for the mcp-server API spec type to the catalog backend plugin. -spec: - lifecycle: experimental - type: backstage-backend-plugin-module - owner: maintainers diff --git a/plugins/catalog-backend-module-mcp-server-model/package.json b/plugins/catalog-backend-module-mcp-server-model/package.json deleted file mode 100644 index d074f7d488..0000000000 --- a/plugins/catalog-backend-module-mcp-server-model/package.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "@backstage/plugin-catalog-backend-module-mcp-server-model", - "version": "0.1.0", - "description": "Adds support for the mcp-server API spec type to the catalog backend plugin.", - "backstage": { - "role": "backend-plugin-module", - "pluginId": "catalog", - "pluginPackage": "@backstage/plugin-catalog-backend" - }, - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "https://github.com/backstage/backstage", - "directory": "plugins/catalog-backend-module-mcp-server-model" - }, - "license": "Apache-2.0", - "exports": { - ".": "./src/index.ts", - "./package.json": "./package.json" - }, - "main": "src/index.ts", - "types": "src/index.ts", - "typesVersions": { - "*": { - "package.json": [ - "package.json" - ] - } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "backstage-cli package build", - "clean": "backstage-cli package clean", - "lint": "backstage-cli package lint", - "prepack": "backstage-cli package prepack", - "postpack": "backstage-cli package postpack", - "start": "backstage-cli package start", - "test": "backstage-cli package test" - }, - "dependencies": { - "@backstage/backend-plugin-api": "workspace:^", - "@backstage/catalog-model": "workspace:^", - "@backstage/plugin-catalog-node": "workspace:^" - }, - "devDependencies": { - "@backstage/backend-test-utils": "workspace:^", - "@backstage/cli": "workspace:^" - } -} diff --git a/plugins/catalog-backend-module-mcp-server-model/report.api.md b/plugins/catalog-backend-module-mcp-server-model/report.api.md deleted file mode 100644 index a3f872c435..0000000000 --- a/plugins/catalog-backend-module-mcp-server-model/report.api.md +++ /dev/null @@ -1,11 +0,0 @@ -## API Report File for "@backstage/plugin-catalog-backend-module-mcp-server-model" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts -import { BackendFeature } from '@backstage/backend-plugin-api'; - -// @public -const catalogModuleMcpServerModel: BackendFeature; -export default catalogModuleMcpServerModel; -``` diff --git a/plugins/catalog-backend-module-mcp-server-model/src/index.ts b/plugins/catalog-backend-module-mcp-server-model/src/index.ts deleted file mode 100644 index 2f3e927e16..0000000000 --- a/plugins/catalog-backend-module-mcp-server-model/src/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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. - */ - -/** - * Adds support for the mcp-server API spec type to the catalog backend plugin. - * - * @packageDocumentation - */ - -export { catalogModuleMcpServerModel as default } from './module'; diff --git a/plugins/catalog-backend-module-mcp-server-model/src/module.ts b/plugins/catalog-backend-module-mcp-server-model/src/module.ts deleted file mode 100644 index 0a9c1eca00..0000000000 --- a/plugins/catalog-backend-module-mcp-server-model/src/module.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 { createBackendModule } from '@backstage/backend-plugin-api'; -import { - CatalogModelSources, - mcpServerApiEntityModel, -} from '@backstage/catalog-model/alpha'; -import { catalogModelExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; - -/** - * Registers support for the mcp-server API spec type in the catalog. - * - * @public - */ -export const catalogModuleMcpServerModel = createBackendModule({ - pluginId: 'catalog', - moduleId: 'mcp-server-model', - register(reg) { - reg.registerInit({ - deps: { - model: catalogModelExtensionPoint, - }, - async init({ model }) { - model.addModelSource( - CatalogModelSources.static([mcpServerApiEntityModel]), - ); - }, - }); - }, -}); diff --git a/yarn.lock b/yarn.lock index 5df632d1bf..6655516238 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5068,18 +5068,6 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-catalog-backend-module-mcp-server-model@workspace:plugins/catalog-backend-module-mcp-server-model": - version: 0.0.0-use.local - resolution: "@backstage/plugin-catalog-backend-module-mcp-server-model@workspace:plugins/catalog-backend-module-mcp-server-model" - dependencies: - "@backstage/backend-plugin-api": "workspace:^" - "@backstage/backend-test-utils": "workspace:^" - "@backstage/catalog-model": "workspace:^" - "@backstage/cli": "workspace:^" - "@backstage/plugin-catalog-node": "workspace:^" - languageName: unknown - linkType: soft - "@backstage/plugin-catalog-backend-module-msgraph-incremental@workspace:plugins/catalog-backend-module-msgraph-incremental": version: 0.0.0-use.local resolution: "@backstage/plugin-catalog-backend-module-msgraph-incremental@workspace:plugins/catalog-backend-module-msgraph-incremental" From a674ec354d50d8c79576f7536aad85ab6e940ab9 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 19 May 2026 08:06:17 +0200 Subject: [PATCH 13/17] refactor: type McpServerApiEntity against ApiEntityV1alpha1 Signed-off-by: benjdlambert --- packages/catalog-model/report-alpha.api.md | 26 ++++++++++--- .../src/kinds/McpServerApiEntity.test.ts | 37 +++++++------------ .../src/kinds/McpServerApiEntity.ts | 13 ++----- .../src/kinds/apiEntityModel.test.ts | 16 +++++--- 4 files changed, 47 insertions(+), 45 deletions(-) diff --git a/packages/catalog-model/report-alpha.api.md b/packages/catalog-model/report-alpha.api.md index c00f6ac675..5b45d80adb 100644 --- a/packages/catalog-model/report-alpha.api.md +++ b/packages/catalog-model/report-alpha.api.md @@ -39,6 +39,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< { @@ -493,7 +511,7 @@ export const isAiResourceEntity: ( // @alpha export function isMcpServerApiEntity( - entity: Entity, + entity: ApiEntityV1alpha1 | McpServerApiEntity, ): entity is McpServerApiEntity; // @alpha @@ -507,11 +525,7 @@ export type KindValidator = { }; // @alpha -export interface McpServerApiEntity extends Entity { - // (undocumented) - apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; - // (undocumented) - kind: 'API'; +export interface McpServerApiEntity extends Omit { // (undocumented) spec: { type: 'mcp-server'; diff --git a/packages/catalog-model/src/kinds/McpServerApiEntity.test.ts b/packages/catalog-model/src/kinds/McpServerApiEntity.test.ts index 60498b1c3d..a203a462da 100644 --- a/packages/catalog-model/src/kinds/McpServerApiEntity.test.ts +++ b/packages/catalog-model/src/kinds/McpServerApiEntity.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { ApiEntityV1alpha1 } from './ApiEntityV1alpha1'; import { McpServerApiEntity, mcpServerApiEntityValidator, @@ -104,29 +105,17 @@ describe('isMcpServerApiEntity', () => { }); it('returns false for a non-mcp-server API entity', () => { - expect( - isMcpServerApiEntity({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'API', - metadata: { name: 'a' }, - spec: { - type: 'openapi', - lifecycle: 'production', - owner: 'me', - definition: 'x', - }, - }), - ).toBe(false); - }); - - it('returns false for a non-API entity', () => { - expect( - isMcpServerApiEntity({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { name: 'c' }, - spec: { type: 'service', lifecycle: 'production', owner: 'me' }, - }), - ).toBe(false); + 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); }); }); diff --git a/packages/catalog-model/src/kinds/McpServerApiEntity.ts b/packages/catalog-model/src/kinds/McpServerApiEntity.ts index a81de0cc41..8ddfad472e 100644 --- a/packages/catalog-model/src/kinds/McpServerApiEntity.ts +++ b/packages/catalog-model/src/kinds/McpServerApiEntity.ts @@ -15,7 +15,7 @@ */ import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; -import type { Entity } from '../entity/Entity'; +import type { ApiEntityV1alpha1 } from './ApiEntityV1alpha1'; import mcpServerSchema from '../schema/kinds/API.v1alpha1.mcp-server.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; @@ -25,9 +25,7 @@ import { ajvCompiledJsonSchemaValidator } from './util'; * * @alpha */ -export interface McpServerApiEntity extends Entity { - apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; - kind: 'API'; +export interface McpServerApiEntity extends Omit { spec: { type: 'mcp-server'; lifecycle: string; @@ -61,12 +59,9 @@ export const mcpServerApiEntityValidator = * @alpha */ export function isMcpServerApiEntity( - entity: Entity, + entity: ApiEntityV1alpha1 | McpServerApiEntity, ): entity is McpServerApiEntity { - return ( - entity.kind === 'API' && - (entity as McpServerApiEntity).spec?.type === 'mcp-server' - ); + 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 index 09373490fd..fbea61ebb5 100644 --- a/packages/catalog-model/src/kinds/apiEntityModel.test.ts +++ b/packages/catalog-model/src/kinds/apiEntityModel.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import lodash from 'lodash'; import { compileCatalogModel } from '../model/compileCatalogModel'; import { defaultCatalogEntityModel } from '../model/defaultCatalogEntityModel'; import { mcpServerApiEntityModel } from './McpServerApiEntity'; @@ -40,10 +41,14 @@ describe('apiEntityModel mcp-server dispatch', () => { expect(openapi).toBeDefined(); expect(mcp!.description).not.toBe(openapi!.description); - const mcpSpecRequired = (mcp!.jsonSchema.properties as any).spec - .required as string[]; - const openapiSpecRequired = (openapi!.jsonSchema.properties as any).spec - .required as string[]; + 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'); @@ -58,8 +63,7 @@ describe('apiEntityModel mcp-server dispatch', () => { spec: { type: 'mcp-server' }, }); expect(mcp).toBeDefined(); - const required = (mcp!.jsonSchema.properties as any).spec - .required as string[]; + const required = lodash.get(mcp, 'jsonSchema.properties.spec.required'); expect(required).toContain('remotes'); }); }); From be714769439ce221d934f8c4c13f990a7bb620bc Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 19 May 2026 11:30:53 +0200 Subject: [PATCH 14/17] chore: rename changeset, remove invalid example entity Signed-off-by: benjdlambert --- ...i-entity-v1alpha2.md => api-mcp-server-spectype.md} | 0 .../examples/apis/backstage-mcp-server-api.yaml | 10 ---------- 2 files changed, 10 deletions(-) rename .changeset/{api-entity-v1alpha2.md => api-mcp-server-spectype.md} (100%) diff --git a/.changeset/api-entity-v1alpha2.md b/.changeset/api-mcp-server-spectype.md similarity index 100% rename from .changeset/api-entity-v1alpha2.md rename to .changeset/api-mcp-server-spectype.md diff --git a/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml b/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml index 88b6aa1100..9a4a986118 100644 --- a/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml +++ b/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml @@ -13,13 +13,3 @@ spec: remotes: - type: streamable-http url: http://localhost:7007/api/mcp/v1 ---- -apiVersion: backstage.io/v1alpha1 -kind: API -metadata: - name: invalid-mcp-server - description: This entity is missing remotes and should fail validation -spec: - type: mcp-server - lifecycle: experimental - owner: team-a From 6d98fcefe0a47857de488d076563f43eb2981995 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 19 May 2026 11:34:32 +0200 Subject: [PATCH 15/17] chore: regenerate API reports Signed-off-by: benjdlambert --- packages/catalog-model/report-alpha.api.md | 29 +++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/catalog-model/report-alpha.api.md b/packages/catalog-model/report-alpha.api.md index 5b45d80adb..0207ae9f73 100644 --- a/packages/catalog-model/report-alpha.api.md +++ b/packages/catalog-model/report-alpha.api.md @@ -14,7 +14,8 @@ export const aiResourceEntityModel: CatalogModelLayer; // @alpha export type AiResourceEntityV1alpha1 = | AiResourceEntityV1alpha1Default - | SkillAiResourceEntityV1alpha1; + | SkillAiResourceEntityV1alpha1 + | RuleAiResourceEntityV1alpha1; // @alpha export interface AiResourceEntityV1alpha1Default extends Entity { @@ -514,6 +515,11 @@ export function isMcpServerApiEntity( entity: ApiEntityV1alpha1 | McpServerApiEntity, ): entity is McpServerApiEntity; +// @alpha +export const isRuleAiResourceEntity: ( + entity: Entity, +) => entity is RuleAiResourceEntityV1alpha1; + // @alpha export const isSkillAiResourceEntity: ( entity: Entity, @@ -548,6 +554,27 @@ export type McpServerRemote = { url: string; }; +// @alpha +export interface RuleAiResourceEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1'; + // (undocumented) + kind: 'AiResource'; + // (undocumented) + spec: { + type: 'rule'; + lifecycle: string; + owner: string; + system?: string; + disciplines?: string[]; + category: string; + rationale: string; + }; +} + +// @alpha +export const ruleAiResourceEntityV1alpha1Validator: KindValidator; + // @alpha export interface SkillAiResourceEntityV1alpha1 extends Entity { // (undocumented) From 34e52d13e77d02b02368ea52902c1a7f0109c0bf Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 19 May 2026 13:46:54 +0200 Subject: [PATCH 16/17] chore: regenerate API reports after rebase Signed-off-by: benjdlambert --- packages/catalog-model/report-alpha.api.md | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/catalog-model/report-alpha.api.md b/packages/catalog-model/report-alpha.api.md index 0207ae9f73..fe4fb2cbc1 100644 --- a/packages/catalog-model/report-alpha.api.md +++ b/packages/catalog-model/report-alpha.api.md @@ -555,11 +555,8 @@ export type McpServerRemote = { }; // @alpha -export interface RuleAiResourceEntityV1alpha1 extends Entity { - // (undocumented) - apiVersion: 'backstage.io/v1alpha1'; - // (undocumented) - kind: 'AiResource'; +export interface RuleAiResourceEntityV1alpha1 + extends AiResourceEntityV1alpha1Default { // (undocumented) spec: { type: 'rule'; @@ -576,11 +573,8 @@ export interface RuleAiResourceEntityV1alpha1 extends Entity { export const ruleAiResourceEntityV1alpha1Validator: KindValidator; // @alpha -export interface SkillAiResourceEntityV1alpha1 extends Entity { - // (undocumented) - apiVersion: 'backstage.io/v1alpha1'; - // (undocumented) - kind: 'AiResource'; +export interface SkillAiResourceEntityV1alpha1 + extends AiResourceEntityV1alpha1Default { // (undocumented) spec: { type: 'skill'; From 3bf3c9d878d81d27a0d7deac1789fb7cbe80ab17 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 19 May 2026 14:15:44 +0200 Subject: [PATCH 17/17] chore: remove unnecessary as const assertions Signed-off-by: benjdlambert --- packages/catalog-model/src/kinds/McpServerApiEntity.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/catalog-model/src/kinds/McpServerApiEntity.ts b/packages/catalog-model/src/kinds/McpServerApiEntity.ts index 8ddfad472e..1f5813ef4f 100644 --- a/packages/catalog-model/src/kinds/McpServerApiEntity.ts +++ b/packages/catalog-model/src/kinds/McpServerApiEntity.ts @@ -85,14 +85,14 @@ export const mcpServerApiEntityModel = createCatalogModelLayer({ selector: { path: 'spec.owner' }, relation: 'ownedBy', defaultKind: 'Group', - defaultNamespace: 'inherit' as const, + defaultNamespace: 'inherit', allowedKinds: ['Group', 'User'], }, { selector: { path: 'spec.system' }, relation: 'partOf', defaultKind: 'System', - defaultNamespace: 'inherit' as const, + defaultNamespace: 'inherit', }, ], schema: { jsonSchema: mcpServerSchema },