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 <ben@blam.sh>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
apiVersion: backstage.io/v1alpha2
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: API
|
||||
metadata:
|
||||
name: backstage-mcp-server
|
||||
|
||||
@@ -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>): 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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+3
-3
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user