Skip to content

Commit 7746b32

Browse files
committed
initial wip on tool creation and registration
1 parent 27a83fc commit 7746b32

31 files changed

+4134
-1
lines changed

.editorconfig

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[*]
2+
charset = utf-8
3+
end_of_line = lf
4+
indent_size = 2
5+
indent_style = space
6+
insert_final_newline = true
7+
max_line_length = 80
8+
trim_trailing_whitespace = true

.prettierrc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
trailingComma: 'es5'
2+
tabWidth: 2
3+
semi: true
4+
singleQuote: true

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
# backstage-mcp-server
1+
# backstage-mcp-server

package.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"dependencies": {
3+
"@backstage/catalog-client": "^1.9.1",
4+
"@backstage/catalog-model": "^1.7.3",
5+
"@modelcontextprotocol/sdk": "^1.10.2",
6+
"axios": "^1.9.0",
7+
"reflect-metadata": "^0.2.2",
8+
"yarn": "^1.22.22",
9+
"zod": "^3.22.2"
10+
},
11+
"devDependencies": {
12+
"@types/jest": "^29.5.4",
13+
"eslint": "^8.57.0",
14+
"jest": "^29.7.0",
15+
"prettier": "^3.5.3",
16+
"ts-jest": "^29.1.1",
17+
"typescript": "^5.3.3"
18+
},
19+
"main": "dist/server.js",
20+
"name": "mcp-backstage-server",
21+
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
22+
"scripts": {
23+
"build": "tsc",
24+
"lint": "eslint . --ext .ts",
25+
"lint:fix": "npx prettier . --write",
26+
"start": "node dist/server.js",
27+
"test": "jest --coverage"
28+
},
29+
"version": "1.0.0"
30+
}

src/api/backstage-catalog-api.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import axios, { AxiosInstance } from 'axios';
2+
import {
3+
AddLocationRequest,
4+
AddLocationResponse,
5+
CatalogApi,
6+
CatalogRequestOptions,
7+
GetEntitiesByRefsRequest,
8+
GetEntitiesByRefsResponse,
9+
GetEntitiesRequest,
10+
GetEntitiesResponse,
11+
GetEntityAncestorsRequest,
12+
GetEntityAncestorsResponse,
13+
GetEntityFacetsRequest,
14+
GetEntityFacetsResponse,
15+
Location,
16+
QueryEntitiesRequest,
17+
QueryEntitiesResponse,
18+
ValidateEntityResponse,
19+
} from '@backstage/catalog-client';
20+
import {
21+
CompoundEntityRef,
22+
Entity,
23+
stringifyEntityRef,
24+
} from '@backstage/catalog-model';
25+
import { isString } from '../utils/guards';
26+
27+
interface BackstageCatalogApiOptions {
28+
baseUrl: string;
29+
token?: string;
30+
}
31+
32+
export class BackstageCatalogApi implements CatalogApi {
33+
private readonly client: AxiosInstance;
34+
35+
constructor({ baseUrl, token }: BackstageCatalogApiOptions) {
36+
this.client = axios.create({
37+
baseURL: `${baseUrl.replace(/\/$/, '')}/v1`,
38+
headers: token ? { Authorization: `Bearer ${token}` } : {},
39+
});
40+
}
41+
42+
async getEntities(
43+
request?: GetEntitiesRequest,
44+
_options?: CatalogRequestOptions
45+
): Promise<GetEntitiesResponse> {
46+
const { data } = await this.client.get<GetEntitiesResponse>('/entities', {
47+
params: request,
48+
});
49+
return data;
50+
}
51+
52+
async getEntitiesByRefs(
53+
request: GetEntitiesByRefsRequest,
54+
_options?: CatalogRequestOptions
55+
): Promise<GetEntitiesByRefsResponse> {
56+
const { entityRefs } = request;
57+
const { data } = await this.client.post<GetEntitiesByRefsResponse>(
58+
'/entities/by-refs',
59+
{ entityRefs }
60+
);
61+
return data;
62+
}
63+
64+
async queryEntities(
65+
request?: QueryEntitiesRequest,
66+
_options?: CatalogRequestOptions
67+
): Promise<QueryEntitiesResponse> {
68+
const { data } = await this.client.post<QueryEntitiesResponse>(
69+
'/entities/query',
70+
request
71+
);
72+
return data;
73+
}
74+
75+
async getEntityAncestors(
76+
request: GetEntityAncestorsRequest,
77+
_options?: CatalogRequestOptions
78+
): Promise<GetEntityAncestorsResponse> {
79+
const { entityRef } = request;
80+
const { data } = await this.client.get<GetEntityAncestorsResponse>(
81+
`/entities/by-ref/${encodeURIComponent(entityRef)}/ancestry`
82+
);
83+
return data;
84+
}
85+
86+
async getEntityByRef(
87+
entityRef: string | CompoundEntityRef,
88+
_options?: CatalogRequestOptions
89+
): Promise<Entity | undefined> {
90+
const refString = isString(entityRef)
91+
? entityRef
92+
: this.formatCompoundEntityRef(entityRef);
93+
try {
94+
const { data } = await this.client.get<Entity>(
95+
`/entities/by-ref/${encodeURIComponent(refString)}`
96+
);
97+
return data;
98+
} catch (error) {
99+
if (axios.isAxiosError(error) && error.response?.status === 404)
100+
return undefined;
101+
throw error;
102+
}
103+
}
104+
105+
async removeEntityByUid(
106+
uid: string,
107+
_options?: CatalogRequestOptions
108+
): Promise<void> {
109+
await this.client.delete(`/entities/by-uid/${encodeURIComponent(uid)}`);
110+
}
111+
112+
async refreshEntity(
113+
entityRef: string,
114+
_options?: CatalogRequestOptions
115+
): Promise<void> {
116+
await this.client.post(`/refresh`, { entityRef });
117+
}
118+
119+
async getEntityFacets(
120+
request: GetEntityFacetsRequest,
121+
_options?: CatalogRequestOptions
122+
): Promise<GetEntityFacetsResponse> {
123+
const { data } = await this.client.post<GetEntityFacetsResponse>(
124+
'/entities/facets',
125+
request
126+
);
127+
return data;
128+
}
129+
130+
async getLocationById(
131+
id: string,
132+
_options?: CatalogRequestOptions
133+
): Promise<Location | undefined> {
134+
try {
135+
const { data } = await this.client.get<Location>(
136+
`/locations/${encodeURIComponent(id)}`
137+
);
138+
return data;
139+
} catch (error) {
140+
if (axios.isAxiosError(error) && error.response?.status === 404)
141+
return undefined;
142+
throw error;
143+
}
144+
}
145+
146+
async getLocationByRef(
147+
locationRef: string,
148+
_options?: CatalogRequestOptions
149+
): Promise<Location | undefined> {
150+
try {
151+
const { data } = await this.client.get<Location>(
152+
`/locations/by-ref/${encodeURIComponent(locationRef)}`
153+
);
154+
return data;
155+
} catch (error) {
156+
if (axios.isAxiosError(error) && error.response?.status === 404)
157+
return undefined;
158+
throw error;
159+
}
160+
}
161+
162+
async addLocation(
163+
location: AddLocationRequest,
164+
_options?: CatalogRequestOptions
165+
): Promise<AddLocationResponse> {
166+
const { data } = await this.client.post<AddLocationResponse>(
167+
'/locations',
168+
location
169+
);
170+
return data;
171+
}
172+
173+
async removeLocationById(
174+
id: string,
175+
_options?: CatalogRequestOptions
176+
): Promise<void> {
177+
await this.client.delete(`/locations/${encodeURIComponent(id)}`);
178+
}
179+
180+
async getLocationByEntity(
181+
entityRef: string | CompoundEntityRef,
182+
_options?: CatalogRequestOptions
183+
): Promise<Location | undefined> {
184+
const refString = isString(entityRef)
185+
? entityRef
186+
: this.formatCompoundEntityRef(entityRef);
187+
try {
188+
const { data } = await this.client.get<Location>(
189+
`/locations/by-entity/${encodeURIComponent(refString)}`
190+
);
191+
return data;
192+
} catch (error) {
193+
if (axios.isAxiosError(error) && error.response?.status === 404)
194+
return undefined;
195+
throw error;
196+
}
197+
}
198+
199+
async validateEntity(
200+
entity: Entity,
201+
locationRef: string,
202+
_options?: CatalogRequestOptions
203+
): Promise<ValidateEntityResponse> {
204+
const { data } = await this.client.post<ValidateEntityResponse>(
205+
'/validate-entity',
206+
{ entity, locationRef }
207+
);
208+
return data;
209+
}
210+
211+
private formatCompoundEntityRef(entityRef: CompoundEntityRef): string {
212+
return stringifyEntityRef(entityRef);
213+
}
214+
}

