diff --git a/.changeset/api-mcp-server-spectype.md b/.changeset/api-mcp-server-spectype.md new file mode 100644 index 0000000000..070ecbfcde --- /dev/null +++ b/.changeset/api-mcp-server-spectype.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': minor +--- + +Added `spec.type: 'mcp-server'` as a structured subtype of the `API` kind under `v1alpha1`/`v1beta1`. MCP server entities carry a `spec.remotes` list instead of a string `definition`, for representing Model Context Protocol servers in the catalog. See RFC [#32062](https://github.com/backstage/backstage/issues/32062). New public exports: `McpServerApiEntity`, `McpServerRemote`, `mcpServerApiEntityValidator`, and `isMcpServerApiEntity`. Also adds `addKindVersion` to `CatalogModelLayerBuilder` (alpha) so layers can add new versions or spec types to existing kinds. 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..9a4a986118 --- /dev/null +++ b/packages/catalog-model/examples/apis/backstage-mcp-server-api.yaml @@ -0,0 +1,15 @@ +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: backstage-mcp-server + description: An MCP server that exposes tools related to the Backstage ecosystem + tags: + - mcp + - ai +spec: + type: mcp-server + lifecycle: experimental + owner: team-a + remotes: + - type: streamable-http + url: http://localhost:7007/api/mcp/v1 diff --git a/packages/catalog-model/report-alpha.api.md b/packages/catalog-model/report-alpha.api.md index d1f5e4ef61..fe4fb2cbc1 100644 --- a/packages/catalog-model/report-alpha.api.md +++ b/packages/catalog-model/report-alpha.api.md @@ -40,6 +40,24 @@ export interface AlphaEntity extends Entity_2 { status?: EntityStatus; } +// @public +interface ApiEntityV1alpha1 extends Entity { + // (undocumented) + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + // (undocumented) + kind: 'API'; + // (undocumented) + spec: { + type: string; + lifecycle: string; + owner: string; + definition: string; + system?: string; + }; +} +export { ApiEntityV1alpha1 as ApiEntity }; +export { ApiEntityV1alpha1 }; + // @alpha export type AsyncCatalogModelSourceGenerator = AsyncGenerator< { @@ -70,6 +88,12 @@ export interface CatalogModel { listRelations(): CatalogModelRelationSummary[]; } +// @alpha +export interface CatalogModelAddKindVersionDefinition { + kind: string; + versions: CatalogModelKindVersionDefinition[]; +} + // @alpha export interface CatalogModelAnnotationDefinition { description: string; @@ -229,6 +253,7 @@ export interface CatalogModelLayer { export interface CatalogModelLayerBuilder { addAnnotation(annotation: CatalogModelAnnotationDefinition): void; addKind(kind: CatalogModelKindDefinition): void; + addKindVersion(definition: CatalogModelAddKindVersionDefinition): void; addLabel(label: CatalogModelLabelDefinition): void; addRelationPair(relation: CatalogModelRelationPairDefinition): void; addTag(tag: CatalogModelTagDefinition): void; @@ -485,6 +510,11 @@ export const isAiResourceEntity: ( entity: Entity, ) => entity is AiResourceEntityV1alpha1; +// @alpha +export function isMcpServerApiEntity( + entity: ApiEntityV1alpha1 | McpServerApiEntity, +): entity is McpServerApiEntity; + // @alpha export const isRuleAiResourceEntity: ( entity: Entity, @@ -500,6 +530,30 @@ export type KindValidator = { check(entity: Entity): Promise; }; +// @alpha +export interface McpServerApiEntity extends Omit { + // (undocumented) + spec: { + type: 'mcp-server'; + lifecycle: string; + owner: string; + system?: string; + remotes: McpServerRemote[]; + }; +} + +// @alpha +export const mcpServerApiEntityModel: CatalogModelLayer; + +// @alpha +export const mcpServerApiEntityValidator: KindValidator; + +// @alpha +export type McpServerRemote = { + type: string; + url: string; +}; + // @alpha export interface RuleAiResourceEntityV1alpha1 extends AiResourceEntityV1alpha1Default { 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'; diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index 565266e219..e735a9eae8 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -83,9 +83,7 @@ export const apiEntityModel = createCatalogModelLayer({ defaultNamespace: 'inherit', }, ], - schema: { - jsonSchema, - }, + schema: { jsonSchema }, }, ], }); 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..a203a462da --- /dev/null +++ b/packages/catalog-model/src/kinds/McpServerApiEntity.test.ts @@ -0,0 +1,121 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ApiEntityV1alpha1 } from './ApiEntityV1alpha1'; +import { + McpServerApiEntity, + mcpServerApiEntityValidator, + isMcpServerApiEntity, +} from './McpServerApiEntity'; + +describe('mcpServerApiEntityValidator', () => { + let entity: McpServerApiEntity; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { name: 'test-mcp' }, + spec: { + type: 'mcp-server', + lifecycle: 'experimental', + owner: 'backstage', + remotes: [ + { + type: 'streamable-http', + url: 'http://localhost:7007/api/mcp', + }, + ], + }, + }; + }); + + it('accepts a valid mcp-server entity', async () => { + await expect(mcpServerApiEntityValidator.check(entity)).resolves.toBe(true); + }); + + it('accepts v1beta1', async () => { + entity.apiVersion = 'backstage.io/v1beta1'; + await expect(mcpServerApiEntityValidator.check(entity)).resolves.toBe(true); + }); + + it('rejects wrong spec.type value', async () => { + (entity as any).spec.type = 'openapi'; + await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow( + /type/, + ); + }); + + it('rejects missing remotes', async () => { + delete (entity as any).spec.remotes; + await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow( + /remotes/, + ); + }); + + it('rejects empty remotes array', async () => { + (entity as any).spec.remotes = []; + await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow( + /remotes/, + ); + }); + + it('rejects remote missing url', async () => { + (entity as any).spec.remotes[0] = { type: 'stdio' }; + await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow( + /url/, + ); + }); + + it('rejects remote missing type', async () => { + (entity as any).spec.remotes[0] = { url: 'http://x' }; + await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow( + /type/, + ); + }); +}); + +describe('isMcpServerApiEntity', () => { + it('returns true for an mcp-server entity', () => { + const entity: McpServerApiEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { name: 'm' }, + spec: { + type: 'mcp-server', + lifecycle: 'production', + owner: 'me', + remotes: [{ type: 'stdio', url: 'cmd' }], + }, + }; + expect(isMcpServerApiEntity(entity)).toBe(true); + }); + + it('returns false for a non-mcp-server API entity', () => { + const entity: ApiEntityV1alpha1 = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { name: 'a' }, + spec: { + type: 'openapi', + lifecycle: 'production', + owner: 'me', + definition: 'x', + }, + }; + expect(isMcpServerApiEntity(entity)).toBe(false); + }); +}); diff --git a/packages/catalog-model/src/kinds/McpServerApiEntity.ts b/packages/catalog-model/src/kinds/McpServerApiEntity.ts new file mode 100644 index 0000000000..1f5813ef4f --- /dev/null +++ b/packages/catalog-model/src/kinds/McpServerApiEntity.ts @@ -0,0 +1,103 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createCatalogModelLayer } from '../model/createCatalogModelLayer'; +import type { ApiEntityV1alpha1 } from './ApiEntityV1alpha1'; +import mcpServerSchema from '../schema/kinds/API.v1alpha1.mcp-server.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; + +/** + * An MCP (Model Context Protocol) server represented as an API entity + * (spec.type: 'mcp-server'). + * + * @alpha + */ +export interface McpServerApiEntity extends Omit { + spec: { + type: 'mcp-server'; + lifecycle: string; + owner: string; + system?: string; + remotes: McpServerRemote[]; + }; +} + +/** + * A transport endpoint for an MCP server. + * + * @alpha + */ +export type McpServerRemote = { + type: string; + url: string; +}; + +/** + * {@link KindValidator} for the `mcp-server` specType of API entities. + * + * @alpha + */ +export const mcpServerApiEntityValidator = + ajvCompiledJsonSchemaValidator(mcpServerSchema); + +/** + * Type guard: narrows an entity to the MCP server API subtype. + * + * @alpha + */ +export function isMcpServerApiEntity( + entity: ApiEntityV1alpha1 | McpServerApiEntity, +): entity is McpServerApiEntity { + return entity.spec.type === 'mcp-server'; +} + +/** + * Extends the API kind with the mcp-server specType. + * + * @alpha + */ +export const mcpServerApiEntityModel = createCatalogModelLayer({ + layerId: 'catalog.backstage.io/kind-api-mcp-server', + builder: model => { + model.addKindVersion({ + kind: 'API', + versions: [ + { + name: ['v1alpha1', 'v1beta1'], + specType: 'mcp-server', + description: + 'An MCP (Model Context Protocol) server exposed as an API entity.', + relationFields: [ + { + selector: { path: 'spec.owner' }, + relation: 'ownedBy', + defaultKind: 'Group', + defaultNamespace: 'inherit', + allowedKinds: ['Group', 'User'], + }, + { + selector: { path: 'spec.system' }, + relation: 'partOf', + defaultKind: 'System', + defaultNamespace: 'inherit', + }, + ], + schema: { jsonSchema: mcpServerSchema }, + }, + ], + }); + }, +}); 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..fbea61ebb5 --- /dev/null +++ b/packages/catalog-model/src/kinds/apiEntityModel.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import lodash from 'lodash'; +import { compileCatalogModel } from '../model/compileCatalogModel'; +import { defaultCatalogEntityModel } from '../model/defaultCatalogEntityModel'; +import { mcpServerApiEntityModel } from './McpServerApiEntity'; + +describe('apiEntityModel mcp-server dispatch', () => { + const model = compileCatalogModel([ + defaultCatalogEntityModel, + mcpServerApiEntityModel, + ]); + + it('routes mcp-server and non-mcp-server v1alpha1 entities to different schemas', () => { + const mcp = model.getKind({ + kind: 'API', + apiVersion: 'backstage.io/v1alpha1', + spec: { type: 'mcp-server' }, + }); + const openapi = model.getKind({ + kind: 'API', + apiVersion: 'backstage.io/v1alpha1', + spec: { type: 'openapi' }, + }); + + expect(mcp).toBeDefined(); + expect(openapi).toBeDefined(); + expect(mcp!.description).not.toBe(openapi!.description); + + const mcpSpecRequired = lodash.get( + mcp, + 'jsonSchema.properties.spec.required', + ); + const openapiSpecRequired = lodash.get( + openapi, + 'jsonSchema.properties.spec.required', + ); + + expect(mcpSpecRequired).toContain('remotes'); + expect(mcpSpecRequired).not.toContain('definition'); + expect(openapiSpecRequired).toContain('definition'); + expect(openapiSpecRequired).not.toContain('remotes'); + }); + + it('routes mcp-server under v1beta1 too', () => { + const mcp = model.getKind({ + kind: 'API', + apiVersion: 'backstage.io/v1beta1', + spec: { type: 'mcp-server' }, + }); + expect(mcp).toBeDefined(); + const required = lodash.get(mcp, 'jsonSchema.properties.spec.required'); + expect(required).toContain('remotes'); + }); +}); 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/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'; diff --git a/packages/catalog-model/src/schema/kinds/API.v1alpha1.mcp-server.schema.json b/packages/catalog-model/src/schema/kinds/API.v1alpha1.mcp-server.schema.json new file mode 100644 index 0000000000..062a7fb929 --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/API.v1alpha1.mcp-server.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "ApiV1alpha1McpServer", + "description": "An MCP (Model Context Protocol) server exposed as an API entity. See RFC backstage/backstage#32062.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "API", + "metadata": { + "name": "backstage-mcp-actions", + "title": "Backstage MCP Server", + "description": "Exposes tools related to the Backstage Ecosystem" + }, + "spec": { + "type": "mcp-server", + "lifecycle": "experimental", + "owner": "backstage", + "remotes": [ + { + "type": "streamable-http", + "url": "http://internal.backstage.company:7007/api/mcp-actions/v1" + } + ] + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["API"] + }, + "spec": { + "type": "object", + "required": ["type", "lifecycle", "owner", "remotes"], + "properties": { + "type": { + "const": "mcp-server", + "description": "Discriminant for the MCP server specType." + }, + "lifecycle": { + "type": "string", + "description": "The lifecycle state of the MCP server.", + "examples": ["experimental", "production", "deprecated"], + "minLength": 1 + }, + "owner": { + "type": "string", + "description": "An entity reference to the owner of the MCP server.", + "examples": ["ai-platform-team", "user:john.johnson"], + "minLength": 1 + }, + "system": { + "type": "string", + "description": "An entity reference to the system that the MCP server belongs to.", + "minLength": 1 + }, + "remotes": { + "type": "array", + "minItems": 1, + "description": "Transport endpoints for the MCP server.", + "items": { + "type": "object", + "required": ["type", "url"], + "properties": { + "type": { + "type": "string", + "description": "Transport type, e.g. streamable-http, stdio, sse.", + "examples": ["streamable-http", "stdio", "sse"], + "minLength": 1 + }, + "url": { + "type": "string", + "description": "Endpoint URL for the remote. For stdio transports this may be a command string.", + "minLength": 1 + } + } + } + } + } + } + } + } + ] +} 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, + ]), ); }, });