Merge pull request #34016 from backstage/benjdlambert/api-v1beta2-mcp-server

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