src/decorators/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { TOOL_METADATA_KEY, Tool } from './tool.decorator';

src/decorators/tool.decorator.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import 'reflect-metadata';
2+
import { ToolMetadata } from '../types';
3+
4+
export const TOOL_METADATA_KEY = Symbol('TOOL_METADATA');
5+
6+
export function Tool(metadata: ToolMetadata): ClassDecorator {
7+
return (target) =>
8+
Reflect.defineMetadata(TOOL_METADATA_KEY, metadata, target);
9+
}

src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { startServer } from './server';
2+
3+
(async function () {
4+
await startServer().catch((err) => {
5+
console.error('Fatal server startup error:', err);
6+
process.exit(1);
7+
});
8+
})();

src/server.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3+
import { BackstageCatalogApi } from './api/backstage-catalog-api';
4+
import { IToolRegistrationContext } from './types';
5+
import { ToolLoader } from './utils/tool-loader';
6+
7+
export async function startServer(): Promise<void> {
8+
const server = new McpServer({
9+
name: 'Backstage MCP Server',
10+
version: '1.0.0',
11+
});
12+
13+
const context: IToolRegistrationContext = {
14+
server,
15+
catalogClient: new BackstageCatalogApi({ baseUrl: '' }),
16+
};
17+
18+
const toolLoader = new ToolLoader('./tools', context);
19+
await toolLoader.registerAllTools();
20+
21+
if (process.env.NODE_ENV !== 'production') {
22+
await toolLoader.exportManifest('./tools-manifest.json');
23+
}
24+
25+
const transport = new StdioServerTransport();
26+
await server.connect(transport);
27+
}

src/tools/add_location.tool.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { z } from 'zod';
2+
3+
import { ApiStatus, IToolRegistrationContext } from '../types';
4+
import { JsonToTextResponse } from '../utils/responses';
5+
import { Tool } from '../decorators/tool.decorator';
6+
import { AddLocationRequest } from '@backstage/catalog-client';
7+
8+
const paramsSchema = z.custom<AddLocationRequest>();
9+
10+
@Tool({
11+
name: 'add_location',
12+
description: 'Create a new location in the catalog.',
13+
paramsSchema: paramsSchema,
14+
})
15+
export class AddLocationTool {
16+
static async execute(
17+
request: z.infer<typeof paramsSchema>,
18+
context: IToolRegistrationContext
19+
) {
20+
const result = await context.catalogClient.addLocation(request);
21+
return JsonToTextResponse({ status: ApiStatus.SUCCESS, data: result });
22+
}
23+
}

0 commit comments

Comments
 (0)