@red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # @red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4bb6232: Add MCP Registry provider backend module that ingests MCP servers from a configured registry into the catalog as mcp-server API entities. Supports cursor pagination with full-mutation semantics, per-entry failure isolation with last-good retention, nested config under `catalog.providers.mcpRegistry.mcpRegistry` (extra instance ids warn and are ignored), optional `defaultOwner` / `defaultLifecycle` / `remotesOnly` / `latestVersion` (`?version=latest`) / `hostAllowList` / `maxEntries` soft-stop with `pageLimit` resume (re-adding last-good as degraded on later syncs until the server is refreshed successfully), schedule and page controls, identity prefix override, redirect handling with Location validation, and clearer fetch error reporting.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [4bb6232]
12
+ - @red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping@0.4.0
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # MCP Registry Provider
2
+
3
+ A Backstage catalog backend module that ingests MCP servers from a configured [MCP Registry](https://github.com/modelcontextprotocol/registry) into the RHDH catalog as `mcp-server` API entities.
4
+
5
+ ## Installation
6
+
7
+ ### Prerequisite
8
+
9
+ Since [Backstage 1.51.0](https://github.com/backstage/backstage/releases/tag/v1.51.0), `spec.type: mcp-server` entities (they use `spec.remotes` and omit `spec.definition`) are accepted only when `@backstage/plugin-catalog-backend-module-ai-model` is installed. Without that module the catalog keeps the generic API validator, which rejects these entities and does not list them.
10
+
11
+ ### Install packages
12
+
13
+ From your Backstage root:
14
+
15
+ ```bash
16
+ yarn --cwd packages/backend add \
17
+ @backstage/plugin-catalog-backend-module-ai-model \
18
+ @red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider
19
+ ```
20
+
21
+ Skip the `ai-model` package if it is already installed.
22
+
23
+ ### Register modules
24
+
25
+ Add both modules to your backend:
26
+
27
+ ```ts
28
+ // packages/backend/src/index.ts
29
+ backend.add(import('@backstage/plugin-catalog-backend-module-ai-model'));
30
+ backend.add(
31
+ import(
32
+ '@red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider'
33
+ ),
34
+ );
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ Configure the provider in your `app-config.yaml`:
40
+
41
+ ```yaml
42
+ catalog:
43
+ providers:
44
+ mcpRegistry:
45
+ # Reserved instance id — only this key is supported today.
46
+ mcpRegistry:
47
+ baseUrl: https://registry.example.com
48
+ # Optional: override the mapping identity prefix (default: mcp.registry)
49
+ # baseName: com.example.registry
50
+ # Optional: registry API version slug (default: v1)
51
+ # apiVersion: v1
52
+ # Optional: default entity owner (default: unknown)
53
+ # defaultOwner: group:default/mcp-admins
54
+ # Optional: default entity lifecycle (default: production)
55
+ # defaultLifecycle: production
56
+ # Optional: max pages fetched per sync (default: 10)
57
+ # pageLimit: 10
58
+ # Optional: registry page size sent as ?limit= (omitted by default)
59
+ # pageSize: 50
60
+ # Optional: max entries per complete traversal; soft-stops with end cursor (default: 5000)
61
+ # maxEntries: 5000
62
+ # Optional: ingest only servers with at least one native remote (default: false)
63
+ # remotesOnly: false
64
+ # Optional: request only the latest version of each server via ?version=latest (default: false)
65
+ # latestVersion: false
66
+ # Optional: restrict outbound requests to specific hostnames (defense-in-depth)
67
+ # hostAllowList:
68
+ # - registry.example.com
69
+ # Optional: sync schedule (defaults shown below)
70
+ # schedule:
71
+ # frequency: { minutes: 30 }
72
+ # timeout: { minutes: 3 }
73
+ # # Optional: defer the first sync
74
+ # # initialDelay: { seconds: 15 }
75
+ ```
76
+
77
+ ### Configuration options
78
+
79
+ | Option | Required | Default | Description |
80
+ | ------------------ | -------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
81
+ | `baseUrl` | Yes | — | MCP Registry base URL. Also passed to the mapping as `placeholderRemoteUrl` when a server has no valid remotes. |
82
+ | `baseName` | No | `mcp.registry` (mapping default) | Override the mapping identity prefix for `metadata.name` |
83
+ | `apiVersion` | No | `v1` | Registry API version slug. The servers endpoint is `<baseUrl>/<apiVersion>/servers`. Note: the live MCP Registry may serve `/v0` or `/v0.1`; set `apiVersion` to match your registry. |
84
+ | `defaultOwner` | No | `unknown` (mapping default) | Backstage entity reference used as `spec.owner` |
85
+ | `defaultLifecycle` | No | `production` (mapping default) | Value used as `spec.lifecycle` |
86
+ | `pageLimit` | No | `10` | Maximum number of pages fetched per sync. When more pages remain, the provider saves the cursor and continues on the next sync (no mutation until the registry is fully traversed). |
87
+ | `pageSize` | No | _(registry default)_ | Sent as `?limit=` on each list request. When omitted, the registry's default page size applies. |
88
+ | `maxEntries` | No | `5000` | Maximum total server entries buffered for one complete registry traversal (spans resume syncs). When exceeded, the provider commits the buffer, saves an end cursor, and later traversals stop there until `maxEntries` is patched. |
89
+ | `remotesOnly` | No | `false` | When `true`, skip servers that do not declare at least one native remote (non-empty type and http(s) URL). Package-only and placeholder-remote servers are omitted from the catalog. |
90
+ | `latestVersion` | No | `false` | When `true`, each list request includes `?version=latest` (for example `<baseUrl>/<apiVersion>/servers?version=latest`). When `false` or omitted, the `version` query parameter is left unset. |
91
+ | `hostAllowList` | No | _(none — all hosts allowed)_ | Array of permitted hostnames. When set, `baseUrl` hostname must be in this list and every outbound request (including each redirect `Location` and `response.url`) is validated at runtime. Missing `response.url` fails closed. Redirects are followed manually so disallowed hosts are never contacted. Provides defense-in-depth against SSRF. When omitted, a warning is logged at startup. |
92
+ | `schedule` | No | 30m frequency, 3m timeout | `SchedulerServiceTaskScheduleDefinition` controlling sync cadence. The first sync runs after one `frequency` interval unless `initialDelay` is set. |
93
+
94
+ ### Multiple registries
95
+
96
+ `catalog.providers.mcpRegistry` is a map of instance ids so additional
97
+ registries can be added later. This implementation only reads the reserved
98
+ `mcpRegistry` instance (`catalog.providers.mcpRegistry.mcpRegistry`). Other
99
+ instance ids are ignored and a warning is logged that multiple MCP Registry
100
+ providers are not supported yet. Use `baseName` on the reserved instance to
101
+ override the identity prefix if needed for future multi-registry support.
102
+
103
+ ## Behavior
104
+
105
+ ### Pagination
106
+
107
+ The provider fully traverses the registry's cursor-based pagination, accumulating all server entries. Cursors are treated as opaque strings. The `pageLimit` configuration caps the number of pages fetched **per sync**. If the registry still has more pages after that cap, the provider saves the next cursor, buffers the entries fetched so far, and continues from that cursor on the next scheduled sync — it does **not** commit a mutation until a sync reaches the end of the registry (no `nextCursor`). When a traversal completes, the provider commits a full mutation and the following sync starts from the beginning again. The `maxEntries` configuration caps the total buffered servers for that complete traversal (not per individual sync tick). When the cap is hit, the provider commits the buffered entries, saves that stop point as an **end cursor**, and later full traversals end at that cursor instead of a missing `nextCursor`. Servers that were committed earlier but fall outside the soft-stop window on a later sync (for example when new registry entries shift page listings) are retained from last-good with `redhat.com/rhdh-mcp-registry-sync-status: degraded` instead of being pruned. Patching `maxEntries` clears the saved end cursor so traversal returns to normal.
108
+
109
+ ### Mapping
110
+
111
+ Each server entry's `.server` object is transformed into an `mcp-server` API entity using the [`catalog-mcp-registry-server-mapping`](../catalog-mcp-registry-server-mapping) library. The provider passes `defaultOwner`, `defaultLifecycle`, and `baseName` as caller overrides, and always passes the configured `baseUrl` as `placeholderRemoteUrl` so a server with no valid remotes gets a placeholder remote for that registry before falling back to `websiteUrl`. When `remotesOnly` is `true`, servers without a native remote are skipped before mapping. It never reimplements the mapping rules.
112
+
113
+ ### Full mutation
114
+
115
+ When a registry traversal completes (no remaining `nextCursor`, possibly after several resume syncs), the provider commits a **full mutation** — the catalog converges to the registry's current server set. Servers removed from the registry are automatically pruned. Partial resume ticks do not mutate.
116
+
117
+ ### Error handling
118
+
119
+ - **Per-entry failures**: If a single server entry fails mapping or formatting validation (for example an invalid or malformed `server.json`), the provider logs the error and continues. If a last-good entity exists for that server (matched by `name` and `version`), it is retained with `redhat.com/rhdh-mcp-registry-sync-status: degraded` and re-added on later syncs until mapping succeeds again.
120
+ - **Soft-stop window shifts**: While an end cursor from `maxEntries` is active, previously synced servers that are no longer inside the truncated window are retained the same way (`degraded`) and re-added on later syncs until they appear in the soft-stop window again.
121
+ - **Registry-level failures**: Transport errors, non-2xx responses, unparseable JSON, or pagination safeguard trips abort the sync — no mutation is committed, preserving the prior catalog state.
122
+
123
+ ### Annotations
124
+
125
+ Each entity carries:
126
+
127
+ - `backstage.io/managed-by-location`: `url:<normalizedBaseUrl>`
128
+ - `backstage.io/managed-by-origin-location`: `url:<normalizedBaseUrl>`
129
+ - `redhat.com/rhdh-mcp-registry-sync-status`: `ok` or `degraded`
130
+ - `modelcontextprotocol.io/name`: the server's canonical name
131
+ - `modelcontextprotocol.io/version`: the server's version
132
+
133
+ Example catalog entities matching this shape (generated from
134
+ [`examples/mcp-registry/seed-data/seed.json`](../../examples/mcp-registry/seed-data/seed.json))
135
+ are in [`examples/api-mcp-servers.yaml`](../../examples/api-mcp-servers.yaml).
136
+
137
+ ## Non-Remote MCP Servers
138
+
139
+ MCP servers without a remote deployment (package(s) only or [custom installation](https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/generic-server-json.md#server-with-custom-installation-path)) can be queried via: `GET /api/catalog/entities?filter=kind=API,spec.type=mcp-server,spec.remotes.type=undefined`
140
+
141
+ These MCP server entries have a single remote _placeholder_ field which should **not** be parsed by a client always expecting a remote MCP Server. To avoid ingesting them at all, set `remotesOnly: true` on the provider. To keep them in the catalog but filter them out at query time, use `POST /api/catalog/entities/by-query` with the following JSON body:
142
+
143
+ ```json
144
+ {
145
+ "query": {
146
+ "$all": [
147
+ { "kind": "API" },
148
+ { "spec.type": "mcp-server" },
149
+ { "$not": { "spec.remotes.type": "undefined" } }
150
+ ]
151
+ }
152
+ }
153
+ ```
154
+
155
+ ## MCP Registry
156
+
157
+ ### Official Live Deployments
158
+
159
+ To ingest MCP servers from the official MCP Registry into the catalog, see
160
+ [Using Official MCP Registries](../../docs/using-official-mcp-registries.md).
161
+
162
+ ### Deploy Locally
163
+
164
+ To run a local MCP Registry for provider development, see
165
+ [Deploy MCP Registry Locally](../../docs/deploy-mcp-registry-locally.md).
@@ -0,0 +1,380 @@
1
+ 'use strict';
2
+
3
+ var catalogModel = require('@backstage/catalog-model');
4
+ var backstagePluginCatalogMcpRegistryServerMapping = require('@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping');
5
+ var config = require('./config.cjs.js');
6
+ var client = require('./client.cjs.js');
7
+ var providerUtils = require('./providerUtils.cjs.js');
8
+ var util = require('./util.cjs.js');
9
+
10
+ const PROVIDER_NAME = "mcp-registry-provider";
11
+ const SYNC_STATUS_ANNOTATION = "redhat.com/rhdh-mcp-registry-sync-status";
12
+ class McpRegistryEntityProvider {
13
+ connection;
14
+ config;
15
+ logger;
16
+ fetchApi;
17
+ taskRunner;
18
+ /**
19
+ * Internal last-good index: keyed by `name::version`, stores the
20
+ * last successfully committed DeferredEntity so that a subsequent
21
+ * sync can retain it when mapping fails (D6).
22
+ */
23
+ lastGoodIndex = /* @__PURE__ */ new Map();
24
+ /**
25
+ * Resume state for multi-sync pagination. When a sync hits
26
+ * `pageLimit` with more pages remaining, entries fetched so far and
27
+ * the next cursor are kept here so the following sync continues
28
+ * instead of restarting. Cleared when a traversal reaches the end
29
+ * of the registry (no next cursor) or a `maxEntries` soft-stop and a
30
+ * full mutation is committed.
31
+ */
32
+ resumeCursor;
33
+ pendingEntries = [];
34
+ seenCursors = /* @__PURE__ */ new Set();
35
+ /**
36
+ * After a `maxEntries` soft-stop, later full traversals end at this
37
+ * cursor instead of a missing `nextCursor`. Cleared when
38
+ * `maxEntries` is patched (value differs from when it was saved).
39
+ */
40
+ endCursor;
41
+ endCursorMaxEntries;
42
+ constructor(config$1, logger, options) {
43
+ this.config = config.resolveMcpRegistryProviderConfig(config$1);
44
+ this.logger = logger;
45
+ this.fetchApi = options?.fetchApi;
46
+ this.taskRunner = options?.taskRunner;
47
+ }
48
+ getProviderName() {
49
+ return PROVIDER_NAME;
50
+ }
51
+ async connect(connection) {
52
+ this.connection = connection;
53
+ if (this.taskRunner) {
54
+ await this.taskRunner.run({
55
+ id: `${PROVIDER_NAME}:refresh`,
56
+ fn: async () => {
57
+ await this.run();
58
+ }
59
+ });
60
+ }
61
+ }
62
+ /**
63
+ * Run one sync cycle: fetch servers from the registry, map them,
64
+ * and commit a full mutation.
65
+ *
66
+ * Intentionally not TypeScript-`private` so unit tests can invoke it
67
+ * directly; `@internal` keeps it out of the published API surface.
68
+ *
69
+ * @internal
70
+ */
71
+ async run() {
72
+ if (!this.connection) {
73
+ throw new Error(
74
+ "McpRegistryEntityProvider not initialized; call connect() first."
75
+ );
76
+ }
77
+ const managedByLocation = `url:${util.stripTrailingSlashes(
78
+ this.config.baseUrl
79
+ )}`;
80
+ const entries = await this.fetchRegistryEntries();
81
+ if (!entries) {
82
+ return;
83
+ }
84
+ const { entities: mappedEntities, hasDegradedEntries: mappingDegraded } = this.mapRegistryEntries(entries, managedByLocation);
85
+ const { entities, hasDegradedEntries } = this.appendSoftStopRetained(
86
+ mappedEntities,
87
+ mappingDegraded,
88
+ managedByLocation
89
+ );
90
+ if (hasDegradedEntries) {
91
+ this.logger.warn(
92
+ `MCP Registry sync completed with degraded entries. Some previously synced servers could not be refreshed (mapping/formatting failure or maxEntries soft-stop window) and are using last-good entities.`
93
+ );
94
+ }
95
+ await this.connection.applyMutation({
96
+ type: "full",
97
+ entities
98
+ });
99
+ this.rebuildLastGoodIndex(entities);
100
+ this.logger.info(
101
+ `MCP Registry sync completed: ${entities.length} entities committed.`
102
+ );
103
+ }
104
+ /**
105
+ * Fetch registry servers for this sync tick.
106
+ *
107
+ * Continues from `resumeCursor` when a prior sync stopped at
108
+ * `pageLimit`. Returns `undefined` when a client error aborts the
109
+ * tick without a mutation, or when more pages remain (entries are
110
+ * buffered until the registry is exhausted or `maxEntries` stops the
111
+ * traversal so a full mutation does not prune unfetched servers).
112
+ *
113
+ * Hitting `maxEntries` commits a full mutation of the buffer, saves
114
+ * `endCursor`, and starts the next cycle from the beginning. Later
115
+ * full traversals stop at that `endCursor` until `maxEntries` is
116
+ * patched.
117
+ */
118
+ async fetchRegistryEntries() {
119
+ const {
120
+ baseUrl,
121
+ apiVersion,
122
+ pageLimit,
123
+ pageSize,
124
+ latestVersion,
125
+ maxEntries,
126
+ hostAllowList
127
+ } = this.config;
128
+ if (this.endCursor !== void 0 && this.endCursorMaxEntries !== maxEntries) {
129
+ this.logger.info(
130
+ `MCP Registry maxEntries changed from ${this.endCursorMaxEntries} to ${maxEntries}; clearing saved endCursor.`
131
+ );
132
+ this.endCursor = void 0;
133
+ this.endCursorMaxEntries = void 0;
134
+ }
135
+ try {
136
+ const result = await client.fetchRegistryServers({
137
+ baseUrl,
138
+ apiVersion,
139
+ pageLimit,
140
+ pageSize,
141
+ latestVersion,
142
+ maxEntries,
143
+ priorEntryCount: this.pendingEntries.length,
144
+ startCursor: this.resumeCursor,
145
+ endCursor: this.endCursor,
146
+ seenCursors: this.seenCursors,
147
+ hostAllowList,
148
+ fetchApi: this.fetchApi
149
+ });
150
+ this.pendingEntries.push(...result.servers);
151
+ this.seenCursors = result.seenCursors;
152
+ if (result.resumeCursor) {
153
+ this.resumeCursor = result.resumeCursor;
154
+ this.logger.info(
155
+ `MCP Registry sync reached pageLimit (${pageLimit} pages); buffered ${this.pendingEntries.length} entries and will resume from the saved cursor on the next sync (no mutation emitted).`
156
+ );
157
+ return void 0;
158
+ }
159
+ if (result.endCursor) {
160
+ this.endCursor = result.endCursor;
161
+ this.endCursorMaxEntries = maxEntries;
162
+ this.logger.warn(
163
+ `MCP Registry sync reached maxEntries (${maxEntries}); committing ${this.pendingEntries.length} buffered entries and saving endCursor for later traversals.`
164
+ );
165
+ }
166
+ const entries = this.pendingEntries;
167
+ this.pendingEntries = [];
168
+ this.resumeCursor = void 0;
169
+ this.seenCursors = /* @__PURE__ */ new Set();
170
+ return entries;
171
+ } catch (err) {
172
+ if (err instanceof client.McpRegistryClientError) {
173
+ this.logger.error(
174
+ `MCP Registry sync failed (no mutation emitted): ${err.message}`
175
+ );
176
+ return void 0;
177
+ }
178
+ throw err;
179
+ }
180
+ }
181
+ /**
182
+ * Map every registry entry with per-entry failure isolation.
183
+ * When `remotesOnly` is set, entries without a native remote are skipped.
184
+ */
185
+ mapRegistryEntries(entries, managedByLocation) {
186
+ const entities = [];
187
+ let hasDegradedEntries = false;
188
+ let skippedNonRemote = 0;
189
+ for (const entry of entries) {
190
+ if (this.config.remotesOnly && !providerUtils.hasNativeRemote(entry.server)) {
191
+ skippedNonRemote += 1;
192
+ continue;
193
+ }
194
+ try {
195
+ entities.push(this.mapRegistryEntry(entry, managedByLocation));
196
+ } catch (err) {
197
+ const retained = this.retainLastGoodOnMappingFailure(
198
+ entry,
199
+ err,
200
+ managedByLocation
201
+ );
202
+ if (retained) {
203
+ entities.push(retained);
204
+ hasDegradedEntries = true;
205
+ }
206
+ }
207
+ }
208
+ if (skippedNonRemote > 0) {
209
+ this.logger.info(
210
+ `MCP Registry remotesOnly skipped ${skippedNonRemote} non-remote server entr${skippedNonRemote === 1 ? "y" : "ies"}.`
211
+ );
212
+ }
213
+ return { entities, hasDegradedEntries };
214
+ }
215
+ /**
216
+ * When a `maxEntries` soft-stop is active (`endCursor` set), retain
217
+ * last-good entities that fell outside the truncated window so a full
218
+ * mutation does not prune them. Mark retained copies as degraded.
219
+ *
220
+ * Full traversals without an end bound continue to prune servers that
221
+ * are absent from the registry.
222
+ */
223
+ appendSoftStopRetained(mappedEntities, hasDegradedEntries, managedByLocation) {
224
+ if (this.endCursor === void 0 || this.lastGoodIndex.size === 0) {
225
+ return { entities: mappedEntities, hasDegradedEntries };
226
+ }
227
+ const seenKeys = /* @__PURE__ */ new Set();
228
+ for (const deferred of mappedEntities) {
229
+ const annotations = deferred.entity.metadata?.annotations;
230
+ const name = annotations?.["modelcontextprotocol.io/name"];
231
+ const version = annotations?.["modelcontextprotocol.io/version"];
232
+ if (name && version) {
233
+ seenKeys.add(providerUtils.buildLastGoodKey(name, version));
234
+ }
235
+ }
236
+ const entities = [...mappedEntities];
237
+ let degraded = hasDegradedEntries;
238
+ for (const [key, lastGood] of this.lastGoodIndex) {
239
+ if (seenKeys.has(key)) {
240
+ continue;
241
+ }
242
+ const retainedEntity = structuredClone(lastGood.entity);
243
+ this.applyProviderAnnotations(
244
+ retainedEntity,
245
+ managedByLocation,
246
+ "degraded"
247
+ );
248
+ entities.push({
249
+ entity: retainedEntity,
250
+ locationKey: PROVIDER_NAME
251
+ });
252
+ degraded = true;
253
+ this.logger.warn(
254
+ `Retaining last-good entity for "${key}" with degraded sync status; it fell outside the maxEntries soft-stop window.`
255
+ );
256
+ }
257
+ return { entities, hasDegradedEntries: degraded };
258
+ }
259
+ /**
260
+ * Map one registry entry into a deferred entity with sync status `ok`.
261
+ */
262
+ mapRegistryEntry(entry, managedByLocation) {
263
+ const serverDoc = entry.server;
264
+ const mappingResult = backstagePluginCatalogMcpRegistryServerMapping.mapServerToEntity(
265
+ serverDoc,
266
+ this.buildMappingDefaults()
267
+ );
268
+ const entity = mappingResult.entity;
269
+ entity.metadata.annotations = {
270
+ ...entity.metadata.annotations,
271
+ ...backstagePluginCatalogMcpRegistryServerMapping.projectAnnotations(
272
+ serverDoc,
273
+ mappingResult.consumedPaths,
274
+ mappingResult.reservedAnnotationKeys
275
+ )
276
+ };
277
+ this.applyProviderAnnotations(entity, managedByLocation, "ok");
278
+ return {
279
+ entity,
280
+ locationKey: PROVIDER_NAME
281
+ };
282
+ }
283
+ /**
284
+ * Build mapping caller overrides from provider config.
285
+ *
286
+ * `placeholderRemoteUrl` is the target registry `baseUrl` so a server
287
+ * with no valid remotes still gets a D8 placeholder pointing at that
288
+ * registry, before the mapping falls back to `websiteUrl`.
289
+ */
290
+ buildMappingDefaults() {
291
+ const mappingDefaults = {
292
+ placeholderRemoteUrl: this.config.baseUrl
293
+ };
294
+ if (this.config.defaultOwner) {
295
+ mappingDefaults.owner = this.config.defaultOwner;
296
+ }
297
+ if (this.config.defaultLifecycle) {
298
+ mappingDefaults.lifecycle = this.config.defaultLifecycle;
299
+ }
300
+ if (this.config.baseName) {
301
+ mappingDefaults.prefix = this.config.baseName;
302
+ }
303
+ return mappingDefaults;
304
+ }
305
+ /**
306
+ * Stamp provider-owned location and sync-status annotations.
307
+ *
308
+ * Catalog processing requires both location annotations. Without the
309
+ * origin annotation the entity is rejected and never listed.
310
+ */
311
+ applyProviderAnnotations(entity, managedByLocation, syncStatus) {
312
+ if (!entity.metadata.annotations) {
313
+ entity.metadata.annotations = {};
314
+ }
315
+ entity.metadata.annotations[catalogModel.ANNOTATION_LOCATION] = managedByLocation;
316
+ entity.metadata.annotations[catalogModel.ANNOTATION_ORIGIN_LOCATION] = managedByLocation;
317
+ entity.metadata.annotations[SYNC_STATUS_ANNOTATION] = syncStatus;
318
+ }
319
+ /**
320
+ * Log a mapping failure and retain a last-good entity when available (D6).
321
+ */
322
+ retainLastGoodOnMappingFailure(entry, err, managedByLocation) {
323
+ const { name, version } = providerUtils.readServerIdentity(entry);
324
+ this.logger.warn(providerUtils.formatMappingFailureMessage(name, version, err));
325
+ if (!name || !version) {
326
+ return void 0;
327
+ }
328
+ const lastGood = this.lastGoodIndex.get(providerUtils.buildLastGoodKey(name, version));
329
+ if (!lastGood) {
330
+ this.logger.info(
331
+ `No last-good entity found for "${name}" version "${version}"; omitting from mutation.`
332
+ );
333
+ return void 0;
334
+ }
335
+ const retainedEntity = structuredClone(lastGood.entity);
336
+ this.applyProviderAnnotations(
337
+ retainedEntity,
338
+ managedByLocation,
339
+ "degraded"
340
+ );
341
+ this.logger.info(
342
+ `Retained last-good entity for "${name}" version "${version}" with degraded sync status.`
343
+ );
344
+ return {
345
+ entity: retainedEntity,
346
+ locationKey: PROVIDER_NAME
347
+ };
348
+ }
349
+ /**
350
+ * Rebuild the last-good index from every committed entity that has a
351
+ * registry identity (`ok` and `degraded` alike).
352
+ *
353
+ * Degraded entries stay indexed so they can be re-added on later syncs
354
+ * until the server is refreshed successfully (`ok`) or omitted from the
355
+ * mutation entirely (true prune after a full traversal without soft-stop
356
+ * retention).
357
+ *
358
+ * The annotation keys used here ('modelcontextprotocol.io/name' and
359
+ * 'modelcontextprotocol.io/version') are set by mapServerToEntity in
360
+ * catalog-mcp-registry-server-mapping and correspond to the raw
361
+ * serverDoc.name and serverDoc.version fields used in buildLastGoodKey
362
+ * during failure recovery. If the mapping library changes these
363
+ * annotation keys, both this rebuild and the failure recovery path
364
+ * must be updated in tandem.
365
+ */
366
+ rebuildLastGoodIndex(entities) {
367
+ this.lastGoodIndex.clear();
368
+ for (const deferred of entities) {
369
+ const annotations = deferred.entity.metadata?.annotations;
370
+ const name = annotations?.["modelcontextprotocol.io/name"];
371
+ const version = annotations?.["modelcontextprotocol.io/version"];
372
+ if (name && version) {
373
+ this.lastGoodIndex.set(providerUtils.buildLastGoodKey(name, version), deferred);
374
+ }
375
+ }
376
+ }
377
+ }
378
+
379
+ exports.McpRegistryEntityProvider = McpRegistryEntityProvider;
380
+ //# sourceMappingURL=McpRegistryEntityProvider.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"McpRegistryEntityProvider.cjs.js","sources":["../src/McpRegistryEntityProvider.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n LoggerService,\n SchedulerServiceTaskRunner,\n} from '@backstage/backend-plugin-api';\nimport {\n ANNOTATION_LOCATION,\n ANNOTATION_ORIGIN_LOCATION,\n type Entity,\n} from '@backstage/catalog-model';\nimport type {\n DeferredEntity,\n EntityProvider,\n EntityProviderConnection,\n} from '@backstage/plugin-catalog-node';\nimport {\n mapServerToEntity,\n projectAnnotations,\n} from '@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping';\nimport type { McpServerMappingDefaults } from '@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping';\nimport {\n resolveMcpRegistryProviderConfig,\n type McpRegistryProviderConfig,\n type ResolvedMcpRegistryProviderConfig,\n} from './config';\nimport { fetchRegistryServers, McpRegistryClientError } from './client';\nimport type { McpRegistryServerEntry } from './client';\nimport {\n buildLastGoodKey,\n formatMappingFailureMessage,\n hasNativeRemote,\n readServerIdentity,\n} from './providerUtils';\nimport { stripTrailingSlashes } from './util';\n\n/** Provider name and locationKey constant. */\nconst PROVIDER_NAME = 'mcp-registry-provider';\n\n/** Sync status annotation key. */\nconst SYNC_STATUS_ANNOTATION = 'redhat.com/rhdh-mcp-registry-sync-status';\n\n/**\n * Optional constructor dependencies for {@link McpRegistryEntityProvider}.\n *\n * @public\n */\nexport interface McpRegistryEntityProviderOptions {\n /**\n * @internal Override the global `fetch` implementation (test seam).\n * Kept non-private so tests can inject it; stripped from published types.\n */\n fetchApi?: typeof fetch;\n /** Scheduler task runner used to periodically invoke sync. */\n taskRunner?: SchedulerServiceTaskRunner;\n}\n\n/**\n * Entity provider that ingests MCP servers from one configured\n * MCP Registry into the Backstage catalog.\n *\n * @public\n */\nexport class McpRegistryEntityProvider implements EntityProvider {\n private connection?: EntityProviderConnection;\n private readonly config: ResolvedMcpRegistryProviderConfig;\n private readonly logger: LoggerService;\n private readonly fetchApi?: typeof fetch;\n private readonly taskRunner?: SchedulerServiceTaskRunner;\n\n /**\n * Internal last-good index: keyed by `name::version`, stores the\n * last successfully committed DeferredEntity so that a subsequent\n * sync can retain it when mapping fails (D6).\n */\n private readonly lastGoodIndex = new Map<string, DeferredEntity>();\n\n /**\n * Resume state for multi-sync pagination. When a sync hits\n * `pageLimit` with more pages remaining, entries fetched so far and\n * the next cursor are kept here so the following sync continues\n * instead of restarting. Cleared when a traversal reaches the end\n * of the registry (no next cursor) or a `maxEntries` soft-stop and a\n * full mutation is committed.\n */\n private resumeCursor?: string;\n private pendingEntries: McpRegistryServerEntry[] = [];\n private seenCursors: Set<string> = new Set();\n\n /**\n * After a `maxEntries` soft-stop, later full traversals end at this\n * cursor instead of a missing `nextCursor`. Cleared when\n * `maxEntries` is patched (value differs from when it was saved).\n */\n private endCursor?: string;\n private endCursorMaxEntries?: number;\n\n constructor(\n config: McpRegistryProviderConfig,\n logger: LoggerService,\n options?: McpRegistryEntityProviderOptions,\n ) {\n this.config = resolveMcpRegistryProviderConfig(config);\n this.logger = logger;\n this.fetchApi = options?.fetchApi;\n this.taskRunner = options?.taskRunner;\n }\n\n getProviderName(): string {\n return PROVIDER_NAME;\n }\n\n async connect(connection: EntityProviderConnection): Promise<void> {\n this.connection = connection;\n // The scheduler's first tick can run immediately. Register it only\n // after the catalog connection exists so that tick can commit.\n if (this.taskRunner) {\n await this.taskRunner.run({\n id: `${PROVIDER_NAME}:refresh`,\n fn: async () => {\n await this.run();\n },\n });\n }\n }\n\n /**\n * Run one sync cycle: fetch servers from the registry, map them,\n * and commit a full mutation.\n *\n * Intentionally not TypeScript-`private` so unit tests can invoke it\n * directly; `@internal` keeps it out of the published API surface.\n *\n * @internal\n */\n async run(): Promise<void> {\n if (!this.connection) {\n throw new Error(\n 'McpRegistryEntityProvider not initialized; call connect() first.',\n );\n }\n\n const managedByLocation = `url:${stripTrailingSlashes(\n this.config.baseUrl,\n )}`;\n const entries = await this.fetchRegistryEntries();\n if (!entries) {\n return;\n }\n\n const { entities: mappedEntities, hasDegradedEntries: mappingDegraded } =\n this.mapRegistryEntries(entries, managedByLocation);\n\n const { entities, hasDegradedEntries } = this.appendSoftStopRetained(\n mappedEntities,\n mappingDegraded,\n managedByLocation,\n );\n\n if (hasDegradedEntries) {\n this.logger.warn(\n `MCP Registry sync completed with degraded entries. ` +\n `Some previously synced servers could not be refreshed ` +\n `(mapping/formatting failure or maxEntries soft-stop window) ` +\n `and are using last-good entities.`,\n );\n }\n\n await this.connection.applyMutation({\n type: 'full',\n entities,\n });\n\n this.rebuildLastGoodIndex(entities);\n\n this.logger.info(\n `MCP Registry sync completed: ${entities.length} entities committed.`,\n );\n }\n\n /**\n * Fetch registry servers for this sync tick.\n *\n * Continues from `resumeCursor` when a prior sync stopped at\n * `pageLimit`. Returns `undefined` when a client error aborts the\n * tick without a mutation, or when more pages remain (entries are\n * buffered until the registry is exhausted or `maxEntries` stops the\n * traversal so a full mutation does not prune unfetched servers).\n *\n * Hitting `maxEntries` commits a full mutation of the buffer, saves\n * `endCursor`, and starts the next cycle from the beginning. Later\n * full traversals stop at that `endCursor` until `maxEntries` is\n * patched.\n */\n private async fetchRegistryEntries(): Promise<\n McpRegistryServerEntry[] | undefined\n > {\n const {\n baseUrl,\n apiVersion,\n pageLimit,\n pageSize,\n latestVersion,\n maxEntries,\n hostAllowList,\n } = this.config;\n\n if (\n this.endCursor !== undefined &&\n this.endCursorMaxEntries !== maxEntries\n ) {\n this.logger.info(\n `MCP Registry maxEntries changed from ` +\n `${this.endCursorMaxEntries} to ${maxEntries}; ` +\n `clearing saved endCursor.`,\n );\n this.endCursor = undefined;\n this.endCursorMaxEntries = undefined;\n }\n\n try {\n const result = await fetchRegistryServers({\n baseUrl,\n apiVersion,\n pageLimit,\n pageSize,\n latestVersion,\n maxEntries,\n priorEntryCount: this.pendingEntries.length,\n startCursor: this.resumeCursor,\n endCursor: this.endCursor,\n seenCursors: this.seenCursors,\n hostAllowList,\n fetchApi: this.fetchApi,\n });\n\n this.pendingEntries.push(...result.servers);\n this.seenCursors = result.seenCursors;\n\n if (result.resumeCursor) {\n this.resumeCursor = result.resumeCursor;\n this.logger.info(\n `MCP Registry sync reached pageLimit (${pageLimit} pages); ` +\n `buffered ${this.pendingEntries.length} entries and will ` +\n `resume from the saved cursor on the next sync ` +\n `(no mutation emitted).`,\n );\n return undefined;\n }\n\n if (result.endCursor) {\n this.endCursor = result.endCursor;\n this.endCursorMaxEntries = maxEntries;\n this.logger.warn(\n `MCP Registry sync reached maxEntries (${maxEntries}); ` +\n `committing ${this.pendingEntries.length} buffered entries ` +\n `and saving endCursor for later traversals.`,\n );\n }\n\n // Clear pagination state before returning so applyMutation failures\n // start a fresh traversal on the next sync (intended, covered by tests).\n const entries = this.pendingEntries;\n this.pendingEntries = [];\n this.resumeCursor = undefined;\n this.seenCursors = new Set();\n return entries;\n } catch (err) {\n if (err instanceof McpRegistryClientError) {\n // Input seenCursors is never mutated by the client; leave\n // this.seenCursors unchanged so the next sync can retry.\n this.logger.error(\n `MCP Registry sync failed (no mutation emitted): ${err.message}`,\n );\n return undefined;\n }\n throw err;\n }\n }\n\n /**\n * Map every registry entry with per-entry failure isolation.\n * When `remotesOnly` is set, entries without a native remote are skipped.\n */\n private mapRegistryEntries(\n entries: McpRegistryServerEntry[],\n managedByLocation: string,\n ): { entities: DeferredEntity[]; hasDegradedEntries: boolean } {\n const entities: DeferredEntity[] = [];\n let hasDegradedEntries = false;\n let skippedNonRemote = 0;\n\n for (const entry of entries) {\n if (this.config.remotesOnly && !hasNativeRemote(entry.server)) {\n skippedNonRemote += 1;\n continue;\n }\n try {\n entities.push(this.mapRegistryEntry(entry, managedByLocation));\n } catch (err) {\n const retained = this.retainLastGoodOnMappingFailure(\n entry,\n err,\n managedByLocation,\n );\n if (retained) {\n entities.push(retained);\n hasDegradedEntries = true;\n }\n }\n }\n\n if (skippedNonRemote > 0) {\n this.logger.info(\n `MCP Registry remotesOnly skipped ${skippedNonRemote} ` +\n `non-remote server entr${skippedNonRemote === 1 ? 'y' : 'ies'}.`,\n );\n }\n\n return { entities, hasDegradedEntries };\n }\n\n /**\n * When a `maxEntries` soft-stop is active (`endCursor` set), retain\n * last-good entities that fell outside the truncated window so a full\n * mutation does not prune them. Mark retained copies as degraded.\n *\n * Full traversals without an end bound continue to prune servers that\n * are absent from the registry.\n */\n private appendSoftStopRetained(\n mappedEntities: DeferredEntity[],\n hasDegradedEntries: boolean,\n managedByLocation: string,\n ): { entities: DeferredEntity[]; hasDegradedEntries: boolean } {\n if (this.endCursor === undefined || this.lastGoodIndex.size === 0) {\n return { entities: mappedEntities, hasDegradedEntries };\n }\n\n const seenKeys = new Set<string>();\n for (const deferred of mappedEntities) {\n const annotations = deferred.entity.metadata?.annotations;\n const name = annotations?.['modelcontextprotocol.io/name'];\n const version = annotations?.['modelcontextprotocol.io/version'];\n if (name && version) {\n seenKeys.add(buildLastGoodKey(name, version));\n }\n }\n\n const entities = [...mappedEntities];\n let degraded = hasDegradedEntries;\n\n for (const [key, lastGood] of this.lastGoodIndex) {\n if (seenKeys.has(key)) {\n continue;\n }\n const retainedEntity = structuredClone(lastGood.entity);\n this.applyProviderAnnotations(\n retainedEntity,\n managedByLocation,\n 'degraded',\n );\n entities.push({\n entity: retainedEntity,\n locationKey: PROVIDER_NAME,\n });\n degraded = true;\n this.logger.warn(\n `Retaining last-good entity for \"${key}\" with degraded sync ` +\n `status; it fell outside the maxEntries soft-stop window.`,\n );\n }\n\n return { entities, hasDegradedEntries: degraded };\n }\n\n /**\n * Map one registry entry into a deferred entity with sync status `ok`.\n */\n private mapRegistryEntry(\n entry: McpRegistryServerEntry,\n managedByLocation: string,\n ): DeferredEntity {\n const serverDoc = entry.server;\n const mappingResult = mapServerToEntity(\n serverDoc,\n this.buildMappingDefaults(),\n );\n const entity = mappingResult.entity;\n\n entity.metadata.annotations = {\n ...entity.metadata.annotations,\n ...projectAnnotations(\n serverDoc,\n mappingResult.consumedPaths,\n mappingResult.reservedAnnotationKeys,\n ),\n };\n\n this.applyProviderAnnotations(entity, managedByLocation, 'ok');\n\n return {\n entity,\n locationKey: PROVIDER_NAME,\n };\n }\n\n /**\n * Build mapping caller overrides from provider config.\n *\n * `placeholderRemoteUrl` is the target registry `baseUrl` so a server\n * with no valid remotes still gets a D8 placeholder pointing at that\n * registry, before the mapping falls back to `websiteUrl`.\n */\n private buildMappingDefaults(): McpServerMappingDefaults {\n const mappingDefaults: McpServerMappingDefaults = {\n placeholderRemoteUrl: this.config.baseUrl,\n };\n if (this.config.defaultOwner) {\n mappingDefaults.owner = this.config.defaultOwner;\n }\n if (this.config.defaultLifecycle) {\n mappingDefaults.lifecycle = this.config.defaultLifecycle;\n }\n if (this.config.baseName) {\n mappingDefaults.prefix = this.config.baseName;\n }\n return mappingDefaults;\n }\n\n /**\n * Stamp provider-owned location and sync-status annotations.\n *\n * Catalog processing requires both location annotations. Without the\n * origin annotation the entity is rejected and never listed.\n */\n private applyProviderAnnotations(\n entity: Entity,\n managedByLocation: string,\n syncStatus: 'ok' | 'degraded',\n ): void {\n if (!entity.metadata.annotations) {\n entity.metadata.annotations = {};\n }\n entity.metadata.annotations[ANNOTATION_LOCATION] = managedByLocation;\n entity.metadata.annotations[ANNOTATION_ORIGIN_LOCATION] = managedByLocation;\n entity.metadata.annotations[SYNC_STATUS_ANNOTATION] = syncStatus;\n }\n\n /**\n * Log a mapping failure and retain a last-good entity when available (D6).\n */\n private retainLastGoodOnMappingFailure(\n entry: McpRegistryServerEntry,\n err: unknown,\n managedByLocation: string,\n ): DeferredEntity | undefined {\n const { name, version } = readServerIdentity(entry);\n this.logger.warn(formatMappingFailureMessage(name, version, err));\n\n if (!name || !version) {\n return undefined;\n }\n\n const lastGood = this.lastGoodIndex.get(buildLastGoodKey(name, version));\n if (!lastGood) {\n this.logger.info(\n `No last-good entity found for \"${name}\" ` +\n `version \"${version}\"; omitting from mutation.`,\n );\n return undefined;\n }\n\n const retainedEntity = structuredClone(lastGood.entity);\n this.applyProviderAnnotations(\n retainedEntity,\n managedByLocation,\n 'degraded',\n );\n\n this.logger.info(\n `Retained last-good entity for \"${name}\" ` +\n `version \"${version}\" with degraded sync status.`,\n );\n\n return {\n entity: retainedEntity,\n locationKey: PROVIDER_NAME,\n };\n }\n\n /**\n * Rebuild the last-good index from every committed entity that has a\n * registry identity (`ok` and `degraded` alike).\n *\n * Degraded entries stay indexed so they can be re-added on later syncs\n * until the server is refreshed successfully (`ok`) or omitted from the\n * mutation entirely (true prune after a full traversal without soft-stop\n * retention).\n *\n * The annotation keys used here ('modelcontextprotocol.io/name' and\n * 'modelcontextprotocol.io/version') are set by mapServerToEntity in\n * catalog-mcp-registry-server-mapping and correspond to the raw\n * serverDoc.name and serverDoc.version fields used in buildLastGoodKey\n * during failure recovery. If the mapping library changes these\n * annotation keys, both this rebuild and the failure recovery path\n * must be updated in tandem.\n */\n private rebuildLastGoodIndex(entities: DeferredEntity[]): void {\n this.lastGoodIndex.clear();\n for (const deferred of entities) {\n const annotations = deferred.entity.metadata?.annotations;\n const name = annotations?.['modelcontextprotocol.io/name'];\n const version = annotations?.['modelcontextprotocol.io/version'];\n if (name && version) {\n this.lastGoodIndex.set(buildLastGoodKey(name, version), deferred);\n }\n }\n }\n}\n"],"names":["config","resolveMcpRegistryProviderConfig","stripTrailingSlashes","fetchRegistryServers","McpRegistryClientError","hasNativeRemote","buildLastGoodKey","mapServerToEntity","projectAnnotations","ANNOTATION_LOCATION","ANNOTATION_ORIGIN_LOCATION","readServerIdentity","formatMappingFailureMessage"],"mappings":"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"}