diff --git a/workspaces/ai-integrations/.changeset/mcp-registry-mapping-common-provider-link.md b/workspaces/ai-integrations/.changeset/mcp-registry-mapping-common-provider-link.md new file mode 100644 index 0000000000..8c380905d7 --- /dev/null +++ b/workspaces/ai-integrations/.changeset/mcp-registry-mapping-common-provider-link.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping': minor +--- + +Rename the mapping common library to `catalog-mcp-registry-server-mapping` (package, directory, and `pluginId`) to match workspace naming conventions. Consumers of `@red-hat-developer-hub/backstage-plugin-mcp-registry-server-mapping-common` should update to the new package name. diff --git a/workspaces/ai-integrations/.changeset/mcp-registry-provider-plugin.md b/workspaces/ai-integrations/.changeset/mcp-registry-provider-plugin.md new file mode 100644 index 0000000000..2da585d145 --- /dev/null +++ b/workspaces/ai-integrations/.changeset/mcp-registry-provider-plugin.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider': minor +--- + +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. diff --git a/workspaces/ai-integrations/.eslintignore b/workspaces/ai-integrations/.eslintignore index 78d283ca04..c48e89a313 100644 --- a/workspaces/ai-integrations/.eslintignore +++ b/workspaces/ai-integrations/.eslintignore @@ -1,3 +1,3 @@ playwright.config.ts !.eslintrc.js -!.prettierrc.js \ No newline at end of file +!.prettierrc.js diff --git a/workspaces/ai-integrations/README.md b/workspaces/ai-integrations/README.md index 711e2ff3dd..1ef9179d35 100644 --- a/workspaces/ai-integrations/README.md +++ b/workspaces/ai-integrations/README.md @@ -29,3 +29,15 @@ If you would like to build with `docker`, add the `--user-docker` tag like so: ``` npx --yes @red-hat-developer-hub/cli@latest plugin package --tag --tag "${PLUGIN_CONTAINER_TAG}" --use-docker ``` + +## MCP Registry + +### Official Live Deployments + +To ingest MCP servers from the official MCP Registry into the catalog, see +[Using Official MCP Registries](./docs/using-official-mcp-registries.md). + +### Deploy Locally + +To run a local MCP Registry for provider development, see +[Deploy MCP Registry Locally](./docs/deploy-mcp-registry-locally.md). diff --git a/workspaces/ai-integrations/app-config.yaml b/workspaces/ai-integrations/app-config.yaml index 35384f3b1b..4f02fb9cf0 100644 --- a/workspaces/ai-integrations/app-config.yaml +++ b/workspaces/ai-integrations/app-config.yaml @@ -123,6 +123,16 @@ catalog: target: ../../examples/ai-model-server-api.yaml rules: - allow: [AiModelServerAPI] + # Example MCP server API entities as produced by + # catalog-backend-module-mcp-registry-provider from the sample registry seed + # at examples/mcp-registry/seed-data/seed.json. + # + # Comment out this location when deploying the MCP Registry provider against + # the same seed (e.g. `MCP_REGISTRY_DATA_DIR=./examples/mcp-registry/seed-data yarn start-local-mcp-registry`). + - type: file + target: ../../examples/api-mcp-servers.yaml + rules: + - allow: [API] providers: modelCatalog: # The field underneath should list the connector plugin ID that the entity provider accesses through @@ -139,7 +149,40 @@ catalog: kubeflow-model-catalog-url: '${KUBEFLOW_MODEL_CATALOG_URL:-}' default-owner: '${OWNER:-default-owner}' default-lifecycle: '${LIFECYCLE:-production}' -# Uncomment to use kubernetesPluginRef — the Backstage kubernetes plugin + mcpRegistry: + # Reserved instance id — only this key is supported today. + mcpRegistry: + # Required: base URL of the MCP Registry + baseUrl: '${MCP_REGISTRY_URL:-http://localhost:8080/}' + # Optional: base name (default: mcp.registry) + # baseName: mcp.registry + # Optional: API version (default: v1) + apiVersion: v0.1 + # Optional: default entity owner (default: unknown) + defaultOwner: '${OWNER:-default-owner}' + # Optional: default entity lifecycle (default: production) + defaultLifecycle: '${LIFECYCLE:-production}' + # Optional: max pages fetched per sync (default: 10) + # pageLimit: 10 + # Optional: registry page size sent as ?limit= (omitted by default) + # pageSize: 50 + # Optional: max entries per complete traversal; soft-stops with end cursor (default: 5000) + # maxEntries: 5000 + # Optional: ingest only servers with at least one native remote (default: false) + # remotesOnly: false + # Optional: request only the latest version of each server via ?version=latest (default: false) + # latestVersion: false + # Optional: allowlist of permitted hostnames for defense-in-depth SSRF protection. + # hostAllowList: + # - registry.modelcontextprotocol.io + # - staging.registry.modelcontextprotocol.io + # Optional: sync schedule (defaults shown below) + # schedule: + # frequency: { minutes: 30 } + # timeout: { minutes: 3 } + # # Optional: defer the first sync + # # initialDelay: { seconds: 15 } +# Uncomment to use kubernetesPluginRef — the Backstage Kubernetes plugin # does NOT need to be installed, only its config section is needed. #kubernetes: # serviceLocatorMethod: diff --git a/workspaces/ai-integrations/docs/deploy-mcp-registry-locally.md b/workspaces/ai-integrations/docs/deploy-mcp-registry-locally.md new file mode 100644 index 0000000000..dc2d23ef8c --- /dev/null +++ b/workspaces/ai-integrations/docs/deploy-mcp-registry-locally.md @@ -0,0 +1,116 @@ +# Deploy MCP Registry Locally + +For local provider development against a real registry instance, this workspace +includes Node scripts under [`scripts/`](../scripts/) that start the upstream +[MCP Registry](https://github.com/modelcontextprotocol/registry) with Podman or +Docker Compose. They use the published +`ghcr.io/modelcontextprotocol/registry` image instead of upstream +`make dev-compose` (which builds with `ko` and does not work with Podman). + +## Prerequisites + +- Node.js 22+ (type stripping for `.ts` scripts) +- `git` +- `podman compose` or `docker compose` + +## Start + +From the `ai-integrations` workspace root, or from +`plugins/catalog-backend-module-mcp-registry-provider`: + +```bash +yarn start-local-mcp-registry +``` + +You can also run the script directly from the workspace root: + +```bash +node scripts/deploy-local-mcp-registry.ts +``` + +This clones the registry into `~/.cache/rhdh-ai-integrations/mcp-registry` +(if needed; override with `MCP_REGISTRY_REPO_DIR`) at tag `v1.8.1` by default +(override with `MCP_REGISTRY_REPO_URL` / `MCP_REGISTRY_REPO_REVISION`), starts +PostgreSQL and the registry in the background, **waits until the HTTP API +responds** (seed import can take a few minutes when seeding from the public +registry), and serves the API at +[http://localhost:8080](http://localhost:8080). + +Start the registry **before** `yarn dev`. If the provider syncs while the +registry is still importing seed data, you will see +`Failed to reach MCP Registry ...` (no mutation). Restart the backend after the +registry is ready, or wait for the next scheduled sync. + +Optional environment variables: + +| Variable | Default | Description | +| ------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_REGISTRY_REPO_DIR` | `~/.cache/rhdh-ai-integrations/mcp-registry` | Local checkout path for the registry | +| `MCP_REGISTRY_REPO_URL` | `https://github.com/modelcontextprotocol/registry.git` | Git remote cloned into `MCP_REGISTRY_REPO_DIR` | +| `MCP_REGISTRY_REPO_REVISION` | `v1.8.1` | Git branch or tag checked out for compose/config | +| `MCP_REGISTRY_IMAGE_NAME` | `ghcr.io/modelcontextprotocol/registry` | Registry container image name (without tag) | +| `MCP_REGISTRY_IMAGE_TAG` | `1.8.1` | Registry container image tag | +| `MCP_REGISTRY_DATA_DIR` | _(checkout `./data`)_ | Host directory mounted at `/data` instead of the [default seed data](https://github.com/modelcontextprotocol/registry/blob/main/data/seed.json). When set, seeds from `data/seed.json` with validation off. | +| `MCP_REGISTRY_URL` | `http://localhost:8080` | URL probed for readiness (and typically used as `catalog.providers.mcpRegistry.mcpRegistry.baseUrl`) | +| `MCP_REGISTRY_API_VERSION` | `v0.1` | Registry HTTP API version path segment used for the readiness probe | +| `MCP_REGISTRY_READY_TIMEOUT_MS` | `300000` (5m) | How long `start-local-mcp-registry` waits for the API before failing | + +Example with custom seed content (directory must contain `seed.json`): + +```bash +MCP_REGISTRY_DATA_DIR=./examples/mcp-registry/seed-data yarn start-local-mcp-registry +``` + +View logs (example with Podman): + +```bash +podman compose -f ~/.cache/rhdh-ai-integrations/mcp-registry/docker-compose.yml logs -f +``` + +## Point the provider at localhost + +Configure `catalog.providers.mcpRegistry.mcpRegistry` to use the local +registry. The default local API version is `v0.1`: + +```yaml +catalog: + providers: + mcpRegistry: + mcpRegistry: + baseUrl: http://localhost:8080 + apiVersion: v0.1 + # Optional when restricting outbound hosts: + # hostAllowList: + # - localhost +``` + +Then start the workspace as usual (`yarn dev` from `workspaces/ai-integrations`). + +See also the +[MCP Registry Provider](../plugins/catalog-backend-module-mcp-registry-provider/) +plugin for full configuration options. + +## Stop + +From the workspace root or +`plugins/catalog-backend-module-mcp-registry-provider`: + +```bash +yarn stop-local-mcp-registry +``` + +Or from the workspace root: + +```bash +node scripts/undeploy-local-mcp-registry.ts +``` + +This runs `compose down` for the same stack. The +`~/.cache/rhdh-ai-integrations/mcp-registry` checkout is left in place so the +next deploy is faster. + +## Official registries alternative + +To point the provider at the production or staging official MCP Registry +instead of a local instance, see +[Using Official MCP Registries](./using-official-mcp-registries.md). diff --git a/workspaces/ai-integrations/docs/server-json-types.md b/workspaces/ai-integrations/docs/server-json-types.md new file mode 100644 index 0000000000..0bcef4c36b --- /dev/null +++ b/workspaces/ai-integrations/docs/server-json-types.md @@ -0,0 +1,179 @@ +# `server.json` types + +TypeScript shapes for MCP Registry **v1.8.1** +[`server.schema.json`](https://raw.githubusercontent.com/modelcontextprotocol/registry/v1.8.1/docs/reference/server-json/draft/server.schema.json) +live in [`src/types.ts`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts). Each table below mirrors one exported +type; headings link to the declaration in source. + +## [`McpServerDocument`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L279) + +Root `server.json` document (`ServerDetail`). Closed shape — only these fields +are allowed. + +| Field | Type | Required | Description | +| ------------- | ------------------------------------------------------------------------------------------ | -------- | --------------------------------------------------------------- | +| `$schema` | `string` | yes | Absolute JSON Schema URI whose basename is `server.schema.json` | +| `name` | `string` | yes | Reverse-DNS server name (`namespace/name`, exactly one `/`) | +| `title` | `string` | no | Optional human-readable display name | +| `description` | `string` | yes | Human-readable explanation of server capabilities | +| `version` | `string` | yes | Server version (semver preferred; ranges rejected) | +| `websiteUrl` | `string` | no | Homepage / docs / project website URL | +| `repository` | [`McpServerRepository`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L176) | no | Source repository metadata | +| `remotes` | [`McpRegistryRemote`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L161)[] | no | Remote transports (`streamable-http` / `sse`) | +| `icons` | [`McpRegistryIcon`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L208)[] | no | UI icons | +| `packages` | [`McpRegistryPackage`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L230)[] | no | Installable package entries | +| `_meta` | [`McpServerMeta`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L260) | no | Reverse-DNS extension metadata | + +## [`McpServerRepository`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L176) + +Repository metadata for browsing and cloning source (`Repository`). + +| Field | Type | Required | Description | +| ----------- | -------- | -------- | ------------------------------------------------------------ | +| `url` | `string` | yes | Repository URL (web browse and git clone) | +| `source` | `string` | yes | Hosting service id (`github`, `gitlab`, `bitbucket`, …) | +| `id` | `string` | no | Hosting-service repo id (stable across renames) | +| `subfolder` | `string` | no | Clean relative path from repo root to the server (monorepos) | + +## [`McpRegistryRemote`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L161) + +Remote transport entry (`RemoteTransport`): `streamable-http` or `sse`, plus +optional URL template variables. + +| Field | Type | Required | Description | +| ----------- | ---------------------------------------------------------------------------------------------------------- | -------- | --------------------------------- | +| `type` | `'streamable-http' \| 'sse'` | yes | Remote transport kind | +| `url` | `string` | yes | Endpoint URL template | +| `headers` | [`McpKeyValueInput`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L62)[] | no | Optional HTTP headers | +| `variables` | `Record` ([`McpInput`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L26)) | no | URL template variable definitions | + +## [`McpRegistryIcon`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L208) + +Icon resource for client UIs (`Icon`). + +| Field | Type | Required | Description | +| ---------- | ------------------------------------------------------------------------------- | -------- | -------------------------------- | +| `src` | `string` | yes | URI of the icon resource | +| `mimeType` | `'image/png' \| 'image/jpeg' \| 'image/jpg' \| 'image/svg+xml' \| 'image/webp'` | no | MIME type override | +| `sizes` | `string[]` | no | Size hints (e.g. `48x48`, `any`) | +| `theme` | `'light' \| 'dark'` | no | Theme the icon is designed for | + +## [`McpRegistryPackage`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L230) + +Installable package entry (`Package`). + +| Field | Type | Required | Description | +| ---------------------- | --------------------------------------------------------------------------------------- | -------- | ------------------------------------------------ | +| `registryType` | `string` | yes | Registry kind (`npm`, `pypi`, `cargo`, `oci`, …) | +| `identifier` | `string` | yes | Package name or download URL | +| `transport` | [`McpLocalTransport`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L147) | yes | Local / package transport config | +| `version` | `string` | no | Specific package version (no ranges) | +| `registryBaseUrl` | `string` | no | Base URL of the package registry | +| `runtimeHint` | `string` | no | Runtime hint (`npx`, `uvx`, `docker`, …) | +| `fileSha256` | `string` | no | SHA-256 of the package file | +| `environmentVariables` | [`McpKeyValueInput`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L62)[] | no | Environment variables for the package | +| `packageArguments` | [`McpArgument`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L101)[] | no | Arguments for the package binary | +| `runtimeArguments` | [`McpArgument`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L101)[] | no | Arguments for the runtime command | + +## [`McpServerMeta`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L260) + +Extension metadata (`ServerDetail._meta`) with reverse-DNS keys. + +| Field | Type | Required | Description | +| ----------------------------------------------------- | ------------------------- | -------- | -------------------------------------------- | +| `io.modelcontextprotocol.registry/publisher-provided` | `Record` | no | Publisher metadata for downstream registries | +| `[key: string]` | `unknown` | no | Additional reverse-DNS namespaced extensions | + +## [`McpLocalTransport`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L147) + +Local / package transport union (`LocalTransport`). + +| Variant | `type` | Description | +| ------------------------------------------------------------------------------------------------ | ------------------- | ---------------------------- | +| [`McpStdioTransport`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L109) | `'stdio'` | Stdio local transport | +| [`McpStreamableHttpTransport`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L119) | `'streamable-http'` | Streamable HTTP transport | +| [`McpSseTransport`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L133) | `'sse'` | Server-Sent Events transport | + +### [`McpStdioTransport`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L109) + +| Field | Type | Required | Description | +| ------ | --------- | -------- | ----------- | +| `type` | `'stdio'` | yes | Literal | + +### [`McpStreamableHttpTransport`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L119) + +| Field | Type | Required | Description | +| --------- | --------------------------------------------------------------------------------------- | -------- | --------------------- | +| `type` | `'streamable-http'` | yes | Literal | +| `url` | `string` | yes | URL template | +| `headers` | [`McpKeyValueInput`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L62)[] | no | Optional HTTP headers | + +### [`McpSseTransport`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L133) + +| Field | Type | Required | Description | +| --------- | --------------------------------------------------------------------------------------- | -------- | ------------------------- | +| `type` | `'sse'` | yes | Literal | +| `url` | `string` | yes | SSE endpoint URL template | +| `headers` | [`McpKeyValueInput`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L62)[] | no | Optional HTTP headers | + +## [`McpInput`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L26) + +Shared input leaf (`Input`) used by env vars, headers, variables, and arguments. + +| Field | Type | Required | Description | +| ------------- | ------------------------------------------------- | -------- | -------------------------------------------- | +| `choices` | `string[]` | no | Allowed values the user must select from | +| `default` | `string` | no | Default value | +| `description` | `string` | no | Human-readable description for clients | +| `format` | `'string' \| 'number' \| 'boolean' \| 'filepath'` | no | Input format hint | +| `isRequired` | `boolean` | no | Whether the input is required | +| `isSecret` | `boolean` | no | Whether the input is a secret value | +| `placeholder` | `string` | no | Placeholder shown during configuration | +| `value` | `string` | no | Fixed value (end users should not configure) | + +## [`McpInputWithVariables`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L51) + +Extends [`McpInput`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L26) with nested `{curly_brace}` variables +(`InputWithVariables`). + +| Field | Type | Required | Description | +| ----------- | ---------------------------------------------------------------------------------------------------------- | -------- | --------------------------------- | +| `variables` | `Record` ([`McpInput`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L26)) | no | Nested variable input definitions | + +## [`McpKeyValueInput`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L62) + +Named key/value input for env vars or headers (`KeyValueInput`). Extends +[`McpInputWithVariables`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L51). + +| Field | Type | Required | Description | +| ------ | -------- | -------- | ----------------------------------- | +| `name` | `string` | yes | Header or environment variable name | + +## [`McpArgument`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L101) + +Package or runtime argument union (`Argument`). + +| Variant | `type` | Description | +| ------------------------------------------------------------------------------------------ | -------------- | -------------------------------- | +| [`McpPositionalArgument`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L73) | `'positional'` | Positional command-line argument | +| [`McpNamedArgument`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L87) | `'named'` | Named flag (`--flag={value}`) | + +### [`McpPositionalArgument`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L73) + +Extends [`McpInputWithVariables`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L51). + +| Field | Type | Required | Description | +| ------------ | -------------- | -------- | ------------------------------------ | +| `type` | `'positional'` | yes | Literal | +| `isRepeated` | `boolean` | no | Whether the argument may be repeated | +| `valueHint` | `string` | no | Identifier / label for the argument | + +### [`McpNamedArgument`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L87) + +Extends [`McpInputWithVariables`](../plugins/catalog-mcp-registry-server-mapping/src/types.ts#L51). + +| Field | Type | Required | Description | +| ------------ | --------- | -------- | ------------------------------------ | +| `type` | `'named'` | yes | Literal | +| `name` | `string` | yes | Flag name, including leading dashes | +| `isRepeated` | `boolean` | no | Whether the argument may be repeated | diff --git a/workspaces/ai-integrations/docs/using-official-mcp-registries.md b/workspaces/ai-integrations/docs/using-official-mcp-registries.md new file mode 100644 index 0000000000..5b06c46f0e --- /dev/null +++ b/workspaces/ai-integrations/docs/using-official-mcp-registries.md @@ -0,0 +1,105 @@ +# Using Official MCP Registries + +The [official MCP Registry](https://github.com/modelcontextprotocol/registry) can be used by the [MCP Registry Provider](../plugins/catalog-backend-module-mcp-registry-provider/) +to ingest published MCP servers into the RHDH catalog as `mcp-server` API +entities. + +The official registry API is documented in the upstream +[Official MCP Registry API](https://github.com/modelcontextprotocol/registry/blob/v1.8.1/docs/reference/api/official-registry-api.md) +(based on the +[generic registry API](https://github.com/modelcontextprotocol/registry/blob/v1.8.1/docs/reference/api/generic-registry-api.md)). +Interactive docs and the OpenAPI spec are available at +[registry.modelcontextprotocol.io/docs](https://registry.modelcontextprotocol.io/docs). + +## Base URLs + +| Environment | Base URL | +| ----------- | -------------------------------------------------- | +| Production | `https://registry.modelcontextprotocol.io` | +| Staging | `https://staging.registry.modelcontextprotocol.io` | + +Listing servers does **not** require authentication. Auth endpoints on the +official registry are only needed for publishing and status updates, which this +provider does not perform. + +## Configure the provider + +Install and register the provider as described in the +[MCP Registry Provider README](../plugins/catalog-backend-module-mcp-registry-provider/README.md), +then set `catalog.providers.mcpRegistry.mcpRegistry` in `app-config.yaml`. +Set `baseUrl` to the production or staging URL from the [table above](#base-urls), +depending on which environment you intend to use. + +The official registry serves the **`v0.1`** API instead of the `v1` default, +set `apiVersion: v0.1` explicitly. + +**Production** + +```yaml +catalog: + providers: + mcpRegistry: + mcpRegistry: + baseUrl: https://registry.modelcontextprotocol.io + apiVersion: v0.1 + # Recommended: restrict outbound hosts (defense-in-depth against SSRF) + hostAllowList: + - registry.modelcontextprotocol.io + # Optional: ingest only the latest version of each server + # latestVersion: true + # Optional: skip package-only / placeholder-remote servers + # remotesOnly: true +``` + +**Staging** + +```yaml +catalog: + providers: + mcpRegistry: + mcpRegistry: + baseUrl: https://staging.registry.modelcontextprotocol.io + apiVersion: v0.1 + hostAllowList: + - staging.registry.modelcontextprotocol.io +``` + +Then start the workspace (`yarn dev` from `workspaces/ai-integrations`) or your +Backstage backend. The provider syncs on its schedule (default: every 30 +minutes). See the +[provider configuration options](../plugins/catalog-backend-module-mcp-registry-provider/README.md#configuration-options) +for `pageLimit`, `pageSize`, `maxEntries`, `schedule`, and related settings. + +**Note**: The [Production](https://registry.modelcontextprotocol.io) environment has +_over 5000 entries_ so it is recommended to review [provider configuration options](../plugins/catalog-backend-module-mcp-registry-provider/README.md#configuration-options) to +configure a setup that respects rate limiting and that works for your deployment resources. + +## How the provider uses the official API + +The provider calls the cursor-paginated list endpoint: + +`GET //servers` + +Against the official registry that is +`GET https://registry.modelcontextprotocol.io/v0.1/servers` (plus optional query +parameters the provider supports). + +| Official registry feature | Provider support | +| ----------------------------------------- | --------------------------------------------------- | +| Cursor pagination (`?cursor=`, `?limit=`) | Yes — via `pageSize` / internal resume | +| `?version=latest` | Yes — set `latestVersion: true` | +| `?search=` | Not used by the provider | +| `?updated_since=` (incremental sync) | Not used — the provider does full traversals | +| `?include_deleted=` | Not used (default listing excludes deleted servers) | +| Server detail / version history endpoints | Not used — entities are built from list entries | +| Publish / auth / status PATCH endpoints | Not used | + +For large registries, raise `pageLimit` / `maxEntries` or rely on resume syncs +as described in the +[provider pagination behavior](../plugins/catalog-backend-module-mcp-registry-provider/README.md#pagination). + +## Local development alternative + +To develop against a local registry instance instead of the public official +API, see +[Deploy MCP Registry Locally](./deploy-mcp-registry-locally.md). diff --git a/workspaces/ai-integrations/examples/api-mcp-servers.yaml b/workspaces/ai-integrations/examples/api-mcp-servers.yaml new file mode 100644 index 0000000000..60cdc8f1da --- /dev/null +++ b/workspaces/ai-integrations/examples/api-mcp-servers.yaml @@ -0,0 +1,203 @@ +--- +# Example mcp-server API entities as produced by +# catalog-backend-module-mcp-registry-provider from the sample registry seed +# at examples/mcp-registry/seed-data/seed.json. +# +# Defaults match app-config.yaml (defaultOwner: default-owner, +# defaultLifecycle: production, baseUrl: http://localhost:8080/, +# default baseName prefix mcp.registry). +# Provider location and sync-status annotations are included. +# +# Wired as a file location in app-config.yaml for local catalog review. +# If the MCP Registry provider is also syncing the same seed, you may see +# duplicate entities — disable one source when comparing. +# Requires @backstage/plugin-catalog-backend-module-ai-model for mcp-server +# API validation (Backstage 1.51+). + +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: mcp.registry__io.example.labs-atlas-search__2.1.0-a5f4e0d4 + description: MCP server that wraps the Atlas Search HTTP API for document discovery + tags: + - mcp + - ai + - example + annotations: + backstage.io/managed-by-location: url:http://localhost:8080 + backstage.io/managed-by-origin-location: url:http://localhost:8080 + backstage.io/source-location: url:https://github.com/example-labs/mcp-servers + modelcontextprotocol.io/name: io.example.labs/atlas-search + modelcontextprotocol.io/packages.0.environmentvariables.0.description: Atlas Search API key + modelcontextprotocol.io/packages.0.environmentvariables.0.isrequired: 'true' + modelcontextprotocol.io/packages.0.environmentvariables.0.issecret: 'true' + modelcontextprotocol.io/packages.0.environmentvariables.0.name: ATLAS_SEARCH_API_KEY + modelcontextprotocol.io/packages.0.identifier: '@example-labs/mcp-server-atlas-search' + modelcontextprotocol.io/packages.0.registrybaseurl: https://registry.npmjs.org + modelcontextprotocol.io/packages.0.registrytype: npm + modelcontextprotocol.io/packages.0.transport.type: stdio + modelcontextprotocol.io/packages.0.version: 2.1.0 + modelcontextprotocol.io/repository.source: github + modelcontextprotocol.io/repository.url: https://github.com/example-labs/mcp-servers + modelcontextprotocol.io/version: 2.1.0 + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-14e325eb: 1.2.0 + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-8a6b76b4: 2025-03-14T09:15:00Z + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provided.tool: npm-publisher + redhat.com/rhdh-mcp-registry-sync-status: ok + title: Atlas Search + links: + - url: https://labs.example.io/mcp/atlas-search + title: Website + - url: https://github.com/example-labs/mcp-servers + title: Source Code +spec: + type: mcp-server + lifecycle: production + owner: default-owner + remotes: + - type: undefined + url: http://localhost:8080/ +--- +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: mcp.registry__io.github.example-labs-workspace-fs__1.4-db809cb2 + description: MCP server for sandboxed workspace filesystem read and write operations. + tags: + - mcp + - ai + - example + annotations: + backstage.io/managed-by-location: url:http://localhost:8080 + backstage.io/managed-by-origin-location: url:http://localhost:8080 + backstage.io/source-location: url:https://github.com/example-labs/mcp-servers + modelcontextprotocol.io/name: io.github.example-labs/workspace-fs + modelcontextprotocol.io/packages.0.environmentvariables.0.default: warn + modelcontextprotocol.io/packages.0.environmentvariables.0.description: Logging level (debug, info, warn, error) + modelcontextprotocol.io/packages.0.environmentvariables.0.name: LOG_LEVEL + modelcontextprotocol.io/packages.0.identifier: '@example-labs/mcp-server-workspace-fs' + modelcontextprotocol.io/packages.0.packagearguments.0.default: /home/developer/workspace + modelcontextprotocol.io/packages.0.packagearguments.0.description: Workspace root directory to expose + modelcontextprotocol.io/packages.0.packagearguments.0.isrepeated: 'true' + modelcontextprotocol.io/packages.0.packagearguments.0.isrequired: 'true' + modelcontextprotocol.io/packages.0.packagearguments.0.type: positional + modelcontextprotocol.io/packages.0.packagearguments.0.valuehint: workspace_root + modelcontextprotocol.io/packages.0.registrybaseurl: https://registry.npmjs.org + modelcontextprotocol.io/packages.0.registrytype: npm + modelcontextprotocol.io/packages.0.transport.type: stdio + modelcontextprotocol.io/packages.0.version: 1.4.1 + modelcontextprotocol.io/packages.1.environmentvariables.0.default: warn + modelcontextprotocol.io/packages.1.environmentvariables.0.description: Logging level (debug, info, warn, error) + modelcontextprotocol.io/packages.1.environmentvariables.0.name: LOG_LEVEL + modelcontextprotocol.io/packages.1.identifier: ghcr.io/example-labs/workspace-fs:1.4.1 + modelcontextprotocol.io/packages.1.packagearguments.0.type: positional + modelcontextprotocol.io/packages.1.packagearguments.0.value: /workspace + modelcontextprotocol.io/packages.1.packagearguments.0.valuehint: workspace_root + modelcontextprotocol.io/packages.1.registrytype: oci + modelcontextprotocol.io/packages.1.runtimearguments.0.description: Bind-mount a host path into the container + modelcontextprotocol.io/packages.1.runtimearguments.0.isrepeated: 'true' + modelcontextprotocol.io/packages.1.runtimearguments.0.isrequired: 'true' + modelcontextprotocol.io/packages.1.runtimearguments.0.name: --mount + modelcontextprotocol.io/packages.1.runtimearguments.0.type: named + modelcontextprotocol.io/packages.1.runtimearguments.0.value: type=bind,src={source_path},dst={target_path} + modelcontextprotocol.io/packages.1.runtimearguments.0.variables.source_path.description: Host path to mount + modelcontextprotocol.io/packages.1.runtimearguments.0.variables.source_path.format: filepath + modelcontextprotocol.io/packages.1.runtimearguments.0.variables.source_path.isrequired: 'true' + modelcontextprotocol.io/packages.1.runtimearguments.0.variables.target_path.default: /workspace + modelcontextprotocol.io/packages.1.runtimearguments.0.variables.target_path.description: Mount point inside the container under `/workspace`. + modelcontextprotocol.io/packages.1.runtimearguments.0.variables.target_path.isrequired: 'true' + modelcontextprotocol.io/packages.1.transport.type: stdio + modelcontextprotocol.io/repository.id: c1a2b3d4-e5f6-7890-abcd-ef1234567890 + modelcontextprotocol.io/repository.source: github + modelcontextprotocol.io/repository.url: https://github.com/example-labs/mcp-servers + modelcontextprotocol.io/version: 1.4.1 + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-14e325eb: 4.0.0 + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-8a6b76b4: 2025-06-02T18:40:00Z + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-b0084041: 9f8e7d6c5b4a3210 + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-eca6f5da: workspace-fs-build-2048 + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-faf17705: staging + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provided.tool: ci-publisher + redhat.com/rhdh-mcp-registry-sync-status: ok + title: Workspace Filesystem + links: + - url: https://github.com/example-labs/mcp-servers + title: Source Code +spec: + type: mcp-server + lifecycle: production + owner: default-owner + remotes: + - type: undefined + url: http://localhost:8080/ +--- +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: mcp.registry__io.github.example-labs-dice-weather-mcpx-cf045e19 + description: NuGet MCP server that returns random dice rolls and sample weather snippets + tags: + - mcp + - ai + - example + annotations: + backstage.io/managed-by-location: url:http://localhost:8080 + backstage.io/managed-by-origin-location: url:http://localhost:8080 + modelcontextprotocol.io/name: io.github.example-labs/dice-weather-mcp + modelcontextprotocol.io/packages.0.identifier: ExampleLabs.DiceWeatherMcp + modelcontextprotocol.io/packages.0.packagearguments.0.type: positional + modelcontextprotocol.io/packages.0.packagearguments.0.value: mcp + modelcontextprotocol.io/packages.0.packagearguments.1.type: positional + modelcontextprotocol.io/packages.0.packagearguments.1.value: start + modelcontextprotocol.io/packages.0.registrybaseurl: https://api.nuget.org/v3/index.json + modelcontextprotocol.io/packages.0.registrytype: nuget + modelcontextprotocol.io/packages.0.transport.type: stdio + modelcontextprotocol.io/packages.0.version: 1.2.0-preview.3 + modelcontextprotocol.io/version: 1.2.0-preview.3 + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-14e325eb: 2.3.1 + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-8a6b76b4: 2025-01-22T11:05:00Z + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-eca6f5da: nuget-dice-weather-101 + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provided.tool: nuget-publisher + redhat.com/rhdh-mcp-registry-sync-status: ok +spec: + type: mcp-server + lifecycle: production + owner: default-owner + remotes: + - type: undefined + url: http://localhost:8080/ +--- +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: mcp.registry__io.example.cloud-remote-workspace__3.1.0-4744af90 + description: Hosted MCP workspace filesystem endpoint for shared team sandboxes + tags: + - mcp + - ai + - example + annotations: + backstage.io/managed-by-location: url:http://localhost:8080 + backstage.io/managed-by-origin-location: url:http://localhost:8080 + backstage.io/source-location: url:https://github.com/example-cloud/remote-workspace-mcp + modelcontextprotocol.io/name: io.example.cloud/remote-workspace + modelcontextprotocol.io/repository.id: a0b1c2d3-e4f5-6789-abcd-ef0123456789 + modelcontextprotocol.io/repository.source: github + modelcontextprotocol.io/repository.url: https://github.com/example-cloud/remote-workspace-mcp + modelcontextprotocol.io/version: 3.1.0 + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-14e325eb: 3.0.2 + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-8a6b76b4: 2025-08-19T13:10:00Z + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-983f3ff8: eu-central-1 + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-b0084041: c3b2a19087fe + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provi-bf4b1861: remote-workspace-deploy-812 + modelcontextprotocol.io/xmeta.io.modelcontextprotocol.registry-publisher-provided.tool: cloud-deployer + redhat.com/rhdh-mcp-registry-sync-status: ok + links: + - url: https://github.com/example-cloud/remote-workspace-mcp + title: Source Code +spec: + type: mcp-server + lifecycle: production + owner: default-owner + remotes: + - type: streamable-http + url: https://mcp.example.cloud/v1/workspace/http diff --git a/workspaces/ai-integrations/examples/mcp-registry/seed-data/seed.json b/workspaces/ai-integrations/examples/mcp-registry/seed-data/seed.json new file mode 100644 index 0000000000..d91be2e840 --- /dev/null +++ b/workspaces/ai-integrations/examples/mcp-registry/seed-data/seed.json @@ -0,0 +1,204 @@ +[ + { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.example.labs/atlas-search", + "description": "MCP server that wraps the Atlas Search HTTP API for document discovery", + "title": "Atlas Search", + "websiteUrl": "https://labs.example.io/mcp/atlas-search", + "repository": { + "url": "https://github.com/example-labs/mcp-servers", + "source": "github" + }, + "version": "2.1.0", + "packages": [ + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "@example-labs/mcp-server-atlas-search", + "version": "2.1.0", + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "name": "ATLAS_SEARCH_API_KEY", + "description": "Atlas Search API key", + "value": "1234567890", + "isRequired": true, + "isSecret": true + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "tool": "npm-publisher", + "version": "1.2.0", + "build_info": { + "timestamp": "2025-03-14T09:15:00Z" + } + } + } + }, + { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.example-labs/workspace-fs", + "description": "MCP server for sandboxed workspace filesystem read and write operations.", + "title": "Workspace Filesystem", + "repository": { + "url": "https://github.com/example-labs/mcp-servers", + "source": "github", + "id": "c1a2b3d4-e5f6-7890-abcd-ef1234567890" + }, + "version": "1.4.1", + "packages": [ + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "@example-labs/mcp-server-workspace-fs", + "version": "1.4.1", + "transport": { + "type": "stdio" + }, + "packageArguments": [ + { + "type": "positional", + "valueHint": "workspace_root", + "description": "Workspace root directory to expose", + "default": "/home/developer/workspace", + "isRequired": true, + "isRepeated": true + } + ], + "environmentVariables": [ + { + "name": "LOG_LEVEL", + "description": "Logging level (debug, info, warn, error)", + "default": "warn" + } + ] + }, + { + "registryType": "oci", + "identifier": "ghcr.io/example-labs/workspace-fs:1.4.1", + "transport": { + "type": "stdio" + }, + "runtimeArguments": [ + { + "type": "named", + "description": "Bind-mount a host path into the container", + "name": "--mount", + "value": "type=bind,src={source_path},dst={target_path}", + "isRequired": true, + "isRepeated": true, + "variables": { + "source_path": { + "description": "Host path to mount", + "format": "filepath", + "isRequired": true + }, + "target_path": { + "description": "Mount point inside the container under `/workspace`.", + "isRequired": true, + "default": "/workspace" + } + } + } + ], + "packageArguments": [ + { + "type": "positional", + "valueHint": "workspace_root", + "value": "/workspace" + } + ], + "environmentVariables": [ + { + "name": "LOG_LEVEL", + "description": "Logging level (debug, info, warn, error)", + "default": "warn" + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "tool": "ci-publisher", + "version": "4.0.0", + "build_info": { + "commit": "9f8e7d6c5b4a3210", + "timestamp": "2025-06-02T18:40:00Z", + "pipeline_id": "workspace-fs-build-2048", + "environment": "staging" + } + } + } + }, + { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.example-labs/dice-weather-mcp", + "description": "NuGet MCP server that returns random dice rolls and sample weather snippets", + "version": "1.2.0-preview.3", + "packages": [ + { + "registryType": "nuget", + "registryBaseUrl": "https://api.nuget.org/v3/index.json", + "identifier": "ExampleLabs.DiceWeatherMcp", + "version": "1.2.0-preview.3", + "transport": { + "type": "stdio" + }, + "packageArguments": [ + { + "type": "positional", + "value": "mcp" + }, + { + "type": "positional", + "value": "start" + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "tool": "nuget-publisher", + "version": "2.3.1", + "build_info": { + "timestamp": "2025-01-22T11:05:00Z", + "pipeline_id": "nuget-dice-weather-101" + } + } + } + }, + { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.example.cloud/remote-workspace", + "description": "Hosted MCP workspace filesystem endpoint for shared team sandboxes", + "repository": { + "url": "https://github.com/example-cloud/remote-workspace-mcp", + "source": "github", + "id": "a0b1c2d3-e4f5-6789-abcd-ef0123456789" + }, + "version": "3.1.0", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.example.cloud/v1/workspace/http" + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "tool": "cloud-deployer", + "version": "3.0.2", + "build_info": { + "commit": "c3b2a19087fe", + "timestamp": "2025-08-19T13:10:00Z", + "deployment_id": "remote-workspace-deploy-812", + "region": "eu-central-1" + } + } + } + } +] diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/examples/server-json/README.md b/workspaces/ai-integrations/examples/mcp-registry/server-json/README.md similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/examples/server-json/README.md rename to workspaces/ai-integrations/examples/mcp-registry/server-json/README.md diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/examples/server-json/npm-oci.server.json b/workspaces/ai-integrations/examples/mcp-registry/server-json/npm-oci.server.json similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/examples/server-json/npm-oci.server.json rename to workspaces/ai-integrations/examples/mcp-registry/server-json/npm-oci.server.json diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/examples/server-json/npm.server.json b/workspaces/ai-integrations/examples/mcp-registry/server-json/npm.server.json similarity index 97% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/examples/server-json/npm.server.json rename to workspaces/ai-integrations/examples/mcp-registry/server-json/npm.server.json index 785ca30d42..02aeed9948 100644 --- a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/examples/server-json/npm.server.json +++ b/workspaces/ai-integrations/examples/mcp-registry/server-json/npm.server.json @@ -22,6 +22,7 @@ { "name": "ATLAS_SEARCH_API_KEY", "description": "Atlas Search API key", + "value": "1234567890", "isRequired": true, "isSecret": true } diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/examples/server-json/nuget-positional.server.json b/workspaces/ai-integrations/examples/mcp-registry/server-json/nuget-positional.server.json similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/examples/server-json/nuget-positional.server.json rename to workspaces/ai-integrations/examples/mcp-registry/server-json/nuget-positional.server.json diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/examples/server-json/remote.server.json b/workspaces/ai-integrations/examples/mcp-registry/server-json/remote.server.json similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/examples/server-json/remote.server.json rename to workspaces/ai-integrations/examples/mcp-registry/server-json/remote.server.json diff --git a/workspaces/ai-integrations/package.json b/workspaces/ai-integrations/package.json index 1a7129681b..da5a842c3c 100644 --- a/workspaces/ai-integrations/package.json +++ b/workspaces/ai-integrations/package.json @@ -10,6 +10,8 @@ "dev:debug": "yarn workspaces foreach -A --include backend --include app --parallel -v -i run start --inspect", "start": "yarn workspace app start", "start-backend": "yarn workspace backend start", + "start-local-mcp-registry": "node scripts/deploy-local-mcp-registry.ts", + "stop-local-mcp-registry": "node scripts/undeploy-local-mcp-registry.ts", "build:backend": "yarn workspace backend build", "tsc": "tsc", "tsc:full": "tsc --skipLibCheck true --incremental false", @@ -64,7 +66,7 @@ }, "prettier": "@backstage/cli/config/prettier", "lint-staged": { - "*.{js,jsx,ts,tsx,mjs,cjs}": [ + "{packages,plugins,scripts}/**/*.{js,jsx,ts,tsx,mjs,cjs}": [ "eslint --fix", "prettier --write" ], diff --git a/workspaces/ai-integrations/packages/backend/package.json b/workspaces/ai-integrations/packages/backend/package.json index 31b7efe348..59280bb9f8 100644 --- a/workspaces/ai-integrations/packages/backend/package.json +++ b/workspaces/ai-integrations/packages/backend/package.json @@ -50,6 +50,7 @@ "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-ai-model-server": "workspace:^", "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-ai-resource-agent": "workspace:^", "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-ai-resource-extensions": "workspace:^", + "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider": "workspace:^", "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-model-catalog": "workspace:^", "@red-hat-developer-hub/backstage-plugin-catalog-techdoc-url-reader-backend": "workspace:^", "@red-hat-developer-hub/backstage-plugin-kserve-kubeflow-connector-backend": "workspace:^", diff --git a/workspaces/ai-integrations/packages/backend/src/index.ts b/workspaces/ai-integrations/packages/backend/src/index.ts index 8ac73ed95b..198d7794b7 100644 --- a/workspaces/ai-integrations/packages/backend/src/index.ts +++ b/workspaces/ai-integrations/packages/backend/src/index.ts @@ -92,6 +92,11 @@ backend.add( '@red-hat-developer-hub/backstage-plugin-catalog-backend-module-ai-model-server' ), ); +backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider' + ), +); backend.add( import( '@red-hat-developer-hub/backstage-plugin-kserve-kubeflow-connector-backend' diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/.eslintrc.js b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/.eslintrc.js similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/.eslintrc.js rename to workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/.eslintrc.js diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/README.md b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/README.md new file mode 100644 index 0000000000..e4369cc6c6 --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/README.md @@ -0,0 +1,165 @@ +# MCP Registry Provider + +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. + +## Installation + +### Prerequisite + +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. + +### Install packages + +From your Backstage root: + +```bash +yarn --cwd packages/backend add \ + @backstage/plugin-catalog-backend-module-ai-model \ + @red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider +``` + +Skip the `ai-model` package if it is already installed. + +### Register modules + +Add both modules to your backend: + +```ts +// packages/backend/src/index.ts +backend.add(import('@backstage/plugin-catalog-backend-module-ai-model')); +backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider' + ), +); +``` + +## Configuration + +Configure the provider in your `app-config.yaml`: + +```yaml +catalog: + providers: + mcpRegistry: + # Reserved instance id — only this key is supported today. + mcpRegistry: + baseUrl: https://registry.example.com + # Optional: override the mapping identity prefix (default: mcp.registry) + # baseName: com.example.registry + # Optional: registry API version slug (default: v1) + # apiVersion: v1 + # Optional: default entity owner (default: unknown) + # defaultOwner: group:default/mcp-admins + # Optional: default entity lifecycle (default: production) + # defaultLifecycle: production + # Optional: max pages fetched per sync (default: 10) + # pageLimit: 10 + # Optional: registry page size sent as ?limit= (omitted by default) + # pageSize: 50 + # Optional: max entries per complete traversal; soft-stops with end cursor (default: 5000) + # maxEntries: 5000 + # Optional: ingest only servers with at least one native remote (default: false) + # remotesOnly: false + # Optional: request only the latest version of each server via ?version=latest (default: false) + # latestVersion: false + # Optional: restrict outbound requests to specific hostnames (defense-in-depth) + # hostAllowList: + # - registry.example.com + # Optional: sync schedule (defaults shown below) + # schedule: + # frequency: { minutes: 30 } + # timeout: { minutes: 3 } + # # Optional: defer the first sync + # # initialDelay: { seconds: 15 } +``` + +### Configuration options + +| Option | Required | Default | Description | +| ------------------ | -------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseUrl` | Yes | — | MCP Registry base URL. Also passed to the mapping as `placeholderRemoteUrl` when a server has no valid remotes. | +| `baseName` | No | `mcp.registry` (mapping default) | Override the mapping identity prefix for `metadata.name` | +| `apiVersion` | No | `v1` | Registry API version slug. The servers endpoint is `//servers`. Note: the live MCP Registry may serve `/v0` or `/v0.1`; set `apiVersion` to match your registry. | +| `defaultOwner` | No | `unknown` (mapping default) | Backstage entity reference used as `spec.owner` | +| `defaultLifecycle` | No | `production` (mapping default) | Value used as `spec.lifecycle` | +| `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). | +| `pageSize` | No | _(registry default)_ | Sent as `?limit=` on each list request. When omitted, the registry's default page size applies. | +| `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. | +| `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. | +| `latestVersion` | No | `false` | When `true`, each list request includes `?version=latest` (for example `//servers?version=latest`). When `false` or omitted, the `version` query parameter is left unset. | +| `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. | +| `schedule` | No | 30m frequency, 3m timeout | `SchedulerServiceTaskScheduleDefinition` controlling sync cadence. The first sync runs after one `frequency` interval unless `initialDelay` is set. | + +### Multiple registries + +`catalog.providers.mcpRegistry` is a map of instance ids so additional +registries can be added later. This implementation only reads the reserved +`mcpRegistry` instance (`catalog.providers.mcpRegistry.mcpRegistry`). Other +instance ids are ignored and a warning is logged that multiple MCP Registry +providers are not supported yet. Use `baseName` on the reserved instance to +override the identity prefix if needed for future multi-registry support. + +## Behavior + +### Pagination + +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. + +### Mapping + +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. + +### Full mutation + +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. + +### Error handling + +- **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. +- **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. +- **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. + +### Annotations + +Each entity carries: + +- `backstage.io/managed-by-location`: `url:` +- `backstage.io/managed-by-origin-location`: `url:` +- `redhat.com/rhdh-mcp-registry-sync-status`: `ok` or `degraded` +- `modelcontextprotocol.io/name`: the server's canonical name +- `modelcontextprotocol.io/version`: the server's version + +Example catalog entities matching this shape (generated from +[`examples/mcp-registry/seed-data/seed.json`](../../examples/mcp-registry/seed-data/seed.json)) +are in [`examples/api-mcp-servers.yaml`](../../examples/api-mcp-servers.yaml). + +## Non-Remote MCP Servers + +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` + +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: + +```json +{ + "query": { + "$all": [ + { "kind": "API" }, + { "spec.type": "mcp-server" }, + { "$not": { "spec.remotes.type": "undefined" } } + ] + } +} +``` + +## MCP Registry + +### Official Live Deployments + +To ingest MCP servers from the official MCP Registry into the catalog, see +[Using Official MCP Registries](../../docs/using-official-mcp-registries.md). + +### Deploy Locally + +To run a local MCP Registry for provider development, see +[Deploy MCP Registry Locally](../../docs/deploy-mcp-registry-locally.md). diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/config.d.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/config.d.ts new file mode 100644 index 0000000000..b0343c61bd --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/config.d.ts @@ -0,0 +1,69 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { SchedulerServiceTaskScheduleDefinitionConfig } from '@backstage/backend-plugin-api'; + +/** Per-registry instance options under `catalog.providers.mcpRegistry.`. */ +interface McpRegistryInstanceConfig { + /** @visibility backend */ + baseUrl: string; + /** @visibility backend */ + baseName?: string; + /** @visibility backend */ + apiVersion?: string; + /** @visibility backend */ + defaultOwner?: string; + /** @visibility backend */ + defaultLifecycle?: string; + /** @visibility backend */ + pageLimit?: number; + /** @visibility backend */ + pageSize?: number; + /** @visibility backend */ + maxEntries?: number; + /** + * When true, only ingest servers that declare at least one native + * remote. Package-only / placeholder-remote servers are skipped. + * + * @visibility backend + */ + remotesOnly?: boolean; + /** + * When true, list requests include `?version=latest` so the registry + * returns only the latest version of each server. + * + * @visibility backend + */ + latestVersion?: boolean; + /** @visibility backend */ + hostAllowList?: string[]; + /** @visibility backend */ + schedule?: SchedulerServiceTaskScheduleDefinitionConfig; +} + +export interface Config { + catalog?: { + providers?: { + /** + * Map of MCP Registry provider instances. This implementation only + * reads the reserved `mcpRegistry` instance id; additional keys are + * ignored with a warning until multi-registry support lands. + */ + mcpRegistry?: { + mcpRegistry?: McpRegistryInstanceConfig; + }; + }; + }; +} diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/dev/index.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/dev/index.ts new file mode 100644 index 0000000000..b1ad56fe92 --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/dev/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { createBackend } from '@backstage/backend-defaults'; +import { mockServices } from '@backstage/backend-test-utils'; + +const backend = createBackend(); + +backend.add(mockServices.auth.factory()); +backend.add(mockServices.httpAuth.factory()); + +backend.add(import('@backstage/plugin-catalog-backend')); +// Since Backstage 1.51.0 this module registers spec.type mcp-server +// (spec.remotes, no spec.definition). Without it, +// BuiltinKindsEntityProcessor rejects the ingested APIs and the catalog +// API never lists them. +backend.add(import('@backstage/plugin-catalog-backend-module-ai-model')); +backend.add(import('../src')); + +backend.start(); diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/package.json b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/package.json new file mode 100644 index 0000000000..5a88aedfbd --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/package.json @@ -0,0 +1,58 @@ +{ + "name": "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider", + "version": "0.1.0", + "license": "Apache-2.0", + "description": "The mcp-registry-provider backend module for the catalog plugin. Provides the MCP Server API catalog entities from a target MCP Registry.", + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/redhat-developer/rhdh-plugins", + "directory": "workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider" + }, + "backstage": { + "role": "backend-plugin-module", + "pluginId": "catalog", + "pluginPackage": "@backstage/plugin-catalog-backend" + }, + "scripts": { + "start": "backstage-cli package start", + "start-local-mcp-registry": "node ../../scripts/deploy-local-mcp-registry.ts", + "stop-local-mcp-registry": "node ../../scripts/undeploy-local-mcp-registry.ts", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "lint:check": "backstage-cli package lint", + "lint:fix": "backstage-cli package lint --fix", + "test": "backstage-cli package test --passWithNoTests --coverage", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "tsc": "tsc", + "prettier:check": "prettier --ignore-unknown --check .", + "prettier:fix": "prettier --ignore-unknown --write ." + }, + "dependencies": { + "@backstage/backend-plugin-api": "^1.10.0", + "@backstage/catalog-model": "^1.10.0", + "@backstage/plugin-catalog-node": "^2.2.4", + "@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping": "workspace:^" + }, + "devDependencies": { + "@backstage/backend-defaults": "^0.17.8", + "@backstage/backend-test-utils": "^1.11.6", + "@backstage/cli": "^0.36.5", + "@backstage/config": "^1.3.8", + "@backstage/plugin-catalog-backend": "^3.9.0", + "@backstage/plugin-catalog-backend-module-ai-model": "^0.1.3", + "@types/supertest": "^2.0.12", + "supertest": "^6.2.4" + }, + "files": [ + "dist" + ] +} diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/report.api.md b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/report.api.md new file mode 100644 index 0000000000..cae42aa4be --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/report.api.md @@ -0,0 +1,50 @@ +## API Report File for "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; +import type { EntityProvider } from '@backstage/plugin-catalog-node'; +import type { EntityProviderConnection } from '@backstage/plugin-catalog-node'; +import type { LoggerService } from '@backstage/backend-plugin-api'; +import type { SchedulerServiceTaskRunner } from '@backstage/backend-plugin-api'; +import type { SchedulerServiceTaskScheduleDefinition } from '@backstage/backend-plugin-api'; + +// @public +const catalogModuleMcpRegistryProvider: BackendFeature; +export default catalogModuleMcpRegistryProvider; + +// @public +export class McpRegistryEntityProvider implements EntityProvider { + constructor( + config: McpRegistryProviderConfig, + logger: LoggerService, + options?: McpRegistryEntityProviderOptions, + ); + // (undocumented) + connect(connection: EntityProviderConnection): Promise; + // (undocumented) + getProviderName(): string; +} + +// @public +export interface McpRegistryEntityProviderOptions { + taskRunner?: SchedulerServiceTaskRunner; +} + +// @public +export interface McpRegistryProviderConfig { + apiVersion?: string; + baseName?: string; + baseUrl: string; + defaultLifecycle?: string; + defaultOwner?: string; + hostAllowList?: string[]; + latestVersion?: boolean; + maxEntries?: number; + pageLimit?: number; + pageSize?: number; + remotesOnly?: boolean; + schedule: SchedulerServiceTaskScheduleDefinition; +} +``` diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/McpRegistryEntityProvider.parts.test.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/McpRegistryEntityProvider.parts.test.ts new file mode 100644 index 0000000000..e23af578e1 --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/McpRegistryEntityProvider.parts.test.ts @@ -0,0 +1,786 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { Entity } from '@backstage/catalog-model'; +import type { DeferredEntity } from '@backstage/plugin-catalog-node'; +import type { McpServerMappingDefaults } from '@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping'; +import type { McpRegistryListResponse, McpRegistryServerEntry } from './client'; +import { McpRegistryEntityProvider } from './McpRegistryEntityProvider'; +import { buildLastGoodKey } from './providerUtils'; +import { + createDefaultConfig, + createMockLogger, + createMockServerDoc, + mockFetchForResponses, +} from './testUtils'; + +const SYNC_STATUS_ANNOTATION = 'redhat.com/rhdh-mcp-registry-sync-status'; +const LOCATION = 'url:https://registry.example.com'; + +type ProviderParts = { + fetchRegistryEntries(): Promise; + mapRegistryEntries( + entries: McpRegistryServerEntry[], + managedByLocation: string, + ): { entities: DeferredEntity[]; hasDegradedEntries: boolean }; + mapRegistryEntry( + entry: McpRegistryServerEntry, + managedByLocation: string, + ): DeferredEntity; + buildMappingDefaults(): McpServerMappingDefaults; + applyProviderAnnotations( + entity: Entity, + managedByLocation: string, + syncStatus: 'ok' | 'degraded', + ): void; + retainLastGoodOnMappingFailure( + entry: McpRegistryServerEntry, + err: unknown, + managedByLocation: string, + ): DeferredEntity | undefined; + rebuildLastGoodIndex(entities: DeferredEntity[]): void; + lastGoodIndex: Map; +}; + +function parts(provider: McpRegistryEntityProvider): ProviderParts { + return provider as unknown as ProviderParts; +} + +function makeDeferred( + name: string, + version: string, + syncStatus: 'ok' | 'degraded' = 'ok', +): DeferredEntity { + return { + locationKey: 'mcp-registry-provider', + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { + name: `${name}-${version}`, + annotations: { + 'modelcontextprotocol.io/name': name, + 'modelcontextprotocol.io/version': version, + [SYNC_STATUS_ANNOTATION]: syncStatus, + }, + }, + spec: { + type: 'mcp-server', + lifecycle: 'experimental', + owner: 'unknown', + remotes: [{ type: 'streamable-http', url: 'https://example.com/mcp' }], + }, + }, + }; +} + +describe('McpRegistryEntityProvider parts', () => { + describe('fetchRegistryEntries', () => { + it('returns the registry server list on success', async () => { + const body: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('a/b', '1.0.0') }], + metadata: { count: 1 }, + }; + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + { fetchApi: mockFetchForResponses([body]) }, + ); + + await expect(parts(provider).fetchRegistryEntries()).resolves.toEqual( + body.servers, + ); + }); + + it('buffers entries and returns undefined when pageLimit leaves more pages', async () => { + const logger = createMockLogger(); + const page1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('a/one', '1.0.0') }], + metadata: { count: 2, nextCursor: 'cursor-1' }, + }; + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ pageLimit: 1 }), + logger, + { fetchApi: mockFetchForResponses([page1]) }, + ); + + await expect( + parts(provider).fetchRegistryEntries(), + ).resolves.toBeUndefined(); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('will resume from the saved cursor'), + ); + }); + + it('resumes from the saved cursor and returns all buffered entries when complete', async () => { + const page1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('a/one', '1.0.0') }], + metadata: { count: 2, nextCursor: 'cursor-1' }, + }; + const page2: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('a/two', '2.0.0') }], + metadata: { count: 2 }, + }; + const fetchFn = mockFetchForResponses([page1, page2]); + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ pageLimit: 1 }), + createMockLogger(), + { fetchApi: fetchFn }, + ); + + await expect( + parts(provider).fetchRegistryEntries(), + ).resolves.toBeUndefined(); + + await expect(parts(provider).fetchRegistryEntries()).resolves.toEqual([ + ...page1.servers, + ...page2.servers, + ]); + + const secondUrl = fetchFn.mock.calls[1][0] as string; + expect(secondUrl).toContain('cursor=cursor-1'); + }); + + it('starts from the beginning again after a complete traversal', async () => { + const firstPassPage: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('a/one', '1.0.0') }], + metadata: { count: 1 }, + }; + const secondPassPage: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('a/two', '2.0.0') }], + metadata: { count: 1 }, + }; + const fetchFn = mockFetchForResponses([firstPassPage, secondPassPage]); + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ pageLimit: 1 }), + createMockLogger(), + { fetchApi: fetchFn }, + ); + + await expect(parts(provider).fetchRegistryEntries()).resolves.toEqual( + firstPassPage.servers, + ); + await expect(parts(provider).fetchRegistryEntries()).resolves.toEqual( + secondPassPage.servers, + ); + + const firstUrl = fetchFn.mock.calls[0][0] as string; + const secondUrl = fetchFn.mock.calls[1][0] as string; + expect(firstUrl).not.toContain('cursor='); + expect(secondUrl).not.toContain('cursor='); + }); + + it('commits buffered entries and saves endCursor when maxEntries is hit', async () => { + const logger = createMockLogger(); + const page1: McpRegistryListResponse = { + servers: Array.from({ length: 3 }, (_, i) => ({ + server: createMockServerDoc(`a/s${i}`, '1.0.0'), + })), + metadata: { count: 6, nextCursor: 'cursor-1' }, + }; + const page2: McpRegistryListResponse = { + servers: Array.from({ length: 3 }, (_, i) => ({ + server: createMockServerDoc(`a/s${i + 3}`, '1.0.0'), + })), + metadata: { count: 6, nextCursor: 'cursor-2' }, + }; + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ maxEntries: 4, pageLimit: 10 }), + logger, + { fetchApi: mockFetchForResponses([page1, page2]) }, + ); + + await expect(parts(provider).fetchRegistryEntries()).resolves.toEqual( + page1.servers, + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('reached maxEntries'), + ); + + const state = provider as unknown as { + endCursor?: string; + endCursorMaxEntries?: number; + }; + expect(state.endCursor).toBe('cursor-1'); + expect(state.endCursorMaxEntries).toBe(4); + }); + + it('stops later traversals at the saved endCursor', async () => { + const page1: McpRegistryListResponse = { + servers: Array.from({ length: 3 }, (_, i) => ({ + server: createMockServerDoc(`a/s${i}`, '1.0.0'), + })), + metadata: { count: 6, nextCursor: 'cursor-1' }, + }; + const page2: McpRegistryListResponse = { + servers: Array.from({ length: 3 }, (_, i) => ({ + server: createMockServerDoc(`a/s${i + 3}`, '1.0.0'), + })), + metadata: { count: 6, nextCursor: 'cursor-2' }, + }; + const secondPass: McpRegistryListResponse = { + servers: Array.from({ length: 3 }, (_, i) => ({ + server: createMockServerDoc(`a/s${i}`, '1.0.0'), + })), + metadata: { count: 6, nextCursor: 'cursor-1' }, + }; + const fetchFn = mockFetchForResponses([page1, page2, secondPass]); + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ maxEntries: 4, pageLimit: 10 }), + createMockLogger(), + { fetchApi: fetchFn }, + ); + + await parts(provider).fetchRegistryEntries(); + await expect(parts(provider).fetchRegistryEntries()).resolves.toEqual( + secondPass.servers, + ); + expect(fetchFn).toHaveBeenCalledTimes(3); + }); + + it('clears endCursor when maxEntries is patched', async () => { + const page1: McpRegistryListResponse = { + servers: Array.from({ length: 3 }, (_, i) => ({ + server: createMockServerDoc(`a/s${i}`, '1.0.0'), + })), + metadata: { count: 6, nextCursor: 'cursor-1' }, + }; + const page2: McpRegistryListResponse = { + servers: Array.from({ length: 3 }, (_, i) => ({ + server: createMockServerDoc(`a/s${i + 3}`, '1.0.0'), + })), + metadata: { count: 6, nextCursor: 'cursor-2' }, + }; + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ maxEntries: 4, pageLimit: 10 }), + createMockLogger(), + { fetchApi: mockFetchForResponses([page1, page2]) }, + ); + + await parts(provider).fetchRegistryEntries(); + const state = provider as unknown as { + endCursor?: string; + endCursorMaxEntries?: number; + config: { maxEntries: number }; + }; + expect(state.endCursor).toBe('cursor-1'); + + state.config.maxEntries = 5000; + const fullPage: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('a/only', '1.0.0') }], + metadata: { count: 1 }, + }; + const fetchFn = mockFetchForResponses([fullPage]); + (provider as unknown as { fetchApi?: typeof fetch }).fetchApi = fetchFn; + + await expect(parts(provider).fetchRegistryEntries()).resolves.toEqual( + fullPage.servers, + ); + expect(state.endCursor).toBeUndefined(); + }); + + it('logs and returns undefined for McpRegistryClientError', async () => { + const logger = createMockLogger(); + const fetchFn = jest.fn().mockResolvedValue({ + ok: false, + status: 503, + text: async () => 'unavailable', + } as unknown as Response); + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + logger, + { fetchApi: fetchFn }, + ); + + await expect( + parts(provider).fetchRegistryEntries(), + ).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + 'MCP Registry sync failed (no mutation emitted)', + ), + ); + }); + + it('wraps transport failures as client errors and returns undefined', async () => { + const logger = createMockLogger(); + const fetchFn = jest + .fn() + .mockRejectedValue(new TypeError('network down')); + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + logger, + { fetchApi: fetchFn }, + ); + + await expect( + parts(provider).fetchRegistryEntries(), + ).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + 'MCP Registry sync failed (no mutation emitted)', + ), + ); + }); + }); + + describe('buildMappingDefaults', () => { + it('uses baseUrl as placeholderRemoteUrl when other overrides are omitted', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + ); + expect(parts(provider).buildMappingDefaults()).toEqual({ + placeholderRemoteUrl: 'https://registry.example.com', + }); + }); + + it('keeps a trailing slash on the configured baseUrl', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ baseUrl: 'https://registry.example.com/' }), + createMockLogger(), + ); + expect(parts(provider).buildMappingDefaults()).toEqual({ + placeholderRemoteUrl: 'https://registry.example.com/', + }); + }); + + it('includes owner and prefix when configured', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ + defaultOwner: 'group:default/mcp-admins', + baseName: 'com.example.registry', + }), + createMockLogger(), + ); + expect(parts(provider).buildMappingDefaults()).toEqual({ + owner: 'group:default/mcp-admins', + prefix: 'com.example.registry', + placeholderRemoteUrl: 'https://registry.example.com', + }); + }); + }); + + describe('applyProviderAnnotations', () => { + it('sets location, origin, and sync-status annotations', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + ); + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { name: 'weather' }, + spec: { type: 'mcp-server' }, + }; + + parts(provider).applyProviderAnnotations(entity, LOCATION, 'ok'); + + expect(entity.metadata.annotations).toEqual({ + 'backstage.io/managed-by-location': LOCATION, + 'backstage.io/managed-by-origin-location': LOCATION, + [SYNC_STATUS_ANNOTATION]: 'ok', + }); + }); + + it('creates the annotations object when missing', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + ); + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { name: 'weather' }, + spec: { type: 'mcp-server' }, + }; + + parts(provider).applyProviderAnnotations(entity, LOCATION, 'degraded'); + + expect(entity.metadata.annotations?.[SYNC_STATUS_ANNOTATION]).toBe( + 'degraded', + ); + }); + }); + + describe('mapRegistryEntry', () => { + it('maps a server entry to a deferred entity with ok sync status', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ defaultOwner: 'user:default/guest' }), + createMockLogger(), + ); + const deferred = parts(provider).mapRegistryEntry( + { server: createMockServerDoc('io.example/weather', '1.0.0') }, + LOCATION, + ); + + expect(deferred.locationKey).toBe('mcp-registry-provider'); + expect(deferred.entity.spec?.owner).toBe('user:default/guest'); + expect(deferred.entity.metadata.annotations).toEqual( + expect.objectContaining({ + 'backstage.io/managed-by-location': LOCATION, + 'backstage.io/managed-by-origin-location': LOCATION, + [SYNC_STATUS_ANNOTATION]: 'ok', + 'modelcontextprotocol.io/name': 'io.example/weather', + 'modelcontextprotocol.io/version': '1.0.0', + }), + ); + }); + + it('uses the registry baseUrl as the placeholder remote when remotes are absent', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + ); + const deferred = parts(provider).mapRegistryEntry( + { + server: createMockServerDoc('io.example/weather', '1.0.0', { + remotes: undefined, + websiteUrl: 'https://website.example.com', + }), + }, + LOCATION, + ); + + expect(deferred.entity.spec).toEqual( + expect.objectContaining({ + remotes: [{ type: 'undefined', url: 'https://registry.example.com' }], + }), + ); + }); + + it('keeps a trailing slash on the placeholder remote url', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ baseUrl: 'https://registry.example.com/' }), + createMockLogger(), + ); + const deferred = parts(provider).mapRegistryEntry( + { + server: createMockServerDoc('io.example/weather', '1.0.0', { + remotes: [], + }), + }, + LOCATION, + ); + + expect(deferred.entity.spec).toEqual( + expect.objectContaining({ + remotes: [ + { type: 'undefined', url: 'https://registry.example.com/' }, + ], + }), + ); + }); + + it('throws when the server document cannot be mapped', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + ); + + expect(() => + parts(provider).mapRegistryEntry( + { + server: { + $schema: + 'https://raw.githubusercontent.com/modelcontextprotocol/registry/v1.8.1/docs/reference/server-json/draft/server.schema.json', + name: 'bad/server', + description: '', + version: '1.0.0', + } as any, + }, + LOCATION, + ), + ).toThrow(); + }); + }); + + describe('mapRegistryEntries', () => { + it('maps successful entries and retains last-good on failure', () => { + const logger = createMockLogger(); + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + logger, + ); + const good = makeDeferred('ok/server', '1.0.0'); + parts(provider).lastGoodIndex.set( + buildLastGoodKey('fail/server', '1.0.0'), + good, + ); + + const result = parts(provider).mapRegistryEntries( + [ + { server: createMockServerDoc('ok/server', '1.0.0') }, + { + server: { + $schema: + 'https://raw.githubusercontent.com/modelcontextprotocol/registry/v1.8.1/docs/reference/server-json/draft/server.schema.json', + name: 'fail/server', + description: '', + version: '1.0.0', + } as any, + }, + ], + LOCATION, + ); + + expect(result.hasDegradedEntries).toBe(true); + expect(result.entities).toHaveLength(2); + expect( + result.entities[0].entity.metadata.annotations?.[ + SYNC_STATUS_ANNOTATION + ], + ).toBe('ok'); + expect( + result.entities[1].entity.metadata.annotations?.[ + SYNC_STATUS_ANNOTATION + ], + ).toBe('degraded'); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('omits failed entries when no last-good exists', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + ); + + const result = parts(provider).mapRegistryEntries( + [ + { + server: { + $schema: + 'https://raw.githubusercontent.com/modelcontextprotocol/registry/v1.8.1/docs/reference/server-json/draft/server.schema.json', + name: 'fail/server', + description: '', + version: '1.0.0', + } as any, + }, + ], + LOCATION, + ); + + expect(result).toEqual({ entities: [], hasDegradedEntries: false }); + }); + + it('skips non-remote entries when remotesOnly is true', () => { + const logger = createMockLogger(); + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ remotesOnly: true }), + logger, + ); + + const result = parts(provider).mapRegistryEntries( + [ + { server: createMockServerDoc('remote/server', '1.0.0') }, + { + server: createMockServerDoc('package/only', '1.0.0', { + remotes: undefined, + }), + }, + { + server: createMockServerDoc('empty/remotes', '1.0.0', { + remotes: [], + }), + }, + ], + LOCATION, + ); + + expect(result.hasDegradedEntries).toBe(false); + expect(result.entities).toHaveLength(1); + expect( + result.entities[0].entity.metadata.annotations?.[ + 'modelcontextprotocol.io/name' + ], + ).toBe('remote/server'); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('remotesOnly skipped 2'), + ); + }); + + it('does not filter non-remote entries when remotesOnly is false', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ remotesOnly: false }), + createMockLogger(), + ); + + const result = parts(provider).mapRegistryEntries( + [ + { server: createMockServerDoc('remote/server', '1.0.0') }, + { + server: createMockServerDoc('package/only', '1.0.0', { + remotes: undefined, + }), + }, + ], + LOCATION, + ); + + expect(result.entities).toHaveLength(2); + }); + }); + + describe('retainLastGoodOnMappingFailure', () => { + it('returns undefined when name or version is missing', () => { + const logger = createMockLogger(); + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + logger, + ); + + const retained = parts(provider).retainLastGoodOnMappingFailure( + { + server: { + ...createMockServerDoc('io.example/weather', '1.0.0'), + version: undefined as unknown as string, + }, + }, + new Error('map failed'), + LOCATION, + ); + + expect(retained).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to map MCP Registry server entry'), + ); + }); + + it('returns undefined and logs when no last-good entity exists', () => { + const logger = createMockLogger(); + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + logger, + ); + + const retained = parts(provider).retainLastGoodOnMappingFailure( + { server: createMockServerDoc('missing/server', '1.0.0') }, + new Error('map failed'), + LOCATION, + ); + + expect(retained).toBeUndefined(); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('No last-good entity found'), + ); + }); + + it('returns a degraded clone of the last-good entity', () => { + const logger = createMockLogger(); + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + logger, + ); + const lastGood = makeDeferred('kept/server', '2.0.0'); + parts(provider).lastGoodIndex.set( + buildLastGoodKey('kept/server', '2.0.0'), + lastGood, + ); + + const retained = parts(provider).retainLastGoodOnMappingFailure( + { server: createMockServerDoc('kept/server', '2.0.0') }, + new Error('map failed'), + LOCATION, + ); + + expect(retained).toBeDefined(); + expect(retained!.entity).not.toBe(lastGood.entity); + expect(retained!.entity.metadata.annotations).toEqual( + expect.objectContaining({ + 'backstage.io/managed-by-location': LOCATION, + 'backstage.io/managed-by-origin-location': LOCATION, + [SYNC_STATUS_ANNOTATION]: 'degraded', + }), + ); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Retained last-good entity'), + ); + }); + }); + + describe('rebuildLastGoodIndex', () => { + it('indexes both ok and degraded entities', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + ); + const ok = makeDeferred('ok/server', '1.0.0', 'ok'); + const degraded = makeDeferred('bad/server', '1.0.0', 'degraded'); + + parts(provider).rebuildLastGoodIndex([ok, degraded]); + + expect(parts(provider).lastGoodIndex.size).toBe(2); + expect( + parts(provider).lastGoodIndex.get( + buildLastGoodKey('ok/server', '1.0.0'), + ), + ).toBe(ok); + expect( + parts(provider).lastGoodIndex.get( + buildLastGoodKey('bad/server', '1.0.0'), + ), + ).toBe(degraded); + }); + + it('skips entities missing name or version annotations', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + ); + const incomplete: DeferredEntity = { + locationKey: 'mcp-registry-provider', + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { + name: 'incomplete', + annotations: { + [SYNC_STATUS_ANNOTATION]: 'ok', + 'modelcontextprotocol.io/name': 'only/name', + }, + }, + spec: { type: 'mcp-server' }, + }, + }; + + parts(provider).rebuildLastGoodIndex([incomplete]); + + expect(parts(provider).lastGoodIndex.size).toBe(0); + }); + + it('clears prior index entries before rebuilding', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + ); + parts(provider).lastGoodIndex.set( + buildLastGoodKey('old/server', '0.1.0'), + makeDeferred('old/server', '0.1.0'), + ); + + parts(provider).rebuildLastGoodIndex([ + makeDeferred('new/server', '2.0.0'), + ]); + + expect(parts(provider).lastGoodIndex.size).toBe(1); + expect( + parts(provider).lastGoodIndex.has( + buildLastGoodKey('old/server', '0.1.0'), + ), + ).toBe(false); + }); + }); +}); diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/McpRegistryEntityProvider.test.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/McpRegistryEntityProvider.test.ts new file mode 100644 index 0000000000..7f6fa04b67 --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/McpRegistryEntityProvider.test.ts @@ -0,0 +1,1124 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { McpRegistryEntityProvider } from './McpRegistryEntityProvider'; +import type { SchedulerServiceTaskRunner } from '@backstage/backend-plugin-api'; +import type { EntityProviderConnection } from '@backstage/plugin-catalog-node'; +import type { McpRegistryListResponse } from './client'; +import { + createDefaultConfig, + createMockLogger, + createMockServerDoc, + mockFetchForResponses, +} from './testUtils'; + +function createMockConnection(): EntityProviderConnection { + return { + applyMutation: jest.fn(), + refresh: jest.fn(), + } as unknown as EntityProviderConnection; +} + +describe('McpRegistryEntityProvider', () => { + it('returns provider name mcp-registry-provider', () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + ); + expect(provider.getProviderName()).toBe('mcp-registry-provider'); + }); + + it('applies documented defaults when optional config fields are omitted', () => { + const provider = new McpRegistryEntityProvider( + { + baseUrl: 'https://registry.example.com', + schedule: { + frequency: { minutes: 30 }, + timeout: { minutes: 3 }, + }, + }, + createMockLogger(), + ); + + const resolved = ( + provider as unknown as { + config: { + apiVersion: string; + pageLimit: number; + maxEntries: number; + remotesOnly: boolean; + latestVersion: boolean; + }; + } + ).config; + + expect(resolved.apiVersion).toBe('v1'); + expect(resolved.pageLimit).toBe(10); + expect(resolved.maxEntries).toBe(5000); + expect(resolved.remotesOnly).toBe(false); + expect(resolved.latestVersion).toBe(false); + }); + + it('registers the refresh task from connect after the catalog connection exists', async () => { + const body: McpRegistryListResponse = { + servers: [], + metadata: { count: 0 }, + }; + const fetchFn = mockFetchForResponses([body]); + const connection = createMockConnection(); + let scheduled: (() => Promise) | undefined; + const taskRunner = { + run: jest.fn(async ({ fn }: { fn: () => Promise }) => { + scheduled = fn; + }), + } as unknown as SchedulerServiceTaskRunner & { + run: jest.Mock; + }; + + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + { fetchApi: fetchFn, taskRunner }, + ); + await provider.connect(connection); + + expect(taskRunner.run).toHaveBeenCalledTimes(1); + expect(scheduled).toBeDefined(); + await scheduled!(); + expect(connection.applyMutation).toHaveBeenCalledTimes(1); + }); + + it('throws if run() is called before connect()', async () => { + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + ); + await expect(provider.run()).rejects.toThrow(/not initialized/); + }); + + it('applies full mutation with mapped entities', async () => { + const body: McpRegistryListResponse = { + servers: [ + { server: createMockServerDoc('io.github.user/weather', '1.0.0') }, + ], + metadata: { count: 1 }, + }; + const fetchFn = mockFetchForResponses([body]); + const connection = createMockConnection(); + const logger = createMockLogger(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + logger, + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + await provider.run(); + + expect(connection.applyMutation).toHaveBeenCalledTimes(1); + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + expect(mutation.type).toBe('full'); + expect(mutation.entities).toHaveLength(1); + + const entity = mutation.entities[0]; + expect(entity.locationKey).toBe('mcp-registry-provider'); + expect(entity.entity.kind).toBe('API'); + expect(entity.entity.spec.type).toBe('mcp-server'); + expect( + entity.entity.metadata.annotations['backstage.io/managed-by-location'], + ).toBe('url:https://registry.example.com'); + expect( + entity.entity.metadata.annotations[ + 'backstage.io/managed-by-origin-location' + ], + ).toBe('url:https://registry.example.com'); + expect( + entity.entity.metadata.annotations[ + 'redhat.com/rhdh-mcp-registry-sync-status' + ], + ).toBe('ok'); + }); + + it('strips trailing slash from baseUrl in managed-by-location', async () => { + const body: McpRegistryListResponse = { + servers: [ + { server: createMockServerDoc('io.github.user/weather', '1.0.0') }, + ], + metadata: { count: 1 }, + }; + const fetchFn = mockFetchForResponses([body]); + const connection = createMockConnection(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ baseUrl: 'https://registry.example.com/' }), + createMockLogger(), + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + await provider.run(); + + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + expect( + mutation.entities[0].entity.metadata.annotations[ + 'backstage.io/managed-by-location' + ], + ).toBe('url:https://registry.example.com'); + expect( + mutation.entities[0].entity.metadata.annotations[ + 'backstage.io/managed-by-origin-location' + ], + ).toBe('url:https://registry.example.com'); + }); + + it('passes defaultOwner to the mapping', async () => { + const body: McpRegistryListResponse = { + servers: [ + { server: createMockServerDoc('io.github.user/weather', '1.0.0') }, + ], + metadata: { count: 1 }, + }; + const fetchFn = mockFetchForResponses([body]); + const connection = createMockConnection(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ + defaultOwner: 'group:default/mcp-admins', + }), + createMockLogger(), + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + await provider.run(); + + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + expect(mutation.entities[0].entity.spec.owner).toBe( + 'group:default/mcp-admins', + ); + }); + + it('passes defaultLifecycle to the mapping', async () => { + const body: McpRegistryListResponse = { + servers: [ + { server: createMockServerDoc('io.github.user/weather', '1.0.0') }, + ], + metadata: { count: 1 }, + }; + const fetchFn = mockFetchForResponses([body]); + const connection = createMockConnection(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ + defaultLifecycle: 'experimental', + }), + createMockLogger(), + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + await provider.run(); + + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + expect(mutation.entities[0].entity.spec.lifecycle).toBe('experimental'); + }); + + it('uses mapping default owner when defaultOwner is omitted', async () => { + const body: McpRegistryListResponse = { + servers: [ + { server: createMockServerDoc('io.github.user/weather', '1.0.0') }, + ], + metadata: { count: 1 }, + }; + const fetchFn = mockFetchForResponses([body]); + const connection = createMockConnection(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + await provider.run(); + + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + expect(mutation.entities[0].entity.spec.owner).toBe('unknown'); + }); + + it('uses mapping default lifecycle when defaultLifecycle is omitted', async () => { + const body: McpRegistryListResponse = { + servers: [ + { server: createMockServerDoc('io.github.user/weather', '1.0.0') }, + ], + metadata: { count: 1 }, + }; + const fetchFn = mockFetchForResponses([body]); + const connection = createMockConnection(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + await provider.run(); + + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + expect(mutation.entities[0].entity.spec.lifecycle).toBe('production'); + }); + + it('passes baseName as prefix override to the mapping', async () => { + const body: McpRegistryListResponse = { + servers: [ + { server: createMockServerDoc('io.github.user/weather', '1.0.2') }, + ], + metadata: { count: 1 }, + }; + const fetchFn = mockFetchForResponses([body]); + const connection = createMockConnection(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ baseName: 'com.example.registry' }), + createMockLogger(), + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + await provider.run(); + + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + // The metadata.name should use the baseName prefix + expect(mutation.entities[0].entity.metadata.name).toContain( + 'com.example.registry', + ); + }); + + it('uses mapping default prefix when baseName is omitted', async () => { + const body: McpRegistryListResponse = { + servers: [ + { server: createMockServerDoc('io.github.user/weather', '1.0.2') }, + ], + metadata: { count: 1 }, + }; + const fetchFn = mockFetchForResponses([body]); + const connection = createMockConnection(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + await provider.run(); + + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + // The metadata.name should use default prefix mcp.registry + expect(mutation.entities[0].entity.metadata.name).toContain('mcp.registry'); + }); + + it('does not emit mutation on registry fetch error', async () => { + const fetchFn = jest.fn().mockRejectedValueOnce(new Error('network')); + const connection = createMockConnection(); + const logger = createMockLogger(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + logger, + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + await provider.run(); + + expect(connection.applyMutation).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + }); + + it('resumes pagination across syncs and mutates only when complete', async () => { + const page1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('io.github.user/one', '1.0.0') }], + metadata: { count: 2, nextCursor: 'cursor-1' }, + }; + const page2: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('io.github.user/two', '2.0.0') }], + metadata: { count: 2 }, + }; + const fetchFn = mockFetchForResponses([page1, page2]); + const connection = createMockConnection(); + const logger = createMockLogger(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ pageLimit: 1 }), + logger, + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + + await provider.run(); + expect(connection.applyMutation).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('will resume from the saved cursor'), + ); + + await provider.run(); + expect(connection.applyMutation).toHaveBeenCalledTimes(1); + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + expect(mutation.entities).toHaveLength(2); + expect(fetchFn.mock.calls[1][0] as string).toContain('cursor=cursor-1'); + }); + + it('continues sync when one entry fails mapping', async () => { + const body: McpRegistryListResponse = { + servers: [ + { server: createMockServerDoc('io.github.user/good', '1.0.0') }, + { + server: { + // Missing required fields - will fail mapping + $schema: + 'https://raw.githubusercontent.com/modelcontextprotocol/registry/v1.8.1/docs/reference/server-json/draft/server.schema.json', + name: '', + description: '', + version: '', + } as any, + }, + ], + metadata: { count: 2 }, + }; + const fetchFn = mockFetchForResponses([body]); + const connection = createMockConnection(); + const logger = createMockLogger(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + logger, + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + await provider.run(); + + expect(connection.applyMutation).toHaveBeenCalledTimes(1); + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + // Only the good server should be in the mutation + expect(mutation.entities).toHaveLength(1); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('retains last-good entity with degraded status on mapping failure', async () => { + const goodBody: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server', '1.0.0') }], + metadata: { count: 1 }, + }; + + const badBody: McpRegistryListResponse = { + servers: [ + { + server: { + $schema: + 'https://raw.githubusercontent.com/modelcontextprotocol/registry/v1.8.1/docs/reference/server-json/draft/server.schema.json', + name: 'test/server', + description: '', + version: '1.0.0', + } as any, + }, + ], + metadata: { count: 1 }, + }; + + // Use a single provider with a combined fetch mock that returns + // good data first, then bad data on the second sync + const combinedFetch = jest.fn(); + // First sync + combinedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => goodBody, + text: async () => JSON.stringify(goodBody), + } as unknown as Response); + // Second sync + combinedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => badBody, + text: async () => JSON.stringify(badBody), + } as unknown as Response); + + const connection2 = createMockConnection(); + const logger2 = createMockLogger(); + const provider2 = new McpRegistryEntityProvider( + createDefaultConfig(), + logger2, + { fetchApi: combinedFetch }, + ); + await provider2.connect(connection2); + + // First sync: populates last-good index + await provider2.run(); + expect(connection2.applyMutation).toHaveBeenCalledTimes(1); + + // Second sync: mapping fails, should retain last-good + await provider2.run(); + expect(connection2.applyMutation).toHaveBeenCalledTimes(2); + + const secondMutation = (connection2.applyMutation as jest.Mock).mock + .calls[1][0]; + expect(secondMutation.entities).toHaveLength(1); + expect( + secondMutation.entities[0].entity.metadata.annotations[ + 'redhat.com/rhdh-mcp-registry-sync-status' + ], + ).toBe('degraded'); + expect(secondMutation.entities[0].locationKey).toBe( + 'mcp-registry-provider', + ); + }); + + it('omits entry on first-time mapping failure with no last-good', async () => { + const body: McpRegistryListResponse = { + servers: [ + { + server: { + $schema: + 'https://raw.githubusercontent.com/modelcontextprotocol/registry/v1.8.1/docs/reference/server-json/draft/server.schema.json', + name: 'test/new-server', + description: '', + version: '1.0.0', + } as any, + }, + ], + metadata: { count: 1 }, + }; + const fetchFn = mockFetchForResponses([body]); + const connection = createMockConnection(); + const logger = createMockLogger(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + logger, + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + await provider.run(); + + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + expect(mutation.entities).toHaveLength(0); + }); + + it('prunes removed servers via full mutation', async () => { + // Use a single provider for both syncs + const body1: McpRegistryListResponse = { + servers: [ + { server: createMockServerDoc('test/server-a', '1.0.0') }, + { server: createMockServerDoc('test/server-b', '1.0.0') }, + ], + metadata: { count: 2 }, + }; + const body2: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 1 }, + }; + const combinedFetch = jest.fn(); + combinedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => body1, + text: async () => JSON.stringify(body1), + } as unknown as Response); + combinedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => body2, + text: async () => JSON.stringify(body2), + } as unknown as Response); + + const connection = createMockConnection(); + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + { fetchApi: combinedFetch }, + ); + await provider.connect(connection); + + // First sync: 2 servers + await provider.run(); + let mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + expect(mutation.entities).toHaveLength(2); + + // Second sync: 1 server (server-b pruned by full mutation) + await provider.run(); + mutation = (connection.applyMutation as jest.Mock).mock.calls[1][0]; + expect(mutation.type).toBe('full'); + expect(mutation.entities).toHaveLength(1); + }); + + it('handles multi-page sync with correct entity count', async () => { + const page1: McpRegistryListResponse = { + servers: [ + { server: createMockServerDoc('test/server-a', '1.0.0') }, + { server: createMockServerDoc('test/server-b', '1.0.0') }, + ], + metadata: { count: 4, nextCursor: 'page2' }, + }; + const page2: McpRegistryListResponse = { + servers: [ + { server: createMockServerDoc('test/server-c', '1.0.0') }, + { server: createMockServerDoc('test/server-d', '1.0.0') }, + ], + metadata: { count: 4 }, + }; + const fetchFn = mockFetchForResponses([page1, page2]); + const connection = createMockConnection(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + await provider.run(); + + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + expect(mutation.entities).toHaveLength(4); + }); + + it('reflects updated server.json in the current mutation', async () => { + const body: McpRegistryListResponse = { + servers: [ + { + server: createMockServerDoc('io.github.user/weather', '1.0.0', { + description: 'Updated weather description', + }), + }, + ], + metadata: { count: 1 }, + }; + const fetchFn = mockFetchForResponses([body]); + const connection = createMockConnection(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + await provider.run(); + + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + expect(mutation.entities[0].entity.metadata.description).toBe( + 'Updated weather description', + ); + }); + + it('commits full mutation with empty entities for empty registry', async () => { + const body: McpRegistryListResponse = { + servers: [], + metadata: { count: 0 }, + }; + const fetchFn = mockFetchForResponses([body]); + const connection = createMockConnection(); + + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + await provider.run(); + + expect(connection.applyMutation).toHaveBeenCalledTimes(1); + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + expect(mutation.type).toBe('full'); + expect(mutation.entities).toHaveLength(0); + }); + + it('does not update lastGoodIndex when applyMutation throws', async () => { + const goodBody: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server', '1.0.0') }], + metadata: { count: 1 }, + }; + + const badBody: McpRegistryListResponse = { + servers: [ + { + server: { + $schema: + 'https://raw.githubusercontent.com/modelcontextprotocol/registry/v1.8.1/docs/reference/server-json/draft/server.schema.json', + name: 'test/server', + description: '', + version: '1.0.0', + } as any, + }, + ], + metadata: { count: 1 }, + }; + + const combinedFetch = jest.fn(); + // First sync — succeeds and populates last-good index + combinedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => goodBody, + text: async () => JSON.stringify(goodBody), + } as unknown as Response); + // Second sync — good data, but applyMutation will throw + combinedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => goodBody, + text: async () => JSON.stringify(goodBody), + } as unknown as Response); + // Third sync — mapping fails, should still use last-good from first sync + combinedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => badBody, + text: async () => JSON.stringify(badBody), + } as unknown as Response); + + const connection = createMockConnection(); + const logger = createMockLogger(); + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + logger, + { fetchApi: combinedFetch }, + ); + await provider.connect(connection); + + // First sync — succeeds, populates last-good + await provider.run(); + expect(connection.applyMutation).toHaveBeenCalledTimes(1); + + // Second sync — applyMutation throws + (connection.applyMutation as jest.Mock).mockRejectedValueOnce( + new Error('catalog unavailable'), + ); + await expect(provider.run()).rejects.toThrow('catalog unavailable'); + + // Third sync — mapping fails; last-good should still be available + // from the first sync (applyMutation throw did not update the index) + (connection.applyMutation as jest.Mock).mockResolvedValueOnce(undefined); + await provider.run(); + expect(connection.applyMutation).toHaveBeenCalledTimes(3); + + const thirdMutation = (connection.applyMutation as jest.Mock).mock + .calls[2][0]; + expect(thirdMutation.entities).toHaveLength(1); + expect( + thirdMutation.entities[0].entity.metadata.annotations[ + 'redhat.com/rhdh-mcp-registry-sync-status' + ], + ).toBe('degraded'); + }); + + it('leaves seenCursors unchanged on fetch error so the next sync resumes correctly', async () => { + // First sync: page 1 succeeds, page 2 fails mid-pagination + const page1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('io.github.user/one', '1.0.0') }], + metadata: { count: 3, nextCursor: 'cursor-1' }, + }; + // Second page fails (non-2xx) + const failPage = { + ok: false, + status: 500, + url: '', + headers: { get: () => null }, + json: async () => ({}), + text: async () => 'Internal Server Error', + } as unknown as Response; + // Retry sync: page 1 again, then page 2 succeeds + const retryPage1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('io.github.user/one', '1.0.0') }], + metadata: { count: 3, nextCursor: 'cursor-1' }, + }; + const retryPage2: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('io.github.user/two', '2.0.0') }], + metadata: { count: 3 }, + }; + + const combinedFetch = jest.fn(); + // First sync: page 1 ok, page 2 fails + combinedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => page1, + text: async () => JSON.stringify(page1), + } as unknown as Response); + combinedFetch.mockResolvedValueOnce(failPage); + // Retry sync: both pages ok + combinedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => retryPage1, + text: async () => JSON.stringify(retryPage1), + } as unknown as Response); + combinedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => retryPage2, + text: async () => JSON.stringify(retryPage2), + } as unknown as Response); + + const connection = createMockConnection(); + const logger = createMockLogger(); + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ pageLimit: 10 }), + logger, + { fetchApi: combinedFetch }, + ); + await provider.connect(connection); + + // First sync: fails mid-pagination (provider keeps prior seenCursors) + await provider.run(); + expect(connection.applyMutation).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('sync failed'), + ); + + // Retry sync: should succeed because the failed call did not + // assign result.seenCursors, so cursor-1 is not incorrectly marked + await provider.run(); + expect(connection.applyMutation).toHaveBeenCalledTimes(1); + const mutation = (connection.applyMutation as jest.Mock).mock.calls[0][0]; + expect(mutation.entities).toHaveLength(2); + }); + + it('keeps re-adding degraded entities on subsequent syncs until refreshed', async () => { + const goodBody: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server', '1.0.0') }], + metadata: { count: 1 }, + }; + + const badBody: McpRegistryListResponse = { + servers: [ + { + server: { + $schema: + 'https://raw.githubusercontent.com/modelcontextprotocol/registry/v1.8.1/docs/reference/server-json/draft/server.schema.json', + name: 'test/server', + description: '', + version: '1.0.0', + } as any, + }, + ], + metadata: { count: 1 }, + }; + + const combinedFetch = jest.fn(); + // First sync — succeeds + combinedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => goodBody, + text: async () => JSON.stringify(goodBody), + } as unknown as Response); + // Second and third syncs — mapping fails; degraded last-good is re-added + combinedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => badBody, + text: async () => JSON.stringify(badBody), + } as unknown as Response); + combinedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => badBody, + text: async () => JSON.stringify(badBody), + } as unknown as Response); + // Fourth sync — mapping succeeds again + combinedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => goodBody, + text: async () => JSON.stringify(goodBody), + } as unknown as Response); + + const connection = createMockConnection(); + const provider = new McpRegistryEntityProvider( + createDefaultConfig(), + createMockLogger(), + { fetchApi: combinedFetch }, + ); + await provider.connect(connection); + + await provider.run(); + await provider.run(); + await provider.run(); + await provider.run(); + + expect(connection.applyMutation).toHaveBeenCalledTimes(4); + const secondMutation = (connection.applyMutation as jest.Mock).mock + .calls[1][0]; + const thirdMutation = (connection.applyMutation as jest.Mock).mock + .calls[2][0]; + const fourthMutation = (connection.applyMutation as jest.Mock).mock + .calls[3][0]; + + expect(secondMutation.entities).toHaveLength(1); + expect( + secondMutation.entities[0].entity.metadata.annotations?.[ + 'redhat.com/rhdh-mcp-registry-sync-status' + ], + ).toBe('degraded'); + expect(thirdMutation.entities).toHaveLength(1); + expect( + thirdMutation.entities[0].entity.metadata.annotations?.[ + 'redhat.com/rhdh-mcp-registry-sync-status' + ], + ).toBe('degraded'); + expect(fourthMutation.entities).toHaveLength(1); + expect( + fourthMutation.entities[0].entity.metadata.annotations?.[ + 'redhat.com/rhdh-mcp-registry-sync-status' + ], + ).toBe('ok'); + }); + + it('retains last-good as degraded when maxEntries soft-stop drops a previously synced seed server after a listing shift', async () => { + const seedDocs = JSON.parse( + readFileSync( + resolve( + __dirname, + '../../../examples/mcp-registry/seed-data/seed.json', + ), + 'utf8', + ), + ) as Array<{ name: string; version: string }>; + + expect(seedDocs).toHaveLength(4); + const [atlas, workspaceFs, diceWeather, remoteWorkspace] = seedDocs; + const inserted = createMockServerDoc('io.example.labs/new-front', '0.1.0'); + + // pageSize=1 so maxEntries=3 soft-stops after three servers and + // saves an end cursor at the fourth page's request cursor. + const firstPassPages: McpRegistryListResponse[] = [ + { + servers: [{ server: atlas as any }], + metadata: { count: 4, nextCursor: 'cursor-1' }, + }, + { + servers: [{ server: workspaceFs as any }], + metadata: { count: 4, nextCursor: 'cursor-2' }, + }, + { + servers: [{ server: diceWeather as any }], + metadata: { count: 4, nextCursor: 'cursor-3' }, + }, + { + servers: [{ server: remoteWorkspace as any }], + metadata: { count: 4, nextCursor: 'cursor-4' }, + }, + ]; + + // New entry at the front shifts listings; traversal still stops at + // endCursor cursor-3, so dice-weather falls out of the window. + const secondPassPages: McpRegistryListResponse[] = [ + { + servers: [{ server: inserted }], + metadata: { count: 5, nextCursor: 'cursor-1' }, + }, + { + servers: [{ server: atlas as any }], + metadata: { count: 5, nextCursor: 'cursor-2' }, + }, + { + servers: [{ server: workspaceFs as any }], + metadata: { count: 5, nextCursor: 'cursor-3' }, + }, + ]; + + const fetchFn = mockFetchForResponses([ + ...firstPassPages, + ...secondPassPages, + ...secondPassPages, + ]); + const connection = createMockConnection(); + const logger = createMockLogger(); + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ + maxEntries: 3, + pageSize: 1, + pageLimit: 10, + defaultOwner: 'default-owner', + }), + logger, + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + + await provider.run(); + expect(connection.applyMutation).toHaveBeenCalledTimes(1); + const firstMutation = (connection.applyMutation as jest.Mock).mock + .calls[0][0]; + const firstNames = firstMutation.entities.map( + (d: { entity: { metadata: { annotations?: Record } } }) => + d.entity.metadata.annotations?.['modelcontextprotocol.io/name'], + ); + expect(firstNames).toEqual([ + atlas.name, + workspaceFs.name, + diceWeather.name, + ]); + expect( + firstMutation.entities.every( + (d: { + entity: { metadata: { annotations?: Record } }; + }) => + d.entity.metadata.annotations?.[ + 'redhat.com/rhdh-mcp-registry-sync-status' + ] === 'ok', + ), + ).toBe(true); + + await provider.run(); + expect(connection.applyMutation).toHaveBeenCalledTimes(2); + const secondMutation = (connection.applyMutation as jest.Mock).mock + .calls[1][0]; + const secondByName = new Map( + secondMutation.entities.map( + (d: { + entity: { metadata: { annotations?: Record } }; + }) => [ + d.entity.metadata.annotations?.['modelcontextprotocol.io/name'], + d.entity.metadata.annotations?.[ + 'redhat.com/rhdh-mcp-registry-sync-status' + ], + ], + ), + ); + + expect(secondByName.get(inserted.name)).toBe('ok'); + expect(secondByName.get(atlas.name)).toBe('ok'); + expect(secondByName.get(workspaceFs.name)).toBe('ok'); + expect(secondByName.get(diceWeather.name)).toBe('degraded'); + expect(secondByName.has(remoteWorkspace.name)).toBe(false); + expect(secondMutation.entities).toHaveLength(4); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('degraded entries'), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining(diceWeather.name), + ); + + // Third sync: still outside the window — degraded must be re-added. + await provider.run(); + expect(connection.applyMutation).toHaveBeenCalledTimes(3); + const thirdMutation = (connection.applyMutation as jest.Mock).mock + .calls[2][0]; + const thirdByName = new Map( + thirdMutation.entities.map( + (d: { + entity: { metadata: { annotations?: Record } }; + }) => [ + d.entity.metadata.annotations?.['modelcontextprotocol.io/name'], + d.entity.metadata.annotations?.[ + 'redhat.com/rhdh-mcp-registry-sync-status' + ], + ], + ), + ); + expect(thirdMutation.entities).toHaveLength(4); + expect(thirdByName.get(diceWeather.name)).toBe('degraded'); + }); + + it('retains last-good as degraded on formatting/mapping failure while maxEntries soft-stop is active', async () => { + const seedDocs = JSON.parse( + readFileSync( + resolve( + __dirname, + '../../../examples/mcp-registry/seed-data/seed.json', + ), + 'utf8', + ), + ) as Array<{ name: string; version: string }>; + + const [atlas, workspaceFs, diceWeather, remoteWorkspace] = seedDocs; + + const firstPassPages: McpRegistryListResponse[] = [ + { + servers: [{ server: atlas as any }], + metadata: { count: 4, nextCursor: 'cursor-1' }, + }, + { + servers: [{ server: workspaceFs as any }], + metadata: { count: 4, nextCursor: 'cursor-2' }, + }, + { + servers: [{ server: diceWeather as any }], + metadata: { count: 4, nextCursor: 'cursor-3' }, + }, + { + servers: [{ server: remoteWorkspace as any }], + metadata: { count: 4, nextCursor: 'cursor-4' }, + }, + ]; + + // Soft-stop window still covers atlas + workspace-fs + dice-weather, + // but dice-weather's payload is now malformed so mapping fails. + const malformedDice = { + $schema: + 'https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json', + name: diceWeather.name, + description: '', + version: diceWeather.version, + }; + const secondPassPages: McpRegistryListResponse[] = [ + { + servers: [{ server: atlas as any }], + metadata: { count: 4, nextCursor: 'cursor-1' }, + }, + { + servers: [{ server: workspaceFs as any }], + metadata: { count: 4, nextCursor: 'cursor-2' }, + }, + { + servers: [{ server: malformedDice as any }], + metadata: { count: 4, nextCursor: 'cursor-3' }, + }, + ]; + + const fetchFn = mockFetchForResponses([ + ...firstPassPages, + ...secondPassPages, + ]); + const connection = createMockConnection(); + const provider = new McpRegistryEntityProvider( + createDefaultConfig({ + maxEntries: 3, + pageSize: 1, + pageLimit: 10, + }), + createMockLogger(), + { fetchApi: fetchFn }, + ); + await provider.connect(connection); + + await provider.run(); + await provider.run(); + + const secondMutation = (connection.applyMutation as jest.Mock).mock + .calls[1][0]; + const diceEntity = secondMutation.entities.find( + (d: { entity: { metadata: { annotations?: Record } } }) => + d.entity.metadata.annotations?.['modelcontextprotocol.io/name'] === + diceWeather.name, + ); + expect(diceEntity).toBeDefined(); + expect( + diceEntity.entity.metadata.annotations?.[ + 'redhat.com/rhdh-mcp-registry-sync-status' + ], + ).toBe('degraded'); + }); +}); diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/McpRegistryEntityProvider.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/McpRegistryEntityProvider.ts new file mode 100644 index 0000000000..a5a082476f --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/McpRegistryEntityProvider.ts @@ -0,0 +1,534 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { + LoggerService, + SchedulerServiceTaskRunner, +} from '@backstage/backend-plugin-api'; +import { + ANNOTATION_LOCATION, + ANNOTATION_ORIGIN_LOCATION, + type Entity, +} from '@backstage/catalog-model'; +import type { + DeferredEntity, + EntityProvider, + EntityProviderConnection, +} from '@backstage/plugin-catalog-node'; +import { + mapServerToEntity, + projectAnnotations, +} from '@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping'; +import type { McpServerMappingDefaults } from '@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping'; +import { + resolveMcpRegistryProviderConfig, + type McpRegistryProviderConfig, + type ResolvedMcpRegistryProviderConfig, +} from './config'; +import { fetchRegistryServers, McpRegistryClientError } from './client'; +import type { McpRegistryServerEntry } from './client'; +import { + buildLastGoodKey, + formatMappingFailureMessage, + hasNativeRemote, + readServerIdentity, +} from './providerUtils'; +import { stripTrailingSlashes } from './util'; + +/** Provider name and locationKey constant. */ +const PROVIDER_NAME = 'mcp-registry-provider'; + +/** Sync status annotation key. */ +const SYNC_STATUS_ANNOTATION = 'redhat.com/rhdh-mcp-registry-sync-status'; + +/** + * Optional constructor dependencies for {@link McpRegistryEntityProvider}. + * + * @public + */ +export interface McpRegistryEntityProviderOptions { + /** + * @internal Override the global `fetch` implementation (test seam). + * Kept non-private so tests can inject it; stripped from published types. + */ + fetchApi?: typeof fetch; + /** Scheduler task runner used to periodically invoke sync. */ + taskRunner?: SchedulerServiceTaskRunner; +} + +/** + * Entity provider that ingests MCP servers from one configured + * MCP Registry into the Backstage catalog. + * + * @public + */ +export class McpRegistryEntityProvider implements EntityProvider { + private connection?: EntityProviderConnection; + private readonly config: ResolvedMcpRegistryProviderConfig; + private readonly logger: LoggerService; + private readonly fetchApi?: typeof fetch; + private readonly taskRunner?: SchedulerServiceTaskRunner; + + /** + * Internal last-good index: keyed by `name::version`, stores the + * last successfully committed DeferredEntity so that a subsequent + * sync can retain it when mapping fails (D6). + */ + private readonly lastGoodIndex = new Map(); + + /** + * Resume state for multi-sync pagination. When a sync hits + * `pageLimit` with more pages remaining, entries fetched so far and + * the next cursor are kept here so the following sync continues + * instead of restarting. Cleared when a traversal reaches the end + * of the registry (no next cursor) or a `maxEntries` soft-stop and a + * full mutation is committed. + */ + private resumeCursor?: string; + private pendingEntries: McpRegistryServerEntry[] = []; + private seenCursors: Set = new Set(); + + /** + * After a `maxEntries` soft-stop, later full traversals end at this + * cursor instead of a missing `nextCursor`. Cleared when + * `maxEntries` is patched (value differs from when it was saved). + */ + private endCursor?: string; + private endCursorMaxEntries?: number; + + constructor( + config: McpRegistryProviderConfig, + logger: LoggerService, + options?: McpRegistryEntityProviderOptions, + ) { + this.config = resolveMcpRegistryProviderConfig(config); + this.logger = logger; + this.fetchApi = options?.fetchApi; + this.taskRunner = options?.taskRunner; + } + + getProviderName(): string { + return PROVIDER_NAME; + } + + async connect(connection: EntityProviderConnection): Promise { + this.connection = connection; + // The scheduler's first tick can run immediately. Register it only + // after the catalog connection exists so that tick can commit. + if (this.taskRunner) { + await this.taskRunner.run({ + id: `${PROVIDER_NAME}:refresh`, + fn: async () => { + await this.run(); + }, + }); + } + } + + /** + * Run one sync cycle: fetch servers from the registry, map them, + * and commit a full mutation. + * + * Intentionally not TypeScript-`private` so unit tests can invoke it + * directly; `@internal` keeps it out of the published API surface. + * + * @internal + */ + async run(): Promise { + if (!this.connection) { + throw new Error( + 'McpRegistryEntityProvider not initialized; call connect() first.', + ); + } + + const managedByLocation = `url:${stripTrailingSlashes( + this.config.baseUrl, + )}`; + const entries = await this.fetchRegistryEntries(); + if (!entries) { + return; + } + + const { entities: mappedEntities, hasDegradedEntries: mappingDegraded } = + this.mapRegistryEntries(entries, managedByLocation); + + const { entities, hasDegradedEntries } = this.appendSoftStopRetained( + mappedEntities, + mappingDegraded, + managedByLocation, + ); + + if (hasDegradedEntries) { + this.logger.warn( + `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.`, + ); + } + + await this.connection.applyMutation({ + type: 'full', + entities, + }); + + this.rebuildLastGoodIndex(entities); + + this.logger.info( + `MCP Registry sync completed: ${entities.length} entities committed.`, + ); + } + + /** + * Fetch registry servers for this sync tick. + * + * Continues from `resumeCursor` when a prior sync stopped at + * `pageLimit`. Returns `undefined` when a client error aborts the + * tick without a mutation, or when more pages remain (entries are + * buffered until the registry is exhausted or `maxEntries` stops the + * traversal so a full mutation does not prune unfetched servers). + * + * Hitting `maxEntries` commits a full mutation of the buffer, saves + * `endCursor`, and starts the next cycle from the beginning. Later + * full traversals stop at that `endCursor` until `maxEntries` is + * patched. + */ + private async fetchRegistryEntries(): Promise< + McpRegistryServerEntry[] | undefined + > { + const { + baseUrl, + apiVersion, + pageLimit, + pageSize, + latestVersion, + maxEntries, + hostAllowList, + } = this.config; + + if ( + this.endCursor !== undefined && + this.endCursorMaxEntries !== maxEntries + ) { + this.logger.info( + `MCP Registry maxEntries changed from ` + + `${this.endCursorMaxEntries} to ${maxEntries}; ` + + `clearing saved endCursor.`, + ); + this.endCursor = undefined; + this.endCursorMaxEntries = undefined; + } + + try { + const result = await fetchRegistryServers({ + baseUrl, + apiVersion, + pageLimit, + pageSize, + latestVersion, + maxEntries, + priorEntryCount: this.pendingEntries.length, + startCursor: this.resumeCursor, + endCursor: this.endCursor, + seenCursors: this.seenCursors, + hostAllowList, + fetchApi: this.fetchApi, + }); + + this.pendingEntries.push(...result.servers); + this.seenCursors = result.seenCursors; + + if (result.resumeCursor) { + this.resumeCursor = result.resumeCursor; + this.logger.info( + `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).`, + ); + return undefined; + } + + if (result.endCursor) { + this.endCursor = result.endCursor; + this.endCursorMaxEntries = maxEntries; + this.logger.warn( + `MCP Registry sync reached maxEntries (${maxEntries}); ` + + `committing ${this.pendingEntries.length} buffered entries ` + + `and saving endCursor for later traversals.`, + ); + } + + // Clear pagination state before returning so applyMutation failures + // start a fresh traversal on the next sync (intended, covered by tests). + const entries = this.pendingEntries; + this.pendingEntries = []; + this.resumeCursor = undefined; + this.seenCursors = new Set(); + return entries; + } catch (err) { + if (err instanceof McpRegistryClientError) { + // Input seenCursors is never mutated by the client; leave + // this.seenCursors unchanged so the next sync can retry. + this.logger.error( + `MCP Registry sync failed (no mutation emitted): ${err.message}`, + ); + return undefined; + } + throw err; + } + } + + /** + * Map every registry entry with per-entry failure isolation. + * When `remotesOnly` is set, entries without a native remote are skipped. + */ + private mapRegistryEntries( + entries: McpRegistryServerEntry[], + managedByLocation: string, + ): { entities: DeferredEntity[]; hasDegradedEntries: boolean } { + const entities: DeferredEntity[] = []; + let hasDegradedEntries = false; + let skippedNonRemote = 0; + + for (const entry of entries) { + if (this.config.remotesOnly && !hasNativeRemote(entry.server)) { + skippedNonRemote += 1; + continue; + } + try { + entities.push(this.mapRegistryEntry(entry, managedByLocation)); + } catch (err) { + const retained = this.retainLastGoodOnMappingFailure( + entry, + err, + managedByLocation, + ); + if (retained) { + entities.push(retained); + hasDegradedEntries = true; + } + } + } + + if (skippedNonRemote > 0) { + this.logger.info( + `MCP Registry remotesOnly skipped ${skippedNonRemote} ` + + `non-remote server entr${skippedNonRemote === 1 ? 'y' : 'ies'}.`, + ); + } + + return { entities, hasDegradedEntries }; + } + + /** + * When a `maxEntries` soft-stop is active (`endCursor` set), retain + * last-good entities that fell outside the truncated window so a full + * mutation does not prune them. Mark retained copies as degraded. + * + * Full traversals without an end bound continue to prune servers that + * are absent from the registry. + */ + private appendSoftStopRetained( + mappedEntities: DeferredEntity[], + hasDegradedEntries: boolean, + managedByLocation: string, + ): { entities: DeferredEntity[]; hasDegradedEntries: boolean } { + if (this.endCursor === undefined || this.lastGoodIndex.size === 0) { + return { entities: mappedEntities, hasDegradedEntries }; + } + + const seenKeys = new Set(); + for (const deferred of mappedEntities) { + const annotations = deferred.entity.metadata?.annotations; + const name = annotations?.['modelcontextprotocol.io/name']; + const version = annotations?.['modelcontextprotocol.io/version']; + if (name && version) { + seenKeys.add(buildLastGoodKey(name, version)); + } + } + + const entities = [...mappedEntities]; + let degraded = hasDegradedEntries; + + for (const [key, lastGood] of this.lastGoodIndex) { + if (seenKeys.has(key)) { + continue; + } + const retainedEntity = structuredClone(lastGood.entity); + this.applyProviderAnnotations( + retainedEntity, + managedByLocation, + 'degraded', + ); + entities.push({ + entity: retainedEntity, + locationKey: PROVIDER_NAME, + }); + degraded = true; + this.logger.warn( + `Retaining last-good entity for "${key}" with degraded sync ` + + `status; it fell outside the maxEntries soft-stop window.`, + ); + } + + return { entities, hasDegradedEntries: degraded }; + } + + /** + * Map one registry entry into a deferred entity with sync status `ok`. + */ + private mapRegistryEntry( + entry: McpRegistryServerEntry, + managedByLocation: string, + ): DeferredEntity { + const serverDoc = entry.server; + const mappingResult = mapServerToEntity( + serverDoc, + this.buildMappingDefaults(), + ); + const entity = mappingResult.entity; + + entity.metadata.annotations = { + ...entity.metadata.annotations, + ...projectAnnotations( + serverDoc, + mappingResult.consumedPaths, + mappingResult.reservedAnnotationKeys, + ), + }; + + this.applyProviderAnnotations(entity, managedByLocation, 'ok'); + + return { + entity, + locationKey: PROVIDER_NAME, + }; + } + + /** + * Build mapping caller overrides from provider config. + * + * `placeholderRemoteUrl` is the target registry `baseUrl` so a server + * with no valid remotes still gets a D8 placeholder pointing at that + * registry, before the mapping falls back to `websiteUrl`. + */ + private buildMappingDefaults(): McpServerMappingDefaults { + const mappingDefaults: McpServerMappingDefaults = { + placeholderRemoteUrl: this.config.baseUrl, + }; + if (this.config.defaultOwner) { + mappingDefaults.owner = this.config.defaultOwner; + } + if (this.config.defaultLifecycle) { + mappingDefaults.lifecycle = this.config.defaultLifecycle; + } + if (this.config.baseName) { + mappingDefaults.prefix = this.config.baseName; + } + return mappingDefaults; + } + + /** + * Stamp provider-owned location and sync-status annotations. + * + * Catalog processing requires both location annotations. Without the + * origin annotation the entity is rejected and never listed. + */ + private applyProviderAnnotations( + entity: Entity, + managedByLocation: string, + syncStatus: 'ok' | 'degraded', + ): void { + if (!entity.metadata.annotations) { + entity.metadata.annotations = {}; + } + entity.metadata.annotations[ANNOTATION_LOCATION] = managedByLocation; + entity.metadata.annotations[ANNOTATION_ORIGIN_LOCATION] = managedByLocation; + entity.metadata.annotations[SYNC_STATUS_ANNOTATION] = syncStatus; + } + + /** + * Log a mapping failure and retain a last-good entity when available (D6). + */ + private retainLastGoodOnMappingFailure( + entry: McpRegistryServerEntry, + err: unknown, + managedByLocation: string, + ): DeferredEntity | undefined { + const { name, version } = readServerIdentity(entry); + this.logger.warn(formatMappingFailureMessage(name, version, err)); + + if (!name || !version) { + return undefined; + } + + const lastGood = this.lastGoodIndex.get(buildLastGoodKey(name, version)); + if (!lastGood) { + this.logger.info( + `No last-good entity found for "${name}" ` + + `version "${version}"; omitting from mutation.`, + ); + return undefined; + } + + const retainedEntity = structuredClone(lastGood.entity); + this.applyProviderAnnotations( + retainedEntity, + managedByLocation, + 'degraded', + ); + + this.logger.info( + `Retained last-good entity for "${name}" ` + + `version "${version}" with degraded sync status.`, + ); + + return { + entity: retainedEntity, + locationKey: PROVIDER_NAME, + }; + } + + /** + * Rebuild the last-good index from every committed entity that has a + * registry identity (`ok` and `degraded` alike). + * + * Degraded entries stay indexed so they can be re-added on later syncs + * until the server is refreshed successfully (`ok`) or omitted from the + * mutation entirely (true prune after a full traversal without soft-stop + * retention). + * + * The annotation keys used here ('modelcontextprotocol.io/name' and + * 'modelcontextprotocol.io/version') are set by mapServerToEntity in + * catalog-mcp-registry-server-mapping and correspond to the raw + * serverDoc.name and serverDoc.version fields used in buildLastGoodKey + * during failure recovery. If the mapping library changes these + * annotation keys, both this rebuild and the failure recovery path + * must be updated in tandem. + */ + private rebuildLastGoodIndex(entities: DeferredEntity[]): void { + this.lastGoodIndex.clear(); + for (const deferred of entities) { + const annotations = deferred.entity.metadata?.annotations; + const name = annotations?.['modelcontextprotocol.io/name']; + const version = annotations?.['modelcontextprotocol.io/version']; + if (name && version) { + this.lastGoodIndex.set(buildLastGoodKey(name, version), deferred); + } + } + } +} diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/catalog.processing.integration.test.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/catalog.processing.integration.test.ts new file mode 100644 index 0000000000..b83e7123f0 --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/catalog.processing.integration.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 request from 'supertest'; +import { ExtendedHttpServer } from '@backstage/backend-defaults/rootHttpRouter'; +import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import type { Entity } from '@backstage/catalog-model'; +import catalogPlugin from '@backstage/plugin-catalog-backend'; +import catalogModuleAiModel from '@backstage/plugin-catalog-backend-module-ai-model'; +import { catalogModuleMcpRegistryProvider } from './module'; + +const REGISTRY_BODY = { + servers: [ + { + server: { + $schema: + 'https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json', + name: 'io.example/weather', + description: 'Weather', + version: '1.0.0', + remotes: [{ type: 'streamable-http', url: 'https://example.com/mcp' }], + }, + }, + ], + metadata: { count: 1 }, +}; + +async function waitForApiEntities( + server: ExtendedHttpServer, + timeoutMs = 40_000, +): Promise { + const start = Date.now(); + let lastBody: unknown; + while (Date.now() - start < timeoutMs) { + const response = await request(server).get( + '/api/catalog/entities?filter=kind=API,spec.type=mcp-server', + ); + lastBody = response.body; + if ( + response.status === 200 && + Array.isArray(response.body) && + response.body.length > 0 + ) { + return response.body as Entity[]; + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + throw new Error( + `Timed out waiting for mcp-server API entities (last body ${JSON.stringify( + lastBody, + )})`, + ); +} + +describe('mcp-server catalog processing', () => { + jest.setTimeout(60_000); + + const originalFetch = global.fetch; + let server: ExtendedHttpServer; + + beforeAll(async () => { + global.fetch = jest.fn(async () => { + return new Response(JSON.stringify(REGISTRY_BODY), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + + const backend = await startTestBackend({ + features: [ + catalogPlugin, + catalogModuleAiModel, + catalogModuleMcpRegistryProvider, + mockServices.rootConfig.factory({ + data: { + app: { baseUrl: 'http://localhost:3000' }, + backend: { + baseUrl: 'http://localhost:7007', + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + catalog: { + processingInterval: { seconds: 1 }, + rules: [{ allow: ['API'] }], + providers: { + mcpRegistry: { + mcpRegistry: { + baseUrl: 'https://registry.example.com', + apiVersion: 'v0.1', + defaultOwner: 'user:default/guest', + schedule: { + frequency: { seconds: 1 }, + timeout: { seconds: 10 }, + }, + }, + }, + }, + }, + }, + }), + mockServices.auth.factory(), + mockServices.httpAuth.factory(), + ], + }); + server = backend.server; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it('lists a committed mcp-server API that omits spec.definition', async () => { + const entities = await waitForApiEntities(server); + expect(entities).toHaveLength(1); + expect(entities[0].kind).toBe('API'); + expect(entities[0].spec).toEqual( + expect.objectContaining({ + type: 'mcp-server', + owner: 'user:default/guest', + }), + ); + expect(entities[0].spec).not.toHaveProperty('definition'); + }); +}); diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/client.test.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/client.test.ts new file mode 100644 index 0000000000..00faccf1ae --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/client.test.ts @@ -0,0 +1,1339 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { + advanceAfterResolvedCursor, + applyMaxEntriesSoftStop, + assertRequestHostAllowed, + assertResponseUrlAllowed, + buildPageRequestUrl, + buildServersEndpoint, + fetchRegistryPage, + fetchRegistryServers, + isAtEndCursor, + isRedirectStatus, + McpRegistryClientError, + parseServersEndpointUrl, + resolveNextCursor, + resolveRedirectUrl, + truncateErrorBody, + validateRedirectTarget, +} from './client'; +import type { McpRegistryListResponse } from './client'; +import { createMockServerDoc } from './testUtils'; + +function mockHeaders(entries: Record = {}): Headers { + return { + get: (name: string) => { + const key = Object.keys(entries).find( + k => k.toLowerCase() === name.toLowerCase(), + ); + return key ? entries[key] : null; + }, + } as Headers; +} + +describe('buildServersEndpoint', () => { + it('constructs endpoint without trailing slash', () => { + expect(buildServersEndpoint('https://registry.example.com', 'v1')).toBe( + 'https://registry.example.com/v1/servers', + ); + }); + + it('constructs endpoint with trailing slash on baseUrl', () => { + expect(buildServersEndpoint('https://registry.example.com/', 'v0')).toBe( + 'https://registry.example.com/v0/servers', + ); + }); + + it('handles multiple trailing slashes', () => { + expect(buildServersEndpoint('https://registry.example.com///', 'v1')).toBe( + 'https://registry.example.com/v1/servers', + ); + }); +}); + +describe('fetchRegistryServers', () => { + function mockFetch( + responses: Array<{ + status?: number; + body?: McpRegistryListResponse | string; + throws?: boolean; + }>, + ): jest.Mock { + const fn = jest.fn(); + for (const resp of responses) { + if (resp.throws) { + fn.mockRejectedValueOnce(new Error('network error')); + } else { + fn.mockImplementationOnce(async (input: RequestInfo) => { + const requestUrl = typeof input === 'string' ? input : String(input); + return { + ok: (resp.status ?? 200) >= 200 && (resp.status ?? 200) < 300, + status: resp.status ?? 200, + url: requestUrl, + json: async () => { + if (typeof resp.body === 'string') { + throw new Error('Invalid JSON'); + } + return resp.body; + }, + text: async () => + typeof resp.body === 'string' + ? resp.body + : JSON.stringify(resp.body), + } as unknown as Response; + }); + } + } + return fn; + } + + it('fetches a single page with no nextCursor', async () => { + const body: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 1 }, + }; + const fn = mockFetch([{ body }]); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(1); + expect(result.servers[0].server.name).toBe('test/server-a'); + expect(result.resumeCursor).toBeUndefined(); + expect(fn).toHaveBeenCalledTimes(1); + // Verify no limit param when pageSize is omitted + const calledUrl = fn.mock.calls[0][0] as string; + expect(calledUrl).not.toContain('limit='); + }); + + it('traverses multiple pages via cursor', async () => { + const page1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 2, nextCursor: 'cursor-abc' }, + }; + const page2: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-b', '2.0.0') }], + metadata: { count: 2 }, + }; + const fn = mockFetch([{ body: page1 }, { body: page2 }]); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(2); + expect(result.resumeCursor).toBeUndefined(); + expect(fn).toHaveBeenCalledTimes(2); + // Second call should include cursor + const secondUrl = fn.mock.calls[1][0] as string; + expect(secondUrl).toContain('cursor=cursor-abc'); + }); + + it('stops on null nextCursor', async () => { + const body: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 1, nextCursor: null }, + }; + const fn = mockFetch([{ body }]); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(1); + expect(result.resumeCursor).toBeUndefined(); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('stops on empty string nextCursor', async () => { + const body: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 1, nextCursor: '' }, + }; + const fn = mockFetch([{ body }]); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(1); + expect(result.resumeCursor).toBeUndefined(); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('sends pageSize as limit query param on every request', async () => { + const page1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 2, nextCursor: 'cursor-xyz' }, + }; + const page2: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-b', '2.0.0') }], + metadata: { count: 2 }, + }; + const fn = mockFetch([{ body: page1 }, { body: page2 }]); + + await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + pageSize: 50, + fetchApi: fn, + }); + + const firstUrl = fn.mock.calls[0][0] as string; + const secondUrl = fn.mock.calls[1][0] as string; + expect(firstUrl).toContain('limit=50'); + expect(secondUrl).toContain('limit=50'); + }); + + it('sends version=latest when latestVersion is true', async () => { + const page1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 1 }, + }; + const fn = mockFetch([{ body: page1 }]); + + await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v0.1', + pageLimit: 10, + latestVersion: true, + fetchApi: fn, + }); + + const requestUrl = fn.mock.calls[0][0] as string; + expect(requestUrl).toBe( + 'https://registry.example.com/v0.1/servers?version=latest', + ); + }); + + it('omits version query param when latestVersion is false', async () => { + const page1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 1 }, + }; + const fn = mockFetch([{ body: page1 }]); + + await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v0.1', + pageLimit: 10, + latestVersion: false, + fetchApi: fn, + }); + + const requestUrl = fn.mock.calls[0][0] as string; + expect(requestUrl).toBe('https://registry.example.com/v0.1/servers'); + expect(requestUrl).not.toContain('version='); + }); + + it('returns a resumeCursor when default pageLimit of 10 is reached with more pages', async () => { + const pages = Array.from({ length: 10 }, (_, i) => ({ + body: { + servers: [{ server: createMockServerDoc(`test/server-${i}`, '1.0.0') }], + metadata: { + count: 11, + nextCursor: `cursor-${i + 1}`, + }, + } as McpRegistryListResponse, + })); + const fn = mockFetch(pages); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(10); + expect(result.resumeCursor).toBe('cursor-10'); + expect(fn).toHaveBeenCalledTimes(10); + }); + + it('returns a resumeCursor when configured pageLimit is reached with more pages', async () => { + const page1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 3, nextCursor: 'cursor-1' }, + }; + const page2: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-b', '2.0.0') }], + metadata: { count: 3, nextCursor: 'cursor-2' }, + }; + const fn = mockFetch([{ body: page1 }, { body: page2 }]); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 2, + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(2); + expect(result.resumeCursor).toBe('cursor-2'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('resumes from startCursor on a follow-up fetch', async () => { + const page3: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-c', '3.0.0') }], + metadata: { count: 3 }, + }; + const fn = mockFetch([{ body: page3 }]); + const seenCursors = new Set(['cursor-1', 'cursor-2']); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 2, + startCursor: 'cursor-2', + seenCursors, + priorEntryCount: 2, + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(1); + expect(result.servers[0].server.name).toBe('test/server-c'); + expect(result.resumeCursor).toBeUndefined(); + expect(result.seenCursors).toEqual(new Set(['cursor-1', 'cursor-2'])); + // Input options set must not be mutated. + expect(seenCursors).toEqual(new Set(['cursor-1', 'cursor-2'])); + expect(fn).toHaveBeenCalledTimes(1); + const calledUrl = fn.mock.calls[0][0] as string; + expect(calledUrl).toContain('cursor=cursor-2'); + }); + + it('returns an updated seenCursors set without mutating the input', async () => { + const page1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 2, nextCursor: 'cursor-1' }, + }; + const page2: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-b', '2.0.0') }], + metadata: { count: 2 }, + }; + const fn = mockFetch([{ body: page1 }, { body: page2 }]); + const seenCursors = new Set(); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + seenCursors, + fetchApi: fn, + }); + + expect(result.seenCursors).toEqual(new Set(['cursor-1'])); + expect(seenCursors.size).toBe(0); + }); + + it('detects repeated cursor', async () => { + const page1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 2, nextCursor: 'cursor-repeat' }, + }; + const page2: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-b', '2.0.0') }], + metadata: { count: 2, nextCursor: 'cursor-repeat' }, + }; + const fn = mockFetch([{ body: page1 }, { body: page2 }]); + + await expect( + fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + fetchApi: fn, + }), + ).rejects.toThrow(/repeated cursor/i); + }); + + it('throws on network error without embedding the Error constructor name', async () => { + const fn = jest.fn().mockRejectedValue( + new TypeError('fetch failed', { + cause: new Error('connect ECONNREFUSED 127.0.0.1:8080'), + }), + ); + + await expect( + fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + fetchApi: fn, + }), + ).rejects.toMatchObject({ + name: 'McpRegistryClientError', + message: + 'Failed to reach MCP Registry at https://registry.example.com/v1/servers: connect ECONNREFUSED 127.0.0.1:8080', + }); + }); + + it('throws on non-2xx status', async () => { + const fn = mockFetch([{ status: 500, body: 'Server Error' }]); + + await expect( + fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + fetchApi: fn, + }), + ).rejects.toThrow(/HTTP 500/); + }); + + it('throws on unparseable JSON', async () => { + const fn = jest.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + text: async () => 'not json', + } as unknown as Response); + + await expect( + fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + fetchApi: fn, + }), + ).rejects.toThrow(/unparseable JSON/); + }); + + it('passes opaque cursor unchanged', async () => { + const opaqueToken = 'eyJsYXN0X2lkIjoiYWJjMTIzIn0='; + const page1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 2, nextCursor: opaqueToken }, + }; + const page2: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-b', '2.0.0') }], + metadata: { count: 2 }, + }; + const fn = mockFetch([{ body: page1 }, { body: page2 }]); + + await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + fetchApi: fn, + }); + + const secondUrl = fn.mock.calls[1][0] as string; + // URL encodes the cursor, but the original value should be present + expect(secondUrl).toContain(`cursor=${encodeURIComponent(opaqueToken)}`); + }); + + it('soft-stops at maxEntries and returns endCursor without the tipping page', async () => { + const page1: McpRegistryListResponse = { + servers: Array.from({ length: 40 }, (_, i) => ({ + server: createMockServerDoc(`test/server-${i}`, '1.0.0'), + })), + metadata: { count: 100, nextCursor: 'cursor-1' }, + }; + const page2: McpRegistryListResponse = { + servers: Array.from({ length: 40 }, (_, i) => ({ + server: createMockServerDoc(`test/server-${i + 40}`, '1.0.0'), + })), + metadata: { count: 100, nextCursor: 'cursor-2' }, + }; + const fn = mockFetch([{ body: page1 }, { body: page2 }]); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + maxEntries: 50, + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(40); + expect(result.resumeCursor).toBeUndefined(); + expect(result.endCursor).toBe('cursor-1'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('keeps a single oversized first page and ends at its nextCursor', async () => { + const largePage: McpRegistryListResponse = { + servers: Array.from({ length: 100 }, (_, i) => ({ + server: createMockServerDoc(`test/server-${i}`, '1.0.0'), + })), + metadata: { count: 100, nextCursor: 'cursor-next' }, + }; + const fn = mockFetch([{ body: largePage }]); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + maxEntries: 50, + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(100); + expect(result.endCursor).toBe('cursor-next'); + }); + + it('succeeds when hostAllowList includes the baseUrl hostname', async () => { + const body: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 1 }, + }; + const fn = mockFetch([{ body }]); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + hostAllowList: ['registry.example.com'], + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(1); + }); + + it('throws when hostAllowList does not include the baseUrl hostname', async () => { + const fn = mockFetch([]); + + await expect( + fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + hostAllowList: ['other.example.com'], + fetchApi: fn, + }), + ).rejects.toThrow(/not in the configured hostAllowList/); + + // Verify no fetch was attempted + expect(fn).not.toHaveBeenCalled(); + }); + + it('throws when a redirect Location points to a disallowed host', async () => { + const fn = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 302, + url: 'https://registry.example.com/v1/servers', + headers: mockHeaders({ + Location: 'https://evil.example.com/v1/servers', + }), + json: async () => ({}), + text: async () => '', + } as unknown as Response); + + await expect( + fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + hostAllowList: ['registry.example.com'], + fetchApi: fn, + }), + ).rejects.toThrow(/not in the configured hostAllowList/); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith('https://registry.example.com/v1/servers', { + redirect: 'manual', + }); + }); + + it('follows an allowlisted redirect Location before reading the body', async () => { + const body: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 1 }, + }; + const fn = jest + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 302, + url: 'https://registry.example.com/v1/servers', + headers: mockHeaders({ + Location: 'https://registry.example.com/v1/servers?redirected=1', + }), + json: async () => ({}), + text: async () => '', + } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + url: 'https://registry.example.com/v1/servers?redirected=1', + headers: mockHeaders(), + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + hostAllowList: ['registry.example.com'], + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(1); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenNthCalledWith( + 2, + 'https://registry.example.com/v1/servers?redirected=1', + { redirect: 'manual' }, + ); + }); + + it('does not enforce maxEntries when unset', async () => { + const largePage: McpRegistryListResponse = { + servers: Array.from({ length: 100 }, (_, i) => ({ + server: createMockServerDoc(`test/server-${i}`, '1.0.0'), + })), + metadata: { count: 100 }, + }; + const fn = mockFetch([{ body: largePage }]); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(100); + expect(result.resumeCursor).toBeUndefined(); + }); + + it('counts priorEntryCount toward maxEntries soft-stop', async () => { + const page: McpRegistryListResponse = { + servers: Array.from({ length: 10 }, (_, i) => ({ + server: createMockServerDoc(`test/server-${i}`, '1.0.0'), + })), + metadata: { count: 10, nextCursor: 'cursor-x' }, + }; + const fn = mockFetch([{ body: page }]); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + maxEntries: 50, + priorEntryCount: 45, + startCursor: 'cursor-prior', + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(0); + expect(result.endCursor).toBe('cursor-prior'); + expect(result.resumeCursor).toBeUndefined(); + }); + + it('stops at a supplied endCursor instead of walking further', async () => { + const page1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 3, nextCursor: 'cursor-end' }, + }; + const fn = mockFetch([{ body: page1 }]); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + endCursor: 'cursor-end', + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(1); + expect(result.resumeCursor).toBeUndefined(); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('returns zero servers when startCursor already matches endCursor', async () => { + const page1: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('test/server-a', '1.0.0') }], + metadata: { count: 1 }, + }; + const fn = mockFetch([{ body: page1 }]); + + const result = await fetchRegistryServers({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + pageLimit: 10, + startCursor: 'cursor-end', + endCursor: 'cursor-end', + fetchApi: fn, + }); + + expect(result.servers).toHaveLength(0); + expect(result.resumeCursor).toBeUndefined(); + expect(result.endCursor).toBeUndefined(); + expect(fn).not.toHaveBeenCalled(); + }); +}); + +describe('parseServersEndpointUrl', () => { + it('returns a URL for a valid endpoint', () => { + const url = parseServersEndpointUrl('https://registry.example.com', 'v1'); + expect(url.toString()).toBe('https://registry.example.com/v1/servers'); + }); + + it('throws McpRegistryClientError for an invalid endpoint URL', () => { + expect(() => parseServersEndpointUrl('://bad', 'v1')).toThrow( + McpRegistryClientError, + ); + expect(() => parseServersEndpointUrl('://bad', 'v1')).toThrow( + /Invalid MCP Registry endpoint URL/, + ); + }); +}); + +describe('buildPageRequestUrl', () => { + const endpoint = new URL('https://registry.example.com/v1/servers'); + + it('returns the endpoint when cursor and pageSize are omitted', () => { + expect(buildPageRequestUrl(endpoint).toString()).toBe( + 'https://registry.example.com/v1/servers', + ); + }); + + it('adds cursor and limit query params when provided', () => { + const url = buildPageRequestUrl(endpoint, 'abc', 25); + expect(url.searchParams.get('cursor')).toBe('abc'); + expect(url.searchParams.get('limit')).toBe('25'); + expect(url.searchParams.get('version')).toBeNull(); + }); + + it('adds version=latest when latestVersion is true', () => { + const url = buildPageRequestUrl(endpoint, undefined, undefined, true); + expect(url.searchParams.get('version')).toBe('latest'); + }); + + it('omits version when latestVersion is false', () => { + const url = buildPageRequestUrl(endpoint, undefined, undefined, false); + expect(url.searchParams.get('version')).toBeNull(); + }); + + it('does not mutate the original endpoint URL', () => { + buildPageRequestUrl(endpoint, 'abc', 25, true); + expect(endpoint.search).toBe(''); + }); +}); + +describe('truncateErrorBody', () => { + it('returns the body unchanged when within the limit', () => { + expect(truncateErrorBody('short')).toBe('short'); + }); + + it('truncates long bodies and appends a marker', () => { + const raw = 'a'.repeat(300); + const truncated = truncateErrorBody(raw, 10); + expect(truncated).toBe(`${'a'.repeat(10)}…(truncated)`); + }); +}); + +describe('fetchRegistryPage', () => { + it('returns a validated list response', async () => { + const body: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('a/b', '1.0.0') }], + metadata: { count: 1 }, + }; + const doFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: mockHeaders(), + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response); + + await expect( + fetchRegistryPage( + doFetch, + new URL('https://registry.example.com/v1/servers'), + ), + ).resolves.toEqual(body); + expect(doFetch).toHaveBeenCalledWith( + 'https://registry.example.com/v1/servers', + { redirect: 'manual' }, + ); + }); + + it('throws when the servers field is missing', async () => { + const doFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: mockHeaders(), + json: async () => ({ metadata: {} }), + text: async () => '{}', + } as unknown as Response); + + await expect( + fetchRegistryPage( + doFetch, + new URL('https://registry.example.com/v1/servers'), + ), + ).rejects.toThrow(/missing "servers" array/); + }); + + it('throws when redirect Location points to a disallowed host', async () => { + const doFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 302, + url: 'https://registry.example.com/v1/servers', + headers: mockHeaders({ + Location: 'https://evil.example.com/v1/servers', + }), + json: async () => ({}), + text: async () => '', + } as unknown as Response); + + await expect( + fetchRegistryPage( + doFetch, + new URL('https://registry.example.com/v1/servers'), + ['registry.example.com'], + ), + ).rejects.toThrow(/not in the configured hostAllowList/); + expect(doFetch).toHaveBeenCalledTimes(1); + }); + + it('throws when hostAllowList is set but response.url is absent', async () => { + const body: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('a/b', '1.0.0') }], + metadata: { count: 1 }, + }; + const doFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: mockHeaders(), + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response); + + await expect( + fetchRegistryPage( + doFetch, + new URL('https://registry.example.com/v1/servers'), + ['registry.example.com'], + ), + ).rejects.toThrow(/missing response\.url/); + }); + + it('follows redirect Location when the target host is allowlisted', async () => { + const body: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('a/b', '1.0.0') }], + metadata: { count: 1 }, + }; + const doFetch = jest + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 301, + url: 'https://registry.example.com/v1/servers', + headers: mockHeaders({ Location: '/v1/servers-mirror' }), + json: async () => ({}), + text: async () => '', + } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + url: 'https://registry.example.com/v1/servers-mirror', + headers: mockHeaders(), + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response); + + await expect( + fetchRegistryPage( + doFetch, + new URL('https://registry.example.com/v1/servers'), + ['registry.example.com'], + ), + ).resolves.toEqual(body); + expect(doFetch).toHaveBeenNthCalledWith( + 2, + 'https://registry.example.com/v1/servers-mirror', + { redirect: 'manual' }, + ); + }); + + it('throws when a redirect is missing the Location header', async () => { + const doFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 302, + headers: mockHeaders(), + json: async () => ({}), + text: async () => '', + } as unknown as Response); + + await expect( + fetchRegistryPage( + doFetch, + new URL('https://registry.example.com/v1/servers'), + ), + ).rejects.toThrow(/without a Location header/); + }); + + it('throws when redirect Location uses a non-http(s) protocol', async () => { + const doFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 302, + headers: mockHeaders({ Location: 'file:///etc/passwd' }), + json: async () => ({}), + text: async () => '', + } as unknown as Response); + + await expect( + fetchRegistryPage( + doFetch, + new URL('https://registry.example.com/v1/servers'), + ), + ).rejects.toThrow(/disallowed protocol/); + }); + + it('throws after exceeding the redirect hop limit', async () => { + const doFetch = jest.fn().mockImplementation(async () => ({ + ok: false, + status: 302, + headers: mockHeaders({ + Location: 'https://registry.example.com/v1/servers?next=1', + }), + json: async () => ({}), + text: async () => '', + })); + + await expect( + fetchRegistryPage( + doFetch, + new URL('https://registry.example.com/v1/servers'), + ), + ).rejects.toThrow(/exceeded 10 redirects/); + expect(doFetch).toHaveBeenCalledTimes(11); + }); + + it('follows redirects when hostAllowList is omitted', async () => { + const body: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('a/b', '1.0.0') }], + metadata: { count: 1 }, + }; + const doFetch = jest + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 307, + headers: mockHeaders({ + Location: 'https://any-host.example.com/v1/servers', + }), + json: async () => ({}), + text: async () => '', + } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: mockHeaders(), + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response); + + await expect( + fetchRegistryPage( + doFetch, + new URL('https://registry.example.com/v1/servers'), + ), + ).resolves.toEqual(body); + }); + + it('follows a multi-hop redirect chain when every hop is allowlisted', async () => { + const body: McpRegistryListResponse = { + servers: [{ server: createMockServerDoc('a/b', '1.0.0') }], + metadata: { count: 1 }, + }; + const doFetch = jest + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 302, + url: 'https://registry.example.com/v1/servers', + headers: mockHeaders({ Location: '/hop-1' }), + json: async () => ({}), + text: async () => '', + } as unknown as Response) + .mockResolvedValueOnce({ + ok: false, + status: 308, + url: 'https://registry.example.com/hop-1', + headers: mockHeaders({ + Location: 'https://registry.example.com/hop-2', + }), + json: async () => ({}), + text: async () => '', + } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + url: 'https://registry.example.com/hop-2', + headers: mockHeaders(), + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response); + + await expect( + fetchRegistryPage( + doFetch, + new URL('https://registry.example.com/v1/servers'), + ['registry.example.com'], + ), + ).resolves.toEqual(body); + expect(doFetch).toHaveBeenCalledTimes(3); + expect(doFetch).toHaveBeenNthCalledWith( + 2, + 'https://registry.example.com/hop-1', + { redirect: 'manual' }, + ); + expect(doFetch).toHaveBeenNthCalledWith( + 3, + 'https://registry.example.com/hop-2', + { redirect: 'manual' }, + ); + }); + + it('throws on an invalid Location without issuing a follow-up request', async () => { + const doFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 302, + headers: mockHeaders({ Location: 'http://[' }), + json: async () => ({}), + text: async () => '', + } as unknown as Response); + + await expect( + fetchRegistryPage( + doFetch, + new URL('https://registry.example.com/v1/servers'), + ), + ).rejects.toThrow(/invalid redirect Location/); + expect(doFetch).toHaveBeenCalledTimes(1); + }); + + it('truncates non-2xx response bodies in the error', async () => { + const doFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + headers: mockHeaders(), + json: async () => ({}), + text: async () => 'x'.repeat(300), + } as unknown as Response); + + await expect( + fetchRegistryPage( + doFetch, + new URL('https://registry.example.com/v1/servers'), + ), + ).rejects.toThrow(/…\(truncated\)/); + }); +}); + +describe('redirect helpers', () => { + it('recognizes redirect status codes', () => { + expect(isRedirectStatus(301)).toBe(true); + expect(isRedirectStatus(302)).toBe(true); + expect(isRedirectStatus(303)).toBe(true); + expect(isRedirectStatus(307)).toBe(true); + expect(isRedirectStatus(308)).toBe(true); + expect(isRedirectStatus(200)).toBe(false); + expect(isRedirectStatus(404)).toBe(false); + }); + + it('resolves absolute and relative Location values', () => { + const current = new URL('https://registry.example.com/v1/servers'); + expect( + resolveRedirectUrl(current, 'https://other.example.com/path').toString(), + ).toBe('https://other.example.com/path'); + expect(resolveRedirectUrl(current, '/v2/servers').toString()).toBe( + 'https://registry.example.com/v2/servers', + ); + }); + + it('throws McpRegistryClientError for an invalid Location value', () => { + expect(() => + resolveRedirectUrl( + new URL('https://registry.example.com/v1/servers'), + 'http://[', + ), + ).toThrow(McpRegistryClientError); + expect(() => + resolveRedirectUrl( + new URL('https://registry.example.com/v1/servers'), + 'http://[', + ), + ).toThrow(/invalid redirect Location/); + }); + + it('rejects non-http(s) redirect targets', () => { + expect(() => + validateRedirectTarget(new URL('ftp://registry.example.com/v1/servers')), + ).toThrow(/disallowed protocol/); + }); + + it('allows http(s) redirect targets and enforces hostAllowList', () => { + expect(() => + validateRedirectTarget( + new URL('https://registry.example.com/v1/servers'), + ['registry.example.com'], + ), + ).not.toThrow(); + expect(() => + validateRedirectTarget( + new URL('http://registry.example.com/v1/servers'), + ['registry.example.com'], + ), + ).not.toThrow(); + expect(() => + validateRedirectTarget(new URL('https://evil.example.com/v1/servers'), [ + 'registry.example.com', + ]), + ).toThrow(/not in the configured hostAllowList/); + }); +}); + +describe('assertRequestHostAllowed', () => { + it('does nothing when hostAllowList is undefined', () => { + expect(() => + assertRequestHostAllowed( + new URL('https://registry.example.com/v1/servers'), + undefined, + ), + ).not.toThrow(); + }); + + it('passes when hostname is in the allow list', () => { + expect(() => + assertRequestHostAllowed( + new URL('https://registry.example.com/v1/servers'), + ['registry.example.com'], + ), + ).not.toThrow(); + }); + + it('throws McpRegistryClientError when hostname is not in the allow list', () => { + expect(() => + assertRequestHostAllowed(new URL('https://evil.example.com/v1/servers'), [ + 'registry.example.com', + ]), + ).toThrow(McpRegistryClientError); + expect(() => + assertRequestHostAllowed(new URL('https://evil.example.com/v1/servers'), [ + 'registry.example.com', + ]), + ).toThrow(/not in the configured hostAllowList/); + }); + + it('matches case-insensitively', () => { + expect(() => + assertRequestHostAllowed( + new URL('https://Registry.Example.COM/v1/servers'), + ['registry.example.com'], + ), + ).not.toThrow(); + }); +}); + +describe('assertResponseUrlAllowed', () => { + it('does nothing when hostAllowList is undefined', () => { + expect(() => + assertResponseUrlAllowed( + { url: '' } as Response, + undefined, + 'https://registry.example.com/v1/servers', + ), + ).not.toThrow(); + }); + + it('throws when response.url is missing under an allowlist', () => { + expect(() => + assertResponseUrlAllowed( + { url: '' } as Response, + ['registry.example.com'], + 'https://registry.example.com/v1/servers', + ), + ).toThrow(/missing response\.url/); + }); + + it('validates response.url against the allowlist', () => { + expect(() => + assertResponseUrlAllowed( + { url: 'https://evil.example.com/v1/servers' } as Response, + ['registry.example.com'], + 'https://registry.example.com/v1/servers', + ), + ).toThrow(/not in the configured hostAllowList/); + expect(() => + assertResponseUrlAllowed( + { url: 'https://registry.example.com/v1/servers' } as Response, + ['registry.example.com'], + 'https://registry.example.com/v1/servers', + ), + ).not.toThrow(); + }); +}); + +describe('resolveNextCursor', () => { + it('returns complete when nextCursor is absent or empty', () => { + const seen = new Set(); + expect(resolveNextCursor(undefined, seen, 1, 10)).toEqual({ + status: 'complete', + }); + expect(resolveNextCursor(null, seen, 1, 10)).toEqual({ + status: 'complete', + }); + expect(resolveNextCursor('', seen, 1, 10)).toEqual({ + status: 'complete', + }); + expect(seen.size).toBe(0); + }); + + it('returns continue without mutating the seen set', () => { + const seen = new Set(); + expect(resolveNextCursor('page-2', seen, 1, 10)).toEqual({ + status: 'continue', + cursor: 'page-2', + }); + expect(seen.size).toBe(0); + }); + + it('throws on a repeated cursor', () => { + const seen = new Set(['page-2']); + expect(() => resolveNextCursor('page-2', seen, 2, 10)).toThrow( + /repeated cursor/, + ); + }); + + it('returns pageLimitReached without mutating the seen set', () => { + const seen = new Set(); + expect(resolveNextCursor('page-2', seen, 1, 1)).toEqual({ + status: 'pageLimitReached', + resumeCursor: 'page-2', + }); + expect(seen.size).toBe(0); + }); +}); + +describe('applyMaxEntriesSoftStop', () => { + it('drops the tipping page and ends at the page cursor', () => { + const tipped = [{ server: createMockServerDoc('a/tip', '1.0.0') }]; + const prior = [{ server: createMockServerDoc('a/keep', '1.0.0') }]; + expect( + applyMaxEntriesSoftStop({ + serversIncludingTippedPage: [...prior, ...tipped], + tippedPageSize: 1, + priorEntryCount: 0, + pageCursor: 'cursor-tip', + startCursor: undefined, + tippedNextCursor: 'cursor-next', + }), + ).toEqual({ + servers: prior, + endCursor: 'cursor-tip', + }); + }); + + it('keeps a single oversized tipped page and ends at its next cursor', () => { + const tipped = [ + { server: createMockServerDoc('a/a', '1.0.0') }, + { server: createMockServerDoc('a/b', '1.0.0') }, + ]; + expect( + applyMaxEntriesSoftStop({ + serversIncludingTippedPage: tipped, + tippedPageSize: 2, + priorEntryCount: 0, + pageCursor: undefined, + startCursor: undefined, + tippedNextCursor: 'cursor-next', + }), + ).toEqual({ + servers: tipped, + endCursor: 'cursor-next', + }); + }); +}); + +describe('advanceAfterResolvedCursor', () => { + it('maps complete without recording a cursor', () => { + const seen = new Set(); + expect( + advanceAfterResolvedCursor({ status: 'complete' }, seen, undefined), + ).toEqual({ action: 'complete' }); + expect(seen.size).toBe(0); + }); + + it('records and resumes when pageLimit is reached', () => { + const seen = new Set(); + expect( + advanceAfterResolvedCursor( + { status: 'pageLimitReached', resumeCursor: 'cursor-2' }, + seen, + undefined, + ), + ).toEqual({ action: 'resume', resumeCursor: 'cursor-2' }); + expect(seen.has('cursor-2')).toBe(true); + }); + + it('stops at endCursor instead of resuming', () => { + const seen = new Set(); + expect( + advanceAfterResolvedCursor( + { status: 'pageLimitReached', resumeCursor: 'end' }, + seen, + 'end', + ), + ).toEqual({ action: 'stopAtEnd' }); + expect(seen.has('end')).toBe(true); + }); + + it('continues paging and records the cursor', () => { + const seen = new Set(); + expect( + advanceAfterResolvedCursor( + { status: 'continue', cursor: 'cursor-2' }, + seen, + undefined, + ), + ).toEqual({ action: 'continue', cursor: 'cursor-2' }); + expect(seen.has('cursor-2')).toBe(true); + }); +}); + +describe('isAtEndCursor', () => { + it('is true only when both values are set and equal', () => { + expect(isAtEndCursor('end', 'end')).toBe(true); + expect(isAtEndCursor('end', 'other')).toBe(false); + expect(isAtEndCursor(undefined, 'end')).toBe(false); + expect(isAtEndCursor('end', undefined)).toBe(false); + }); +}); diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/client.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/client.ts new file mode 100644 index 0000000000..f78cd21b1e --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/client.ts @@ -0,0 +1,636 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { McpServerDocument } from '@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping'; +import { formatErrorDetail, stripTrailingSlashes } from './util'; + +/** Max characters of an error response body included in client errors. */ +const MAX_ERROR_BODY_LENGTH = 256; + +/** Max redirect hops followed for a single page request. */ +const MAX_REDIRECTS = 10; + +/** HTTP statuses treated as redirects to follow manually. */ +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + +/** + * A single server entry from the MCP Registry list response. + */ +export interface McpRegistryServerEntry { + server: McpServerDocument; +} + +/** + * The MCP Registry servers list response shape. + */ +export interface McpRegistryListResponse { + servers: McpRegistryServerEntry[]; + metadata: { + count?: number; + nextCursor?: string | null; + }; +} + +/** + * Error thrown when the registry client encounters a transport or + * protocol error that should abort the sync run. + */ +export class McpRegistryClientError extends Error { + constructor(message: string) { + super(message); + this.name = 'McpRegistryClientError'; + } +} + +/** + * Build the servers endpoint URL from baseUrl and apiVersion, + * normalizing slashes so a trailing slash on baseUrl does not + * produce a double separator. + */ +export function buildServersEndpoint( + baseUrl: string, + apiVersion: string, +): string { + return `${stripTrailingSlashes(baseUrl)}/${apiVersion}/servers`; +} + +/** + * Options for fetching servers from the MCP Registry. + */ +export interface FetchServersOptions { + baseUrl: string; + apiVersion: string; + pageLimit: number; + pageSize?: number; + /** + * When true, each list request includes `?version=latest`. When false + * or omitted, the `version` query parameter is left unset. + */ + latestVersion?: boolean; + /** + * Maximum total entries buffered across the current registry + * traversal (including prior resume syncs) before a full mutation. + * When exceeded, paging stops gracefully: the tipping page is left + * out (unless it is the only page), and `endCursor` is returned so + * the provider can commit what was buffered and bound later + * traversals. + * + * Counts `priorEntryCount` plus servers fetched in this call. + */ + maxEntries?: number; + /** + * Entry count already accumulated earlier in the current multi-sync + * traversal. Used with `maxEntries` so the cap spans resume cycles. + */ + priorEntryCount?: number; + /** + * Cursor to resume from after a prior sync hit `pageLimit`. When + * omitted, the first request starts at the beginning of the list. + */ + startCursor?: string; + /** + * When set (after a prior `maxEntries` soft-stop), a traversal that + * starts from the beginning stops when it would advance to this + * cursor, instead of waiting for a missing `nextCursor`. + */ + endCursor?: string; + /** + * Cursors already seen in the current multi-sync traversal. Shared + * across resume cycles so repeated-cursor detection spans syncs. + * Treated as read-only input; the updated set is returned on + * {@link FetchServersResult.seenCursors}. + */ + seenCursors?: ReadonlySet; + /** + * Optional allowlist of permitted hostnames. When set, every + * outbound request URL is validated against this list before + * fetching, providing defense-in-depth against SSRF. + */ + hostAllowList?: string[]; + /** Optional fetch implementation for testing. */ + fetchApi?: typeof fetch; +} + +/** + * Result of one `fetchRegistryServers` call (up to `pageLimit` pages). + * + * @internal + */ +export interface FetchServersResult { + /** Servers fetched during this call. */ + servers: McpRegistryServerEntry[]; + /** + * When set, more pages remain after this call stopped at `pageLimit`. + * The next sync should pass this as `startCursor`. + */ + resumeCursor?: string; + /** + * When set, this call stopped because `maxEntries` was exceeded. + * The provider should commit a full mutation of the buffer and + * remember this cursor as the end bound for later full traversals. + */ + endCursor?: string; + /** + * Cursor set after this call, including any newly observed cursors. + * Callers should replace their prior set with this value on success. + */ + seenCursors: Set; +} + +/** + * Outcome of resolving the registry's `nextCursor` for pagination. + * + * @internal + */ +export type ResolveNextCursorResult = + | { status: 'complete' } + | { status: 'continue'; cursor: string } + | { status: 'pageLimitReached'; resumeCursor: string }; + +/** + * Parse the servers list endpoint into a URL. + * + * @internal + */ +export function parseServersEndpointUrl( + baseUrl: string, + apiVersion: string, +): URL { + const endpoint = buildServersEndpoint(baseUrl, apiVersion); + try { + return new URL(endpoint); + } catch (err) { + throw new McpRegistryClientError( + `Invalid MCP Registry endpoint URL "${endpoint}": ${formatErrorDetail( + err, + )}`, + ); + } +} + +/** + * Build a page request URL with optional cursor, page-size, and + * latest-version query params. + * + * @internal + */ +export function buildPageRequestUrl( + endpoint: URL, + cursor?: string, + pageSize?: number, + latestVersion?: boolean, +): URL { + const url = new URL(endpoint.toString()); + if (cursor) { + url.searchParams.set('cursor', cursor); + } + if (pageSize !== undefined) { + url.searchParams.set('limit', String(pageSize)); + } + if (latestVersion) { + url.searchParams.set('version', 'latest'); + } + return url; +} + +/** + * Truncate an error response body for safe inclusion in log/error text. + * + * @internal + */ +export function truncateErrorBody( + rawBody: string, + maxLength = MAX_ERROR_BODY_LENGTH, +): string { + if (rawBody.length <= maxLength) { + return rawBody; + } + return `${rawBody.substring(0, maxLength)}…(truncated)`; +} + +/** + * Whether an HTTP status code is a redirect this client follows. + * + * @internal + */ +export function isRedirectStatus(status: number): boolean { + return REDIRECT_STATUSES.has(status); +} + +/** + * Resolve a redirect `Location` header against the current request URL. + * + * @internal + */ +export function resolveRedirectUrl(currentUrl: URL, location: string): URL { + try { + return new URL(location, currentUrl); + } catch (err) { + throw new McpRegistryClientError( + `MCP Registry returned an invalid redirect Location "${location}" ` + + `from ${currentUrl}: ${formatErrorDetail(err)}`, + ); + } +} + +/** + * Validate a redirect target before following it. + * + * Requires http(s) and, when configured, an allowlisted hostname. + * + * @internal + */ +export function validateRedirectTarget( + targetUrl: URL, + hostAllowList?: string[], +): void { + if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') { + throw new McpRegistryClientError( + `MCP Registry redirect to disallowed protocol "${targetUrl.protocol}" ` + + `in "${targetUrl}". Only http and https are permitted.`, + ); + } + assertRequestHostAllowed(targetUrl, hostAllowList); +} + +/** + * Fetch and validate one registry list page. + * + * Uses `redirect: 'manual'` and validates each `Location` header + * against the host allowlist before following, so SSRF via redirect + * cannot reach a disallowed host. + * + * @internal + */ +export async function fetchRegistryPage( + doFetch: typeof fetch, + url: URL, + hostAllowList?: string[], +): Promise { + let currentUrl = url; + let redirectsFollowed = 0; + let response = await fetchOnce(doFetch, currentUrl, hostAllowList); + + while (isRedirectStatus(response.status)) { + if (redirectsFollowed >= MAX_REDIRECTS) { + throw new McpRegistryClientError( + `MCP Registry exceeded ${MAX_REDIRECTS} redirects starting from ` + + `${url}. Last redirect was from ${currentUrl}.`, + ); + } + + const location = response.headers.get('Location'); + if (!location) { + throw new McpRegistryClientError( + `MCP Registry returned HTTP ${response.status} without a ` + + `Location header from ${currentUrl}.`, + ); + } + + const nextUrl = resolveRedirectUrl(currentUrl, location); + validateRedirectTarget(nextUrl, hostAllowList); + redirectsFollowed += 1; + currentUrl = nextUrl; + response = await fetchOnce(doFetch, currentUrl, hostAllowList); + } + + const requestUrl = currentUrl.toString(); + + if (!response.ok) { + const rawBody = await response.text().catch(() => '(no body)'); + throw new McpRegistryClientError( + `MCP Registry returned HTTP ${response.status} for ` + + `${requestUrl}: ${truncateErrorBody(rawBody)}`, + ); + } + + let body: McpRegistryListResponse; + try { + body = (await response.json()) as McpRegistryListResponse; + } catch (err) { + throw new McpRegistryClientError( + `MCP Registry returned unparseable JSON from ${requestUrl}: ${formatErrorDetail( + err, + )}`, + ); + } + + if (!body.servers || !Array.isArray(body.servers)) { + throw new McpRegistryClientError( + `MCP Registry response missing "servers" array from ${requestUrl}`, + ); + } + + return body; +} + +/** + * Perform one allowlist-checked fetch with `redirect: 'manual'`. + * + * @internal + */ +async function fetchOnce( + doFetch: typeof fetch, + url: URL, + hostAllowList?: string[], +): Promise { + const requestUrl = url.toString(); + assertRequestHostAllowed(url, hostAllowList); + let response: Response; + try { + response = await doFetch(requestUrl, { redirect: 'manual' }); + } catch (err) { + throw new McpRegistryClientError( + `Failed to reach MCP Registry at ${requestUrl}: ${formatErrorDetail( + err, + )}`, + ); + } + assertResponseUrlAllowed(response, hostAllowList, requestUrl); + return response; +} + +/** + * Resolve the next pagination cursor for this sync. + * + * Returns `complete` when paging is done, `continue` when another page + * should be fetched in this sync, or `pageLimitReached` when this sync + * should stop and resume from `resumeCursor` on a later sync. + * Enforces repeated-cursor detection (still a hard error). Does not + * mutate `seenCursors`; the caller records new cursors. + * + * @internal + */ +export function resolveNextCursor( + nextCursor: string | null | undefined, + seenCursors: ReadonlySet, + pagesFetched: number, + pageLimit: number, +): ResolveNextCursorResult { + if (!nextCursor || nextCursor.length === 0) { + return { status: 'complete' }; + } + + if (seenCursors.has(nextCursor)) { + throw new McpRegistryClientError( + `MCP Registry returned a repeated cursor "${nextCursor}" ` + + `during pagination. Aborting sync to prevent infinite loop.`, + ); + } + + if (pagesFetched >= pageLimit) { + return { status: 'pageLimitReached', resumeCursor: nextCursor }; + } + + return { status: 'continue', cursor: nextCursor }; +} + +/** + * Runtime request guard: assert a URL's hostname is on the configured + * allow list before issuing (or following) an outbound fetch. + * Throws McpRegistryClientError when the hostname is not permitted. + * + * @internal + */ +export function assertRequestHostAllowed( + url: URL, + hostAllowList: string[] | undefined, +): void { + if (!hostAllowList) { + return; + } + const hostname = url.hostname.toLowerCase(); + if (!hostAllowList.includes(hostname)) { + throw new McpRegistryClientError( + `Request to hostname "${hostname}" blocked: not in the configured ` + + `hostAllowList [${hostAllowList.join(', ')}].`, + ); + } +} + +/** + * Fail closed when an allowlist is configured but the fetch response + * does not expose a URL, then validate that URL's hostname. + * + * @internal + */ +export function assertResponseUrlAllowed( + response: Response, + hostAllowList: string[] | undefined, + requestUrl: string, +): void { + if (!hostAllowList) { + return; + } + if (!response.url) { + throw new McpRegistryClientError( + `MCP Registry response for ${requestUrl} is missing response.url ` + + `while hostAllowList is configured; refusing to proceed.`, + ); + } + assertRequestHostAllowed(new URL(response.url), hostAllowList); +} + +/** + * Soft-stop when a tipped page would push the traversal past `maxEntries`. + * + * Drops the tipping page unless it is the only content so far (then keeps + * it and bounds later traversals at its next cursor). + * + * @internal + */ +export function applyMaxEntriesSoftStop(params: { + serversIncludingTippedPage: McpRegistryServerEntry[]; + tippedPageSize: number; + priorEntryCount: number; + pageCursor: string | undefined; + startCursor: string | undefined; + tippedNextCursor: string | null | undefined; +}): { servers: McpRegistryServerEntry[]; endCursor: string | undefined } { + const { + serversIncludingTippedPage, + tippedPageSize, + priorEntryCount, + pageCursor, + startCursor, + tippedNextCursor, + } = params; + + const withoutTip = serversIncludingTippedPage.slice( + 0, + serversIncludingTippedPage.length - tippedPageSize, + ); + + if (priorEntryCount + withoutTip.length === 0) { + // Single page alone exceeds the cap — keep it so a mutation can + // still proceed, and bound later traversals at its next cursor. + return { + servers: serversIncludingTippedPage, + endCursor: + typeof tippedNextCursor === 'string' && tippedNextCursor.length > 0 + ? tippedNextCursor + : undefined, + }; + } + + // Exclude the tipping page; end at the cursor used to fetch it. + return { + servers: withoutTip, + endCursor: pageCursor ?? startCursor, + }; +} + +/** + * Record a resolved next-cursor decision into `seenCursors` and map it + * to a pagination control action for the fetch loop. + * + * @internal + */ +export function advanceAfterResolvedCursor( + next: ResolveNextCursorResult, + seenCursors: Set, + endCursor: string | undefined, +): + | { action: 'complete' } + | { action: 'stopAtEnd' } + | { action: 'resume'; resumeCursor: string } + | { action: 'continue'; cursor: string } { + if (next.status === 'complete') { + return { action: 'complete' }; + } + + if (next.status === 'pageLimitReached') { + seenCursors.add(next.resumeCursor); + if (endCursor && next.resumeCursor === endCursor) { + return { action: 'stopAtEnd' }; + } + return { action: 'resume', resumeCursor: next.resumeCursor }; + } + + seenCursors.add(next.cursor); + if (endCursor && next.cursor === endCursor) { + return { action: 'stopAtEnd' }; + } + return { action: 'continue', cursor: next.cursor }; +} + +/** + * Whether paging should stop because the current cursor matches a + * previously saved maxEntries end bound. + * + * @internal + */ +export function isAtEndCursor( + cursor: string | undefined, + endCursor: string | undefined, +): boolean { + return endCursor !== undefined && cursor === endCursor; +} + +/** + * Fetch server entries from the MCP Registry using cursor pagination. + * + * Fetches at most `pageLimit` pages starting from `startCursor` (or the + * beginning when unset). When more pages remain after the cap, returns + * those pages' servers plus a `resumeCursor` for the next sync instead + * of failing. When `maxEntries` would be exceeded, stops gracefully and + * returns `endCursor` so the provider can commit the buffer. When + * `endCursor` is supplied, paging stops upon reaching that cursor + * instead of requiring a missing `nextCursor`. + * + * @throws McpRegistryClientError on transport, protocol, or + * repeated-cursor errors. + */ +export async function fetchRegistryServers( + options: FetchServersOptions, +): Promise { + const { + baseUrl, + apiVersion, + pageLimit, + pageSize, + latestVersion, + maxEntries, + priorEntryCount = 0, + startCursor, + endCursor, + hostAllowList, + fetchApi, + } = options; + const doFetch = fetchApi ?? fetch; + const endpoint = parseServersEndpointUrl(baseUrl, apiVersion); + // Own a local copy so the caller's options set is never mutated. + const seenCursors = new Set(options.seenCursors); + + // Defense-in-depth: validate endpoint hostname at runtime even + // though config parsing already checked baseUrl against the list. + assertRequestHostAllowed(endpoint, hostAllowList); + + let allServers: McpRegistryServerEntry[] = []; + let cursor: string | undefined = startCursor; + let pagesFetched = 0; + let resumeCursor: string | undefined; + let maxEntriesEndCursor: string | undefined; + + while (!isAtEndCursor(cursor, endCursor)) { + const url = buildPageRequestUrl(endpoint, cursor, pageSize, latestVersion); + const body = await fetchRegistryPage(doFetch, url, hostAllowList); + allServers.push(...body.servers); + pagesFetched += 1; + + const totalEntries = priorEntryCount + allServers.length; + if (maxEntries !== undefined && totalEntries > maxEntries) { + const capped = applyMaxEntriesSoftStop({ + serversIncludingTippedPage: allServers, + tippedPageSize: body.servers.length, + priorEntryCount, + pageCursor: cursor, + startCursor, + tippedNextCursor: body.metadata?.nextCursor, + }); + allServers = capped.servers; + maxEntriesEndCursor = capped.endCursor; + break; + } + + const advance = advanceAfterResolvedCursor( + resolveNextCursor( + body.metadata?.nextCursor, + seenCursors, + pagesFetched, + pageLimit, + ), + seenCursors, + endCursor, + ); + + if (advance.action === 'complete' || advance.action === 'stopAtEnd') { + break; + } + if (advance.action === 'resume') { + resumeCursor = advance.resumeCursor; + break; + } + cursor = advance.cursor; + } + + return { + servers: allServers, + resumeCursor, + endCursor: maxEntriesEndCursor, + seenCursors, + }; +} diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/config.test.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/config.test.ts new file mode 100644 index 0000000000..195fc256d7 --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/config.test.ts @@ -0,0 +1,650 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { ConfigReader } from '@backstage/config'; +import type { JsonObject } from '@backstage/types'; +import { + assertSingleRegistryConfig, + MCP_REGISTRY_INSTANCE_ID, + readMaxEntries, + readMcpRegistryProviderConfig, + readHostAllowList, + readOptionalPageSize, + readPageLimit, + readProviderSchedule, + readRemotesOnly, + readLatestVersion, + readRequiredHttpBaseUrl, + resolveMcpRegistryProviderConfig, + safeGetOptionalString, + validateHostAllowList, +} from './config'; + +/** Nest instance options under the reserved `mcpRegistry` map key. */ +function providersConfig(instance: JsonObject): JsonObject { + return { + catalog: { + providers: { + mcpRegistry: { + [MCP_REGISTRY_INSTANCE_ID]: instance, + }, + }, + }, + }; +} + +describe('readMcpRegistryProviderConfig', () => { + it('returns undefined when catalog.providers is absent', () => { + const config = new ConfigReader({}); + expect(readMcpRegistryProviderConfig(config)).toBeUndefined(); + }); + + it('returns undefined when catalog.providers.mcpRegistry is absent', () => { + const config = new ConfigReader({ + catalog: { providers: {} }, + }); + expect(readMcpRegistryProviderConfig(config)).toBeUndefined(); + }); + + it('reads a single object with baseUrl and applies defaults', () => { + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://registry.example.com', + }), + ); + + const result = readMcpRegistryProviderConfig(config); + expect(result).toBeDefined(); + expect(result!.baseUrl).toBe('https://registry.example.com'); + expect(result!.apiVersion).toBe('v1'); + expect(result!.pageLimit).toBe(10); + expect(result!.maxEntries).toBe(5000); + expect(result!.remotesOnly).toBe(false); + expect(result!.latestVersion).toBe(false); + expect(result!.pageSize).toBeUndefined(); + expect(result!.baseName).toBeUndefined(); + expect(result!.defaultOwner).toBeUndefined(); + expect(result!.defaultLifecycle).toBeUndefined(); + expect(result!.schedule).toEqual({ + frequency: { minutes: 30 }, + timeout: { minutes: 3 }, + }); + }); + + it('reads optional baseName', () => { + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://registry.example.com', + baseName: 'com.example.registry', + }), + ); + + const result = readMcpRegistryProviderConfig(config); + expect(result!.baseName).toBe('com.example.registry'); + }); + + it('reads explicit pageLimit override', () => { + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://registry.example.com', + pageLimit: 3, + }), + ); + + const result = readMcpRegistryProviderConfig(config); + expect(result!.pageLimit).toBe(3); + }); + + it('reads explicit pageSize', () => { + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://registry.example.com', + pageSize: 50, + }), + ); + + const result = readMcpRegistryProviderConfig(config); + expect(result!.pageSize).toBe(50); + }); + + it('reads omitted pageSize as undefined', () => { + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://registry.example.com', + }), + ); + + const result = readMcpRegistryProviderConfig(config); + expect(result!.pageSize).toBeUndefined(); + }); + + it('throws when baseUrl is missing', () => { + const config = new ConfigReader( + providersConfig({ + apiVersion: 'v0', + }), + ); + + expect(() => readMcpRegistryProviderConfig(config)).toThrow( + /missing required "baseUrl"/, + ); + }); + + it('throws when a legacy flat catalog.providers.mcpRegistry object is used', () => { + const config = new ConfigReader({ + catalog: { + providers: { + mcpRegistry: { + baseUrl: 'https://registry.example.com', + }, + }, + }, + }); + + expect(() => readMcpRegistryProviderConfig(config)).toThrow( + /must be nested under the reserved instance key/, + ); + }); + + it('ignores additional instance ids and warns that multi-registry is unsupported', () => { + const warnings: string[] = []; + const config = new ConfigReader({ + catalog: { + providers: { + mcpRegistry: { + mcpRegistry: { + baseUrl: 'https://registry.example.com', + hostAllowList: ['registry.example.com'], + }, + public: { + baseUrl: 'https://public-registry.example.com', + }, + }, + }, + }, + }); + + const result = readMcpRegistryProviderConfig(config, message => + warnings.push(message), + ); + expect(result!.baseUrl).toBe('https://registry.example.com'); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/additional instance id\(s\)/); + expect(warnings[0]).toMatch(/not supported yet/); + expect(warnings[0]).toMatch(/public/); + }); + + it('returns undefined when the providers map is empty', () => { + const config = new ConfigReader({ + catalog: { providers: { mcpRegistry: {} } }, + }); + expect(readMcpRegistryProviderConfig(config)).toBeUndefined(); + }); + + it('reads a custom schedule', () => { + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://registry.example.com', + schedule: { + frequency: { minutes: 15 }, + timeout: { minutes: 5 }, + initialDelay: { seconds: 30 }, + }, + }), + ); + + const result = readMcpRegistryProviderConfig(config); + expect(result!.schedule).toEqual({ + frequency: { minutes: 15 }, + timeout: { minutes: 5 }, + initialDelay: { seconds: 30 }, + }); + }); + + it('reads defaultOwner', () => { + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://registry.example.com', + defaultOwner: 'group:default/mcp-admins', + }), + ); + + const result = readMcpRegistryProviderConfig(config); + expect(result!.defaultOwner).toBe('group:default/mcp-admins'); + }); + + it('reads defaultLifecycle', () => { + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://registry.example.com', + defaultLifecycle: 'experimental', + }), + ); + + const result = readMcpRegistryProviderConfig(config); + expect(result!.defaultLifecycle).toBe('experimental'); + }); + + it('reads latestVersion', () => { + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://registry.example.com', + latestVersion: true, + }), + ); + + const result = readMcpRegistryProviderConfig(config); + expect(result!.latestVersion).toBe(true); + }); + + it('reads apiVersion override', () => { + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v0', + }), + ); + + const result = readMcpRegistryProviderConfig(config); + expect(result!.apiVersion).toBe('v0'); + }); + + it('reads hostAllowList when provided', () => { + const warnings: string[] = []; + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://registry.example.com', + hostAllowList: ['registry.example.com'], + }), + ); + + const result = readMcpRegistryProviderConfig(config, message => + warnings.push(message), + ); + expect(result!.hostAllowList).toEqual(['registry.example.com']); + expect(warnings).toEqual([]); + }); + + it('returns undefined hostAllowList when omitted and warns', () => { + const warnings: string[] = []; + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://registry.example.com', + }), + ); + + const result = readMcpRegistryProviderConfig(config, message => + warnings.push(message), + ); + expect(result!.hostAllowList).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/hostAllowList is not configured/); + expect(warnings[0]).toMatch(/SSRF/); + }); + + it('does not warn about hostAllowList when configured as empty deny-all', () => { + const warnings: string[] = []; + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://registry.example.com', + hostAllowList: [], + }), + ); + + // Empty list fails validation of baseUrl; catch before asserting warn absence. + expect(() => + readMcpRegistryProviderConfig(config, message => warnings.push(message)), + ).toThrow(/not in the configured hostAllowList/); + expect(warnings).toEqual([]); + }); + + it('throws when baseUrl hostname is not in hostAllowList', () => { + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://registry.example.com', + hostAllowList: ['other.example.com'], + }), + ); + + expect(() => readMcpRegistryProviderConfig(config)).toThrow( + /not in the configured hostAllowList/, + ); + }); + + it('normalizes hostAllowList entries to lowercase', () => { + const config = new ConfigReader( + providersConfig({ + baseUrl: 'https://Registry.Example.COM', + hostAllowList: ['REGISTRY.EXAMPLE.COM'], + }), + ); + + const result = readMcpRegistryProviderConfig(config); + expect(result!.hostAllowList).toEqual(['registry.example.com']); + }); +}); + +describe('safeGetOptionalString', () => { + it('returns the string value when present', () => { + const config = new ConfigReader({ + baseUrl: 'https://registry.example.com', + }); + expect(safeGetOptionalString(config, 'baseUrl')).toBe( + 'https://registry.example.com', + ); + }); + + it('returns undefined when the key is absent', () => { + const config = new ConfigReader({}); + expect(safeGetOptionalString(config, 'baseUrl')).toBeUndefined(); + }); + + it('returns undefined when ConfigReader rejects an empty string', () => { + const config = new ConfigReader({ baseUrl: '' }); + expect(safeGetOptionalString(config, 'baseUrl')).toBeUndefined(); + }); +}); + +describe('assertSingleRegistryConfig', () => { + it('allows a flat single-registry object', () => { + const config = new ConfigReader({ + baseUrl: 'https://registry.example.com', + apiVersion: 'v1', + }); + expect(() => assertSingleRegistryConfig(config)).not.toThrow(); + }); + + it('ignores unknown scalar keys', () => { + const config = new ConfigReader({ + baseUrl: 'https://registry.example.com', + extraFlag: true, + }); + expect(() => assertSingleRegistryConfig(config)).not.toThrow(); + }); + + it('throws when an unknown key is a nested instance object', () => { + const config = new ConfigReader({ + internal: { + baseUrl: 'https://internal.example.com', + }, + }); + expect(() => assertSingleRegistryConfig(config)).toThrow( + /found keyed instance/, + ); + expect(() => assertSingleRegistryConfig(config)).toThrow( + /remotesOnly, latestVersion, hostAllowList/, + ); + }); +}); + +describe('readRequiredHttpBaseUrl', () => { + it('returns a valid https baseUrl', () => { + const config = new ConfigReader({ + baseUrl: 'https://registry.example.com', + }); + expect(readRequiredHttpBaseUrl(config)).toBe( + 'https://registry.example.com', + ); + }); + + it('returns a valid http baseUrl', () => { + const config = new ConfigReader({ + baseUrl: 'http://localhost:8080', + }); + expect(readRequiredHttpBaseUrl(config)).toBe('http://localhost:8080'); + }); + + it('throws when baseUrl is missing', () => { + const config = new ConfigReader({}); + expect(() => readRequiredHttpBaseUrl(config)).toThrow( + /missing required "baseUrl"/, + ); + }); + + it('throws when baseUrl is not a valid URL', () => { + const config = new ConfigReader({ baseUrl: 'not a url' }); + expect(() => readRequiredHttpBaseUrl(config)).toThrow(/is not a valid URL/); + }); + + it('throws when baseUrl uses a non-http protocol', () => { + const config = new ConfigReader({ baseUrl: 'ftp://registry.example.com' }); + expect(() => readRequiredHttpBaseUrl(config)).toThrow( + /must use http or https protocol/, + ); + }); +}); + +describe('readPageLimit', () => { + it('defaults to 10 when omitted', () => { + expect(readPageLimit(new ConfigReader({}))).toBe(10); + }); + + it('returns an explicit pageLimit', () => { + expect(readPageLimit(new ConfigReader({ pageLimit: 3 }))).toBe(3); + }); + + it('throws when pageLimit is less than 1', () => { + expect(() => readPageLimit(new ConfigReader({ pageLimit: 0 }))).toThrow( + /"pageLimit" must be at least 1/, + ); + }); +}); + +describe('readOptionalPageSize', () => { + it('returns undefined when omitted', () => { + expect(readOptionalPageSize(new ConfigReader({}))).toBeUndefined(); + }); + + it('returns an explicit pageSize', () => { + expect(readOptionalPageSize(new ConfigReader({ pageSize: 50 }))).toBe(50); + }); + + it('throws when pageSize is less than 1', () => { + expect(() => + readOptionalPageSize(new ConfigReader({ pageSize: 0 })), + ).toThrow(/"pageSize" must be at least 1/); + }); +}); + +describe('readMaxEntries', () => { + it('defaults to 5000 when omitted', () => { + expect(readMaxEntries(new ConfigReader({}))).toBe(5000); + }); + + it('returns an explicit maxEntries', () => { + expect(readMaxEntries(new ConfigReader({ maxEntries: 1000 }))).toBe(1000); + }); + + it('throws when maxEntries is less than 1', () => { + expect(() => readMaxEntries(new ConfigReader({ maxEntries: 0 }))).toThrow( + /"maxEntries" must be at least 1/, + ); + }); +}); + +describe('readRemotesOnly', () => { + it('defaults to false when omitted', () => { + expect(readRemotesOnly(new ConfigReader({}))).toBe(false); + }); + + it('returns true when remotesOnly is true', () => { + expect(readRemotesOnly(new ConfigReader({ remotesOnly: true }))).toBe(true); + }); + + it('returns false when remotesOnly is false', () => { + expect(readRemotesOnly(new ConfigReader({ remotesOnly: false }))).toBe( + false, + ); + }); +}); + +describe('readLatestVersion', () => { + it('defaults to false when omitted', () => { + expect(readLatestVersion(new ConfigReader({}))).toBe(false); + }); + + it('returns true when latestVersion is true', () => { + expect(readLatestVersion(new ConfigReader({ latestVersion: true }))).toBe( + true, + ); + }); + + it('returns false when latestVersion is false', () => { + expect(readLatestVersion(new ConfigReader({ latestVersion: false }))).toBe( + false, + ); + }); +}); + +describe('readHostAllowList', () => { + it('returns undefined when omitted', () => { + expect(readHostAllowList(new ConfigReader({}))).toBeUndefined(); + }); + + it('returns an empty array for an empty array (deny all)', () => { + expect(readHostAllowList(new ConfigReader({ hostAllowList: [] }))).toEqual( + [], + ); + }); + + it('returns normalized lowercase hostnames', () => { + expect( + readHostAllowList( + new ConfigReader({ + hostAllowList: ['Registry.Example.COM', 'Other.HOST'], + }), + ), + ).toEqual(['registry.example.com', 'other.host']); + }); +}); + +describe('validateHostAllowList', () => { + it('passes when hostname is in the allow list', () => { + expect(() => + validateHostAllowList('https://registry.example.com/path', [ + 'registry.example.com', + ]), + ).not.toThrow(); + }); + + it('throws when hostname is not in the allow list', () => { + expect(() => + validateHostAllowList('https://evil.example.com', [ + 'registry.example.com', + ]), + ).toThrow(/not in the configured hostAllowList/); + }); + + it('matches case-insensitively', () => { + expect(() => + validateHostAllowList('https://Registry.Example.COM', [ + 'registry.example.com', + ]), + ).not.toThrow(); + }); +}); + +describe('readProviderSchedule', () => { + it('returns the default schedule when omitted', () => { + expect(readProviderSchedule(new ConfigReader({}))).toEqual({ + frequency: { minutes: 30 }, + timeout: { minutes: 3 }, + }); + }); + + it('reads an explicit schedule', () => { + const config = new ConfigReader({ + schedule: { + frequency: { minutes: 15 }, + timeout: { minutes: 5 }, + initialDelay: { seconds: 30 }, + }, + }); + expect(readProviderSchedule(config)).toEqual({ + frequency: { minutes: 15 }, + timeout: { minutes: 5 }, + initialDelay: { seconds: 30 }, + }); + }); +}); + +describe('resolveMcpRegistryProviderConfig', () => { + const schedule = { + frequency: { minutes: 30 }, + timeout: { minutes: 3 }, + }; + + it('fills documented defaults when optional fields are omitted', () => { + expect( + resolveMcpRegistryProviderConfig({ + baseUrl: 'https://registry.example.com', + schedule, + }), + ).toEqual({ + baseUrl: 'https://registry.example.com', + schedule, + apiVersion: 'v1', + pageLimit: 10, + maxEntries: 5000, + remotesOnly: false, + latestVersion: false, + }); + }); + + it('preserves explicit overrides', () => { + expect( + resolveMcpRegistryProviderConfig({ + baseUrl: 'https://registry.example.com', + schedule, + apiVersion: 'v0', + pageLimit: 3, + maxEntries: 100, + remotesOnly: true, + latestVersion: true, + }), + ).toEqual({ + baseUrl: 'https://registry.example.com', + schedule, + apiVersion: 'v0', + pageLimit: 3, + maxEntries: 100, + remotesOnly: true, + latestVersion: true, + }); + }); + + it('treats explicit undefined the same as omitted for defaulted fields', () => { + expect( + resolveMcpRegistryProviderConfig({ + baseUrl: 'https://registry.example.com', + schedule, + apiVersion: undefined, + pageLimit: undefined, + maxEntries: undefined, + remotesOnly: undefined, + latestVersion: undefined, + }), + ).toEqual({ + baseUrl: 'https://registry.example.com', + schedule, + apiVersion: 'v1', + pageLimit: 10, + maxEntries: 5000, + remotesOnly: false, + latestVersion: false, + }); + }); +}); diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/config.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/config.ts new file mode 100644 index 0000000000..4920417798 --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/config.ts @@ -0,0 +1,500 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { Config } from '@backstage/config'; +import type { SchedulerServiceTaskScheduleDefinition } from '@backstage/backend-plugin-api'; +import { readSchedulerServiceTaskScheduleDefinitionFromConfig } from '@backstage/backend-plugin-api'; + +/** Default schedule when `schedule` is omitted. */ +const DEFAULT_SCHEDULE: SchedulerServiceTaskScheduleDefinition = { + frequency: { minutes: 30 }, + timeout: { minutes: 3 }, +}; + +/** Default apiVersion when omitted. */ +const DEFAULT_API_VERSION = 'v1'; + +/** Default page limit (max pages per sync). */ +const DEFAULT_PAGE_LIMIT = 10; + +/** Default max entries per complete registry traversal (full mutation). */ +const DEFAULT_MAX_ENTRIES = 5000; + +/** Default remotesOnly when omitted. */ +const DEFAULT_REMOTES_ONLY = false; + +/** Default latestVersion when omitted. */ +const DEFAULT_LATEST_VERSION = false; + +/** + * Reserved instance id under `catalog.providers.mcpRegistry`. + * This implementation expects only this key; additional ids are rejected + * until multi-registry support lands. + */ +export const MCP_REGISTRY_INSTANCE_ID = 'mcpRegistry'; + +/** Config path for the reserved registry instance. */ +const MCP_REGISTRY_INSTANCE_CONFIG_PATH = `catalog.providers.mcpRegistry.${MCP_REGISTRY_INSTANCE_ID}`; + +/** Supported single-registry config keys under the reserved instance. */ +const KNOWN_MCP_REGISTRY_KEYS = new Set([ + 'baseUrl', + 'baseName', + 'apiVersion', + 'defaultOwner', + 'defaultLifecycle', + 'pageLimit', + 'pageSize', + 'maxEntries', + 'remotesOnly', + 'latestVersion', + 'hostAllowList', + 'schedule', +]); + +/** + * Safely read an optional string from config, returning `undefined` + * when Backstage's ConfigReader throws TypeError for empty-string + * values from env var substitution like `${VAR:-}`. + * + * @internal + */ +export function safeGetOptionalString( + config: Config, + key: string, +): string | undefined { + try { + return config.getOptionalString(key); + } catch { + // ConfigReader throws TypeError for empty-string values + // from env var substitution like ${VAR:-} + return undefined; + } +} + +/** + * Reject unexpected nested objects under a registry instance config. + * + * @internal + */ +export function assertSingleRegistryConfig(registryConfig: Config): void { + const unknownKeys = registryConfig + .keys() + .filter(key => !KNOWN_MCP_REGISTRY_KEYS.has(key)); + + for (const key of unknownKeys) { + let nested; + try { + nested = registryConfig.getOptionalConfig(key); + } catch { + // ConfigReader throws TypeError when the value is a scalar + // rather than an object — skip this key silently. + continue; + } + if (nested && nested.keys().length > 0) { + const knownKeys = [...KNOWN_MCP_REGISTRY_KEYS].join(', '); + throw new Error( + `Invalid ${MCP_REGISTRY_INSTANCE_CONFIG_PATH} configuration: found ` + + `keyed instance "${key}". Configure a single registry object ` + + `with known keys: ${knownKeys}.`, + ); + } + } +} + +/** + * Optional warning sink used when configuration is accepted with caveats + * (e.g. ignored extra registry instance ids, or absent `hostAllowList`). + * + * @internal + */ +export type ConfigWarnFn = (message: string) => void; + +/** + * Resolve the reserved registry instance from the providers map. + * + * `catalog.providers.mcpRegistry` is a map of instance ids. This + * implementation only reads the reserved key {@link MCP_REGISTRY_INSTANCE_ID}; + * additional ids are ignored (with an optional warning). + * + * @internal + */ +export function readReservedRegistryInstanceConfig( + providersMap: Config, + warn?: ConfigWarnFn, +): Config | undefined { + const keys = providersMap.keys(); + if (keys.length === 0) { + return undefined; + } + + const legacyKeys = keys.filter(key => KNOWN_MCP_REGISTRY_KEYS.has(key)); + if (legacyKeys.length > 0) { + throw new Error( + `Invalid catalog.providers.mcpRegistry configuration: registry ` + + `options (${legacyKeys.join(', ')}) must be nested under the ` + + `reserved instance key "${MCP_REGISTRY_INSTANCE_ID}" ` + + `(e.g. catalog.providers.mcpRegistry.${MCP_REGISTRY_INSTANCE_ID}.baseUrl).`, + ); + } + + const unexpectedKeys = keys.filter(key => key !== MCP_REGISTRY_INSTANCE_ID); + if (unexpectedKeys.length > 0) { + warn?.( + `catalog.providers.mcpRegistry has additional instance id(s) ` + + `[${unexpectedKeys.join( + ', ', + )}] which are ignored; multiple MCP Registry providers are not ` + + `supported yet. Only the reserved "${MCP_REGISTRY_INSTANCE_ID}" ` + + `instance is used.`, + ); + } + + const registryConfig = providersMap.getOptionalConfig( + MCP_REGISTRY_INSTANCE_ID, + ); + if (!registryConfig) { + return undefined; + } + + return registryConfig; +} + +/** + * Read and validate the required HTTP(S) `baseUrl`. + * + * @internal + */ +export function readRequiredHttpBaseUrl(registryConfig: Config): string { + const baseUrl = safeGetOptionalString(registryConfig, 'baseUrl'); + if (!baseUrl) { + throw new Error( + `Invalid ${MCP_REGISTRY_INSTANCE_CONFIG_PATH} configuration: missing ` + + `required "baseUrl" field. Set baseUrl to the MCP Registry base URL ` + + `(e.g., "https://registry.example.com").`, + ); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(baseUrl); + } catch { + throw new Error( + `Invalid ${MCP_REGISTRY_INSTANCE_CONFIG_PATH} configuration: "baseUrl" ` + + `is not a valid URL: "${baseUrl}". Set baseUrl to an absolute ` + + `HTTP(S) URL (e.g., "https://registry.example.com").`, + ); + } + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new Error( + `Invalid ${MCP_REGISTRY_INSTANCE_CONFIG_PATH} configuration: "baseUrl" ` + + `must use http or https protocol, got "${parsedUrl.protocol}" ` + + `in "${baseUrl}".`, + ); + } + + return baseUrl; +} + +/** + * Read `pageLimit`, applying the default and rejecting values below 1. + * + * @internal + */ +export function readPageLimit(registryConfig: Config): number { + const pageLimit = + registryConfig.getOptionalNumber('pageLimit') ?? DEFAULT_PAGE_LIMIT; + if (pageLimit < 1) { + throw new Error( + `Invalid ${MCP_REGISTRY_INSTANCE_CONFIG_PATH} configuration: "pageLimit" ` + + `must be at least 1, got ${pageLimit}.`, + ); + } + return pageLimit; +} + +/** + * Read `maxEntries`, applying the default and rejecting values below 1. + * Caps total servers buffered for one complete registry traversal + * (possibly spanning multiple resume syncs) before a full mutation. + * + * @internal + */ +export function readMaxEntries(registryConfig: Config): number { + const maxEntries = + registryConfig.getOptionalNumber('maxEntries') ?? DEFAULT_MAX_ENTRIES; + if (maxEntries < 1) { + throw new Error( + `Invalid ${MCP_REGISTRY_INSTANCE_CONFIG_PATH} configuration: "maxEntries" ` + + `must be at least 1, got ${maxEntries}.`, + ); + } + return maxEntries; +} + +/** + * Read optional `pageSize`, rejecting values below 1 when set. + * + * @internal + */ +export function readOptionalPageSize( + registryConfig: Config, +): number | undefined { + const pageSize = registryConfig.getOptionalNumber('pageSize'); + if (pageSize !== undefined && pageSize < 1) { + throw new Error( + `Invalid ${MCP_REGISTRY_INSTANCE_CONFIG_PATH} configuration: "pageSize" ` + + `must be at least 1, got ${pageSize}.`, + ); + } + return pageSize; +} + +/** + * Read optional `hostAllowList`, normalizing entries to lowercase. + * + * Returns `undefined` when the key is absent (no filtering). + * Returns an empty array when configured as `[]` — semantically + * "deny all" (no hostname can pass validation). + * + * @internal + */ +export function readHostAllowList( + registryConfig: Config, +): string[] | undefined { + const list = registryConfig.getOptionalStringArray('hostAllowList'); + if (!list) { + return undefined; + } + return list.map(h => h.toLowerCase()); +} + +/** + * Validate that a URL's hostname is present in the configured allow list. + * Throws when the hostname is not in the list. + * + * @internal + */ +export function validateHostAllowList( + url: string, + hostAllowList: string[], +): void { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + if (!hostAllowList.includes(hostname)) { + throw new Error( + `Invalid ${MCP_REGISTRY_INSTANCE_CONFIG_PATH} configuration: the hostname ` + + `"${hostname}" from baseUrl "${url}" is not in the configured ` + + `hostAllowList [${hostAllowList.join(', ')}].`, + ); + } +} + +/** + * Read `remotesOnly`, defaulting to `false`. + * + * @internal + */ +export function readRemotesOnly(registryConfig: Config): boolean { + try { + return ( + registryConfig.getOptionalBoolean('remotesOnly') ?? DEFAULT_REMOTES_ONLY + ); + } catch { + // ConfigReader throws TypeError for empty-string env substitution. + return DEFAULT_REMOTES_ONLY; + } +} + +/** + * Read `latestVersion`, defaulting to `false`. + * + * When true, list requests include `?version=latest`. + * + * @internal + */ +export function readLatestVersion(registryConfig: Config): boolean { + try { + return ( + registryConfig.getOptionalBoolean('latestVersion') ?? + DEFAULT_LATEST_VERSION + ); + } catch { + // ConfigReader throws TypeError for empty-string env substitution. + return DEFAULT_LATEST_VERSION; + } +} + +/** + * Read the provider schedule, or the documented default when omitted. + * + * @internal + */ +export function readProviderSchedule( + registryConfig: Config, +): SchedulerServiceTaskScheduleDefinition { + const scheduleConfig = registryConfig.getOptionalConfig('schedule'); + if (!scheduleConfig) { + return DEFAULT_SCHEDULE; + } + return readSchedulerServiceTaskScheduleDefinitionFromConfig(scheduleConfig); +} + +/** + * Provider configuration. Fields with documented defaults may be omitted + * on direct construction; the entity provider and config reader apply the + * same defaults as app-config parsing. + * + * @public + */ +export interface McpRegistryProviderConfig { + /** Base URL of the MCP Registry (required). */ + baseUrl: string; + /** Optional identity prefix override passed to the mapping transform. */ + baseName?: string; + /** Registry API version slug used in the endpoint path (default `v1`). */ + apiVersion?: string; + /** Default entity owner ref when the mapping does not supply one. */ + defaultOwner?: string; + /** Default entity lifecycle when the mapping does not supply one. */ + defaultLifecycle?: string; + /** Maximum pages fetched per sync (default `10`); excess pages resume next sync. */ + pageLimit?: number; + /** Registry `?limit=` page-size query; omitted from the request when unset. */ + pageSize?: number; + /** + * Maximum total entries buffered for one complete registry traversal + * before a full mutation (default `5000`). Spans resume syncs when + * `pageLimit` pauses mid-traversal. When exceeded, the provider + * commits the buffer, saves an end cursor, and later traversals stop + * at that cursor until `maxEntries` is patched. + */ + maxEntries?: number; + /** + * When true, only ingest servers with at least one native remote. + * Package-only / placeholder-remote servers are skipped (default `false`). + */ + remotesOnly?: boolean; + /** + * When true, list requests include `?version=latest` so the registry + * returns only the latest version of each server (default `false`). + */ + latestVersion?: boolean; + /** Optional allowlist of permitted hostnames for defense-in-depth SSRF protection. */ + hostAllowList?: string[]; + /** Schedule for the sync task. */ + schedule: SchedulerServiceTaskScheduleDefinition; +} + +/** + * {@link McpRegistryProviderConfig} with defaults applied for fields + * that are optional on the public interface. + * + * @internal + */ +export type ResolvedMcpRegistryProviderConfig = McpRegistryProviderConfig & { + apiVersion: string; + pageLimit: number; + maxEntries: number; + remotesOnly: boolean; + latestVersion: boolean; +}; + +/** + * Apply documented defaults for optional provider config fields. + * + * @internal + */ +export function resolveMcpRegistryProviderConfig( + config: McpRegistryProviderConfig, +): ResolvedMcpRegistryProviderConfig { + return { + ...config, + apiVersion: config.apiVersion ?? DEFAULT_API_VERSION, + pageLimit: config.pageLimit ?? DEFAULT_PAGE_LIMIT, + maxEntries: config.maxEntries ?? DEFAULT_MAX_ENTRIES, + remotesOnly: config.remotesOnly ?? DEFAULT_REMOTES_ONLY, + latestVersion: config.latestVersion ?? DEFAULT_LATEST_VERSION, + }; +} + +/** + * Read and validate the MCP Registry provider configuration from + * `catalog.providers.mcpRegistry.mcpRegistry` (reserved instance id). + * Returns `undefined` when the providers map or reserved instance is + * absent (inert module). + * + * Additional instance ids under `catalog.providers.mcpRegistry` are + * ignored; pass `warn` to surface that multiple registries are not + * supported yet. When `hostAllowList` is omitted, `warn` is also used + * to recommend configuring hostname restrictions. + * + * @throws When a legacy flat `catalog.providers.mcpRegistry` object is + * used, or when `baseUrl` is missing on the reserved instance. + */ +export function readMcpRegistryProviderConfig( + rootConfig: Config, + warn?: ConfigWarnFn, +): ResolvedMcpRegistryProviderConfig | undefined { + const providersConfig = rootConfig.getOptionalConfig('catalog.providers'); + if (!providersConfig) { + return undefined; + } + + const providersMap = providersConfig.getOptionalConfig('mcpRegistry'); + if (!providersMap) { + return undefined; + } + + const registryConfig = readReservedRegistryInstanceConfig(providersMap, warn); + if (!registryConfig) { + return undefined; + } + + assertSingleRegistryConfig(registryConfig); + + const baseUrl = readRequiredHttpBaseUrl(registryConfig); + const hostAllowList = readHostAllowList(registryConfig); + + if (hostAllowList) { + validateHostAllowList(baseUrl, hostAllowList); + } else { + warn?.( + `${MCP_REGISTRY_INSTANCE_CONFIG_PATH}.hostAllowList is not configured; ` + + `outbound registry requests are not restricted by hostname. Set ` + + `hostAllowList to permitted registry hostnames for defense-in-depth ` + + `against SSRF.`, + ); + } + + return { + baseUrl, + baseName: safeGetOptionalString(registryConfig, 'baseName'), + apiVersion: + safeGetOptionalString(registryConfig, 'apiVersion') ?? + DEFAULT_API_VERSION, + defaultOwner: safeGetOptionalString(registryConfig, 'defaultOwner'), + defaultLifecycle: safeGetOptionalString(registryConfig, 'defaultLifecycle'), + pageLimit: readPageLimit(registryConfig), + pageSize: readOptionalPageSize(registryConfig), + maxEntries: readMaxEntries(registryConfig), + remotesOnly: readRemotesOnly(registryConfig), + latestVersion: readLatestVersion(registryConfig), + hostAllowList, + schedule: readProviderSchedule(registryConfig), + }; +} diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/index.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/index.ts new file mode 100644 index 0000000000..65c2f0f299 --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Red Hat, Inc. + * + * 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. + */ + +/** + * The mcp-registry-provider backend module for the catalog plugin. + * + * @packageDocumentation + */ + +export { catalogModuleMcpRegistryProvider as default } from './module'; +export { McpRegistryEntityProvider } from './McpRegistryEntityProvider'; +export type { McpRegistryEntityProviderOptions } from './McpRegistryEntityProvider'; +export type { McpRegistryProviderConfig } from './config'; diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/module.test.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/module.test.ts new file mode 100644 index 0000000000..b526595d7a --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/module.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { catalogModuleMcpRegistryProvider } from './module'; + +describe('mcp-registry-provider module', () => { + it('should export the backend module', () => { + expect(catalogModuleMcpRegistryProvider).toBeDefined(); + }); +}); diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/module.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/module.ts new file mode 100644 index 0000000000..78b9efcdbb --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/module.ts @@ -0,0 +1,69 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; +import { readMcpRegistryProviderConfig } from './config'; +import { McpRegistryEntityProvider } from './McpRegistryEntityProvider'; + +/** + * The mcp-registry-provider backend module for the catalog plugin. + * + * Registers a single entity provider that ingests MCP servers from + * one configured MCP Registry into the catalog as `mcp-server` API + * entities. + * + * @public + */ +export const catalogModuleMcpRegistryProvider = createBackendModule({ + pluginId: 'catalog', + moduleId: 'mcp-registry-provider', + register(env) { + env.registerInit({ + deps: { + catalog: catalogProcessingExtensionPoint, + config: coreServices.rootConfig, + logger: coreServices.logger, + scheduler: coreServices.scheduler, + }, + async init({ catalog, config, logger, scheduler }) { + const providerConfig = readMcpRegistryProviderConfig(config, message => + logger.warn(message), + ); + + if (!providerConfig) { + logger.info( + 'catalog.providers.mcpRegistry.mcpRegistry not configured; ' + + 'MCP Registry provider is inactive.', + ); + return; + } + + const taskRunner = scheduler.createScheduledTaskRunner( + providerConfig.schedule, + ); + const provider = new McpRegistryEntityProvider(providerConfig, logger, { + taskRunner, + }); + + catalog.addEntityProvider(provider); + }, + }); + }, +}); diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/providerUtils.test.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/providerUtils.test.ts new file mode 100644 index 0000000000..1bf072ade5 --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/providerUtils.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { + buildLastGoodKey, + formatMappingFailureMessage, + hasNativeRemote, + readServerIdentity, +} from './providerUtils'; +import { createMockServerDoc } from './testUtils'; + +describe('buildLastGoodKey', () => { + it('joins name and version with a double-colon separator', () => { + expect(buildLastGoodKey('io.example/weather', '1.0.0')).toBe( + 'io.example/weather::1.0.0', + ); + }); +}); + +describe('hasNativeRemote', () => { + it('returns true when a remote has a non-empty type and http(s) URL', () => { + expect( + hasNativeRemote(createMockServerDoc('io.example/weather', '1.0.0')), + ).toBe(true); + }); + + it('returns false when remotes are missing or empty', () => { + expect( + hasNativeRemote( + createMockServerDoc('io.example/weather', '1.0.0', { + remotes: undefined, + }), + ), + ).toBe(false); + expect( + hasNativeRemote( + createMockServerDoc('io.example/weather', '1.0.0', { remotes: [] }), + ), + ).toBe(false); + expect(hasNativeRemote(undefined)).toBe(false); + }); + + it('returns false for invalid remote URLs', () => { + expect( + hasNativeRemote( + createMockServerDoc('io.example/weather', '1.0.0', { + remotes: [{ type: 'streamable-http', url: 'not-a-url' }], + }), + ), + ).toBe(false); + }); +}); + +describe('readServerIdentity', () => { + it('returns name and version from a valid entry', () => { + expect( + readServerIdentity({ + server: createMockServerDoc('io.example/weather', '1.2.3'), + }), + ).toEqual({ name: 'io.example/weather', version: '1.2.3' }); + }); + + it('returns undefined fields for null or undefined entries', () => { + expect(readServerIdentity(null)).toEqual({ + name: undefined, + version: undefined, + }); + expect(readServerIdentity(undefined)).toEqual({ + name: undefined, + version: undefined, + }); + }); + + it('ignores non-string name and version values', () => { + expect( + readServerIdentity({ + server: { + ...createMockServerDoc('io.example/weather', '1.0.0'), + name: 42 as unknown as string, + version: { n: 1 } as unknown as string, + }, + }), + ).toEqual({ name: undefined, version: undefined }); + }); +}); + +describe('formatMappingFailureMessage', () => { + it('includes name and version when both are present', () => { + expect( + formatMappingFailureMessage('io.example/weather', '1.0.0', 'boom'), + ).toBe( + 'Failed to map MCP Registry server entry "io.example/weather" (version "1.0.0"): boom', + ); + }); + + it('omits missing name and version segments', () => { + expect(formatMappingFailureMessage(undefined, undefined, 'boom')).toBe( + 'Failed to map MCP Registry server entry: boom', + ); + }); + + it('includes only the version when name is missing', () => { + expect(formatMappingFailureMessage(undefined, '1.0.0', 'boom')).toBe( + 'Failed to map MCP Registry server entry (version "1.0.0"): boom', + ); + }); +}); diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/providerUtils.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/providerUtils.ts new file mode 100644 index 0000000000..6347d64681 --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/providerUtils.ts @@ -0,0 +1,91 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { isAllowedUrl } from '@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping'; +import type { McpServerDocument } from '@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping'; +import type { McpRegistryServerEntry } from './client'; +import { formatErrorDetail } from './util'; + +/** + * Whether a server.json document declares at least one native remote + * (non-empty type and D11-valid URL). Matches the mapping's copy rules + * for remotes that become `spec.remotes` rather than D8 placeholders. + * + * @internal + */ +export function hasNativeRemote(doc: McpServerDocument | undefined): boolean { + const remotes = doc?.remotes ?? []; + for (const remote of remotes) { + if ( + typeof remote.type === 'string' && + remote.type.length > 0 && + remote.url !== undefined && + remote.url !== null && + isAllowedUrl(remote.url) + ) { + return true; + } + } + return false; +} + +/** + * Build a last-good lookup key from name and version. + * + * @internal + */ +export function buildLastGoodKey(name: string, version: string): string { + return `${name}::${version}`; +} + +/** + * Read optional name/version from a registry list entry. + * + * @internal + */ +export function readServerIdentity( + entry: McpRegistryServerEntry | null | undefined, +): { + name?: string; + version?: string; +} { + const serverDoc = entry?.server; + return { + name: typeof serverDoc?.name === 'string' ? serverDoc.name : undefined, + version: + typeof serverDoc?.version === 'string' ? serverDoc.version : undefined, + }; +} + +/** + * Format the per-entry mapping failure warning. + * + * @internal + */ +export function formatMappingFailureMessage( + name: string | undefined, + version: string | undefined, + err: unknown, +): string { + let message = 'Failed to map MCP Registry server entry'; + if (name) { + message += ` "${name}"`; + } + if (version) { + message += ` (version "${version}")`; + } + return `${message}: ${formatErrorDetail(err)}`; +} diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/testUtils.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/testUtils.ts new file mode 100644 index 0000000000..c8951718ce --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/testUtils.ts @@ -0,0 +1,92 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { McpServerDocument } from '@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping'; +import type { McpRegistryProviderConfig } from './config'; +import type { McpRegistryListResponse } from './client'; + +/** + * Create a minimal valid MCP server.json document for testing. + */ +export function createMockServerDoc( + name: string, + version: string, + overrides?: Partial, +): McpServerDocument { + return { + $schema: + 'https://raw.githubusercontent.com/modelcontextprotocol/registry/v1.8.1/docs/reference/server-json/draft/server.schema.json', + name, + description: `Test server ${name}`, + version, + remotes: [ + { + type: 'streamable-http', + url: `https://${name.replace('/', '.')}.example.com/mcp`, + }, + ], + ...overrides, + }; +} + +/** + * Create a mock logger with jest spies for all methods. + */ +export function createMockLogger() { + return { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn().mockReturnThis(), + }; +} + +/** + * Create a default McpRegistryProviderConfig for testing. + * Omits fields that have documented defaults so construction + * matches the app-config path. + */ +export function createDefaultConfig( + overrides?: Partial, +): McpRegistryProviderConfig { + return { + baseUrl: 'https://registry.example.com', + schedule: { + frequency: { minutes: 30 }, + timeout: { minutes: 3 }, + }, + ...overrides, + }; +} + +/** + * Create a mock fetch that returns the given responses in order. + */ +export function mockFetchForResponses( + responses: McpRegistryListResponse[], +): jest.Mock { + const fn = jest.fn(); + for (const body of responses) { + fn.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response); + } + return fn; +} diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/util.test.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/util.test.ts new file mode 100644 index 0000000000..0c8074ce49 --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/util.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { formatErrorDetail, stripTrailingSlashes } from './util'; + +describe('stripTrailingSlashes', () => { + it('returns the value unchanged when there is no trailing slash', () => { + expect(stripTrailingSlashes('https://registry.example.com')).toBe( + 'https://registry.example.com', + ); + }); + + it('strips one or more trailing slashes', () => { + expect(stripTrailingSlashes('https://registry.example.com/')).toBe( + 'https://registry.example.com', + ); + expect(stripTrailingSlashes('https://registry.example.com///')).toBe( + 'https://registry.example.com', + ); + }); + + it('returns an empty string when the value is only slashes', () => { + expect(stripTrailingSlashes('///')).toBe(''); + }); +}); + +describe('formatErrorDetail', () => { + it('returns the Error message without the constructor name', () => { + expect(formatErrorDetail(new TypeError('fetch failed'))).toBe( + 'fetch failed', + ); + }); + + it('prefers the deepest cause message for wrapped fetch failures', () => { + const cause = new Error('connect ECONNREFUSED 127.0.0.1:8080'); + const err = new TypeError('fetch failed', { cause }); + expect(formatErrorDetail(err)).toBe('connect ECONNREFUSED 127.0.0.1:8080'); + }); + + it('returns string values as-is', () => { + expect(formatErrorDetail('boom')).toBe('boom'); + }); +}); diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/util.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/util.ts new file mode 100644 index 0000000000..9065f390c2 --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-mcp-registry-provider/src/util.ts @@ -0,0 +1,59 @@ +/* + * Copyright Red Hat, Inc. + * + * 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. + */ + +/** + * Strip trailing `/` characters with a linear scan (no regex backtracking). + * + * @internal + */ +export function stripTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value.charAt(end - 1) === '/') { + end -= 1; + } + return end === value.length ? value : value.slice(0, end); +} + +/** + * Format an unknown thrown value for operator-facing error text. + * + * Prefer the deepest `cause` message (Node/undici often wraps network + * failures as `TypeError: fetch failed` with a useful cause). Never + * prefixes the Error constructor name (e.g. `TypeError:`). + * + * @internal + */ +export function formatErrorDetail(err: unknown): string { + if (err instanceof Error) { + let current: Error = err; + // Walk a short cause chain for a more specific message. + for (let depth = 0; depth < 5; depth += 1) { + const cause = (current as Error & { cause?: unknown }).cause; + if (!(cause instanceof Error) || !cause.message) { + break; + } + current = cause; + } + if (current.message) { + return current.message; + } + return current.name || 'unknown error'; + } + if (typeof err === 'string' && err.length > 0) { + return err; + } + return String(err); +} diff --git a/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/.eslintrc.js b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/.eslintrc.js new file mode 100644 index 0000000000..9184408ae4 --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/.eslintrc.js @@ -0,0 +1,16 @@ +/* + * Copyright Red Hat, Inc. + * + * 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. + */ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/CHANGELOG.md b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/CHANGELOG.md similarity index 92% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/CHANGELOG.md rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/CHANGELOG.md index b4ef444392..69ea721261 100644 --- a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/CHANGELOG.md +++ b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/CHANGELOG.md @@ -1,4 +1,4 @@ -# @red-hat-developer-hub/backstage-plugin-mcp-registry-server-mapping-common +# @red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping ## 0.3.0 diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/README.md b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/README.md similarity index 85% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/README.md rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/README.md index bcb3e8652e..44be165258 100644 --- a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/README.md +++ b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/README.md @@ -1,4 +1,4 @@ -# @red-hat-developer-hub/backstage-plugin-mcp-registry-server-mapping-common +# @red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping Deterministic transform from [MCP Registry](https://github.com/modelcontextprotocol/registry) **v1.8.1** @@ -6,13 +6,13 @@ Deterministic transform from [MCP Registry](https://github.com/modelcontextproto documents to Backstage `API` entities with `spec.type: mcp-server`. This common library is a pure mapping contract (no I/O, no registry client). -It is intended for consumers such as a future `mcp-registry-provider` catalog +It is consumed by the `catalog-backend-module-mcp-registry-provider` catalog entity provider. ## Install ```bash -yarn add @red-hat-developer-hub/backstage-plugin-mcp-registry-server-mapping-common +yarn add @red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping ``` ## Usage @@ -23,8 +23,8 @@ yarn add @red-hat-developer-hub/backstage-plugin-mcp-registry-server-mapping-com import { mapServerToEntity, projectAnnotations, -} from '@red-hat-developer-hub/backstage-plugin-mcp-registry-server-mapping-common'; -import type { McpServerDocument } from '@red-hat-developer-hub/backstage-plugin-mcp-registry-server-mapping-common'; +} from '@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping'; +import type { McpServerDocument } from '@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping'; const doc: McpServerDocument = { $schema: @@ -78,7 +78,8 @@ fields) with an actionable message. ## `server.json` types Field-level breakdowns of the MCP Registry **v1.8.1** `server.json` TypeScript -shapes live in [`docs/server-json-types.md`](./docs/server-json-types.md) +shapes live in +[`docs/server-json-types.md`](../../docs/server-json-types.md) (source of truth: [`src/types.ts`](./src/types.ts)). ### Caller defaults (`McpServerMappingDefaults`) @@ -103,8 +104,8 @@ Design decisions and scenarios live under ## Examples -See [`examples/server-json/`](./examples/server-json/) for rewritten MCP Registry -`server.json` fixtures useful for local testing. +See [`examples/mcp-registry/server-json/`](../../examples/mcp-registry/server-json/) +for rewritten MCP Registry `server.json` fixtures useful for local testing. ## Development @@ -115,7 +116,7 @@ From the `workspaces/ai-integrations` workspace root: yarn tsc # Unit tests for this package -yarn test -- plugins/mcp-registry-server-mapping-common/src +yarn test -- plugins/catalog-mcp-registry-server-mapping/src # Lint / API report (when public exports change) yarn lint:all diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/package.json b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/package.json similarity index 54% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/package.json rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/package.json index 6a286c2d00..0a46cad41d 100644 --- a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/package.json +++ b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/package.json @@ -1,5 +1,5 @@ { - "name": "@red-hat-developer-hub/backstage-plugin-mcp-registry-server-mapping-common", + "name": "@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping", "version": "0.3.0", "license": "Apache-2.0", "description": "Common library that provides a deterministic transformation functions for transforming an MCP Registry server.json into Backstage mcp-server API entity (direct field mapping).", @@ -13,24 +13,30 @@ "repository": { "type": "git", "url": "https://github.com/redhat-developer/rhdh-plugins", - "directory": "workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common" + "directory": "workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping" }, "backstage": { "role": "common-library", - "pluginId": "mcp-registry-provider", - "pluginPackage": "@red-hat-developer-hub/backstage-plugin-mcp-registry-server-mapping-common", + "pluginId": "catalog-mcp-registry-server-mapping", + "pluginPackage": "@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping", "pluginPackages": [ - "@red-hat-developer-hub/backstage-plugin-mcp-registry-server-mapping-common" + "@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping" ] }, "sideEffects": false, "scripts": { "build": "backstage-cli package build", - "lint": "backstage-cli package lint", - "test": "backstage-cli package test", "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "lint:check": "backstage-cli package lint", + "lint:fix": "backstage-cli package lint --fix", + "postpack": "backstage-cli package postpack", "prepack": "backstage-cli package prepack", - "postpack": "backstage-cli package postpack" + "start": "backstage-cli package start", + "test": "backstage-cli package test --passWithNoTests --coverage", + "tsc": "tsc", + "prettier:check": "prettier --ignore-unknown --check .", + "prettier:fix": "prettier --ignore-unknown --write ." }, "dependencies": { "@backstage/catalog-model": "^1.10.1" diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/report.api.md b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/report.api.md similarity index 99% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/report.api.md rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/report.api.md index afe12d225e..1f3edeebf3 100644 --- a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/report.api.md +++ b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/report.api.md @@ -1,4 +1,4 @@ -## API Report File for "@red-hat-developer-hub/backstage-plugin-mcp-registry-server-mapping-common" +## API Report File for "@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping" > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/annotationProjection.test.ts b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/annotationProjection.test.ts similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/annotationProjection.test.ts rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/annotationProjection.test.ts diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/annotationProjection.ts b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/annotationProjection.ts similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/annotationProjection.ts rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/annotationProjection.ts diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/identity.test.ts b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/identity.test.ts similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/identity.test.ts rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/identity.test.ts diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/identity.ts b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/identity.ts similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/identity.ts rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/identity.ts diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/index.ts b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/index.ts similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/index.ts rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/index.ts diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/mapServerToEntity.test.ts b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/mapServerToEntity.test.ts similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/mapServerToEntity.test.ts rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/mapServerToEntity.test.ts diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/mapServerToEntity.ts b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/mapServerToEntity.ts similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/mapServerToEntity.ts rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/mapServerToEntity.ts diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/repository.test.ts b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/repository.test.ts similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/repository.test.ts rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/repository.test.ts diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/repository.ts b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/repository.ts similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/repository.ts rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/repository.ts diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/types.ts b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/types.ts similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/types.ts rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/types.ts diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/urlPolicy.test.ts b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/urlPolicy.test.ts similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/urlPolicy.test.ts rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/urlPolicy.test.ts diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/urlPolicy.ts b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/urlPolicy.ts similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/urlPolicy.ts rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/urlPolicy.ts diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/util.test.ts b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/util.test.ts similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/util.test.ts rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/util.test.ts diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/util.ts b/workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/util.ts similarity index 100% rename from workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/src/util.ts rename to workspaces/ai-integrations/plugins/catalog-mcp-registry-server-mapping/src/util.ts diff --git a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/docs/server-json-types.md b/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/docs/server-json-types.md deleted file mode 100644 index 5170b80346..0000000000 --- a/workspaces/ai-integrations/plugins/mcp-registry-server-mapping-common/docs/server-json-types.md +++ /dev/null @@ -1,179 +0,0 @@ -# `server.json` types - -TypeScript shapes for MCP Registry **v1.8.1** -[`server.schema.json`](https://raw.githubusercontent.com/modelcontextprotocol/registry/v1.8.1/docs/reference/server-json/draft/server.schema.json) -live in [`src/types.ts`](../src/types.ts). Each table below mirrors one exported -type; headings link to the declaration in source. - -## [`McpServerDocument`](../src/types.ts#L279) - -Root `server.json` document (`ServerDetail`). Closed shape — only these fields -are allowed. - -| Field | Type | Required | Description | -| ------------- | ---------------------------------------------- | -------- | --------------------------------------------------------------- | -| `$schema` | `string` | yes | Absolute JSON Schema URI whose basename is `server.schema.json` | -| `name` | `string` | yes | Reverse-DNS server name (`namespace/name`, exactly one `/`) | -| `title` | `string` | no | Optional human-readable display name | -| `description` | `string` | yes | Human-readable explanation of server capabilities | -| `version` | `string` | yes | Server version (semver preferred; ranges rejected) | -| `websiteUrl` | `string` | no | Homepage / docs / project website URL | -| `repository` | [`McpServerRepository`](../src/types.ts#L176) | no | Source repository metadata | -| `remotes` | [`McpRegistryRemote`](../src/types.ts#L161)[] | no | Remote transports (`streamable-http` / `sse`) | -| `icons` | [`McpRegistryIcon`](../src/types.ts#L208)[] | no | UI icons | -| `packages` | [`McpRegistryPackage`](../src/types.ts#L230)[] | no | Installable package entries | -| `_meta` | [`McpServerMeta`](../src/types.ts#L260) | no | Reverse-DNS extension metadata | - -## [`McpServerRepository`](../src/types.ts#L176) - -Repository metadata for browsing and cloning source (`Repository`). - -| Field | Type | Required | Description | -| ----------- | -------- | -------- | ------------------------------------------------------------ | -| `url` | `string` | yes | Repository URL (web browse and git clone) | -| `source` | `string` | yes | Hosting service id (`github`, `gitlab`, `bitbucket`, …) | -| `id` | `string` | no | Hosting-service repo id (stable across renames) | -| `subfolder` | `string` | no | Clean relative path from repo root to the server (monorepos) | - -## [`McpRegistryRemote`](../src/types.ts#L161) - -Remote transport entry (`RemoteTransport`): `streamable-http` or `sse`, plus -optional URL template variables. - -| Field | Type | Required | Description | -| ----------- | -------------------------------------------------------------- | -------- | --------------------------------- | -| `type` | `'streamable-http' \| 'sse'` | yes | Remote transport kind | -| `url` | `string` | yes | Endpoint URL template | -| `headers` | [`McpKeyValueInput`](../src/types.ts#L62)[] | no | Optional HTTP headers | -| `variables` | `Record` ([`McpInput`](../src/types.ts#L26)) | no | URL template variable definitions | - -## [`McpRegistryIcon`](../src/types.ts#L208) - -Icon resource for client UIs (`Icon`). - -| Field | Type | Required | Description | -| ---------- | ------------------------------------------------------------------------------- | -------- | -------------------------------- | -| `src` | `string` | yes | URI of the icon resource | -| `mimeType` | `'image/png' \| 'image/jpeg' \| 'image/jpg' \| 'image/svg+xml' \| 'image/webp'` | no | MIME type override | -| `sizes` | `string[]` | no | Size hints (e.g. `48x48`, `any`) | -| `theme` | `'light' \| 'dark'` | no | Theme the icon is designed for | - -## [`McpRegistryPackage`](../src/types.ts#L230) - -Installable package entry (`Package`). - -| Field | Type | Required | Description | -| ---------------------- | ------------------------------------------- | -------- | ------------------------------------------------ | -| `registryType` | `string` | yes | Registry kind (`npm`, `pypi`, `cargo`, `oci`, …) | -| `identifier` | `string` | yes | Package name or download URL | -| `transport` | [`McpLocalTransport`](../src/types.ts#L147) | yes | Local / package transport config | -| `version` | `string` | no | Specific package version (no ranges) | -| `registryBaseUrl` | `string` | no | Base URL of the package registry | -| `runtimeHint` | `string` | no | Runtime hint (`npx`, `uvx`, `docker`, …) | -| `fileSha256` | `string` | no | SHA-256 of the package file | -| `environmentVariables` | [`McpKeyValueInput`](../src/types.ts#L62)[] | no | Environment variables for the package | -| `packageArguments` | [`McpArgument`](../src/types.ts#L101)[] | no | Arguments for the package binary | -| `runtimeArguments` | [`McpArgument`](../src/types.ts#L101)[] | no | Arguments for the runtime command | - -## [`McpServerMeta`](../src/types.ts#L260) - -Extension metadata (`ServerDetail._meta`) with reverse-DNS keys. - -| Field | Type | Required | Description | -| ----------------------------------------------------- | ------------------------- | -------- | -------------------------------------------- | -| `io.modelcontextprotocol.registry/publisher-provided` | `Record` | no | Publisher metadata for downstream registries | -| `[key: string]` | `unknown` | no | Additional reverse-DNS namespaced extensions | - -## [`McpLocalTransport`](../src/types.ts#L147) - -Local / package transport union (`LocalTransport`). - -| Variant | `type` | Description | -| ---------------------------------------------------- | ------------------- | ---------------------------- | -| [`McpStdioTransport`](../src/types.ts#L109) | `'stdio'` | Stdio local transport | -| [`McpStreamableHttpTransport`](../src/types.ts#L119) | `'streamable-http'` | Streamable HTTP transport | -| [`McpSseTransport`](../src/types.ts#L133) | `'sse'` | Server-Sent Events transport | - -### [`McpStdioTransport`](../src/types.ts#L109) - -| Field | Type | Required | Description | -| ------ | --------- | -------- | ----------- | -| `type` | `'stdio'` | yes | Literal | - -### [`McpStreamableHttpTransport`](../src/types.ts#L119) - -| Field | Type | Required | Description | -| --------- | ------------------------------------------- | -------- | --------------------- | -| `type` | `'streamable-http'` | yes | Literal | -| `url` | `string` | yes | URL template | -| `headers` | [`McpKeyValueInput`](../src/types.ts#L62)[] | no | Optional HTTP headers | - -### [`McpSseTransport`](../src/types.ts#L133) - -| Field | Type | Required | Description | -| --------- | ------------------------------------------- | -------- | ------------------------- | -| `type` | `'sse'` | yes | Literal | -| `url` | `string` | yes | SSE endpoint URL template | -| `headers` | [`McpKeyValueInput`](../src/types.ts#L62)[] | no | Optional HTTP headers | - -## [`McpInput`](../src/types.ts#L26) - -Shared input leaf (`Input`) used by env vars, headers, variables, and arguments. - -| Field | Type | Required | Description | -| ------------- | ------------------------------------------------- | -------- | -------------------------------------------- | -| `choices` | `string[]` | no | Allowed values the user must select from | -| `default` | `string` | no | Default value | -| `description` | `string` | no | Human-readable description for clients | -| `format` | `'string' \| 'number' \| 'boolean' \| 'filepath'` | no | Input format hint | -| `isRequired` | `boolean` | no | Whether the input is required | -| `isSecret` | `boolean` | no | Whether the input is a secret value | -| `placeholder` | `string` | no | Placeholder shown during configuration | -| `value` | `string` | no | Fixed value (end users should not configure) | - -## [`McpInputWithVariables`](../src/types.ts#L51) - -Extends [`McpInput`](../src/types.ts#L26) with nested `{curly_brace}` variables -(`InputWithVariables`). - -| Field | Type | Required | Description | -| ----------- | -------------------------------------------------------------- | -------- | --------------------------------- | -| `variables` | `Record` ([`McpInput`](../src/types.ts#L26)) | no | Nested variable input definitions | - -## [`McpKeyValueInput`](../src/types.ts#L62) - -Named key/value input for env vars or headers (`KeyValueInput`). Extends -[`McpInputWithVariables`](../src/types.ts#L51). - -| Field | Type | Required | Description | -| ------ | -------- | -------- | ----------------------------------- | -| `name` | `string` | yes | Header or environment variable name | - -## [`McpArgument`](../src/types.ts#L101) - -Package or runtime argument union (`Argument`). - -| Variant | `type` | Description | -| ---------------------------------------------- | -------------- | -------------------------------- | -| [`McpPositionalArgument`](../src/types.ts#L73) | `'positional'` | Positional command-line argument | -| [`McpNamedArgument`](../src/types.ts#L87) | `'named'` | Named flag (`--flag={value}`) | - -### [`McpPositionalArgument`](../src/types.ts#L73) - -Extends [`McpInputWithVariables`](../src/types.ts#L51). - -| Field | Type | Required | Description | -| ------------ | -------------- | -------- | ------------------------------------ | -| `type` | `'positional'` | yes | Literal | -| `isRepeated` | `boolean` | no | Whether the argument may be repeated | -| `valueHint` | `string` | no | Identifier / label for the argument | - -### [`McpNamedArgument`](../src/types.ts#L87) - -Extends [`McpInputWithVariables`](../src/types.ts#L51). - -| Field | Type | Required | Description | -| ------------ | --------- | -------- | ------------------------------------ | -| `type` | `'named'` | yes | Literal | -| `name` | `string` | yes | Flag name, including leading dashes | -| `isRepeated` | `boolean` | no | Whether the argument may be repeated | diff --git a/workspaces/ai-integrations/scripts/.eslintrc.js b/workspaces/ai-integrations/scripts/.eslintrc.js new file mode 100644 index 0000000000..f6315ceedd --- /dev/null +++ b/workspaces/ai-integrations/scripts/.eslintrc.js @@ -0,0 +1,19 @@ +/* + * Copyright Red Hat, Inc. + * + * 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. + */ +module.exports = require('@backstage/cli/config/eslint-factory').createConfigForRole( + __dirname, + 'cli', +); diff --git a/workspaces/ai-integrations/scripts/deploy-local-mcp-registry.ts b/workspaces/ai-integrations/scripts/deploy-local-mcp-registry.ts new file mode 100755 index 0000000000..724a22adf0 --- /dev/null +++ b/workspaces/ai-integrations/scripts/deploy-local-mcp-registry.ts @@ -0,0 +1,266 @@ +#!/usr/bin/env node +/* + * Copyright Red Hat, Inc. + * + * 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. + */ + +/** + * Start a local MCP Registry for provider development. + * + * Upstream `make dev-compose` builds the registry image with ko into the Docker + * daemon. ko does not work with podman, so this script uses the published GHCR + * image and the upstream docker-compose.yml (postgres + registry) instead. + * + * Set MCP_REGISTRY_DATA_DIR to mount a custom host directory over /data (instead + * of the checkout's ./data, which includes the default seed.json). When set, + * seeding defaults to data/seed.json with registry validation disabled unless + * MCP_REGISTRY_SEED_FROM / MCP_REGISTRY_ENABLE_REGISTRY_VALIDATION are already + * set. + */ + +const { spawnSync } = require('node:child_process'); +const { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, + writeFileSync, +} = require('node:fs'); +const { homedir } = require('node:os'); +const { join, resolve } = require('node:path'); + +/** Fixed, typically non-writable dirs — avoid PATH-based binary lookup (S4036). */ +const SAFE_BIN_DIRS = ['/usr/bin', '/bin', '/usr/local/bin']; + +const CACHE_ROOT = join(homedir(), '.cache', 'rhdh-ai-integrations'); +const DEFAULT_REPO_DIR = join(CACHE_ROOT, 'mcp-registry'); + +const REPO_DIR = process.env.MCP_REGISTRY_REPO_DIR?.trim() || DEFAULT_REPO_DIR; +const REPO_URL = + process.env.MCP_REGISTRY_REPO_URL?.trim() || + 'https://github.com/modelcontextprotocol/registry.git'; +const REPO_REVISION = + process.env.MCP_REGISTRY_REPO_REVISION?.trim() || 'v1.8.1'; +const IMAGE_NAME = + process.env.MCP_REGISTRY_IMAGE_NAME?.trim() || + 'ghcr.io/modelcontextprotocol/registry'; +const IMAGE_TAG = process.env.MCP_REGISTRY_IMAGE_TAG?.trim() || '1.8.1'; +const IMAGE = `${IMAGE_NAME}:${IMAGE_TAG}`; +const DATA_DIR = process.env.MCP_REGISTRY_DATA_DIR?.trim(); +const REGISTRY_URL = + process.env.MCP_REGISTRY_URL?.trim() || 'http://localhost:8080'; +const API_VERSION = process.env.MCP_REGISTRY_API_VERSION?.trim() || 'v0.1'; +const READY_TIMEOUT_MS = Number( + process.env.MCP_REGISTRY_READY_TIMEOUT_MS?.trim() || 300_000, +); + +function findBinary(name: string): string | undefined { + for (const dir of SAFE_BIN_DIRS) { + const candidate = join(dir, name); + if (existsSync(candidate)) { + return candidate; + } + } + return undefined; +} + +function requireBinary(name: string): string { + const path = findBinary(name); + if (!path) { + throw new Error( + `command not found in ${SAFE_BIN_DIRS.join(', ')}: ${name}`, + ); + } + return path; +} + +function composeVersionOk(binPath: string): boolean { + return ( + spawnSync(binPath, ['compose', 'version'], { stdio: 'ignore' }).status === 0 + ); +} + +function resolveCompose(): [string, ...string[]] { + const podman = findBinary('podman'); + if (podman && composeVersionOk(podman)) { + return [podman, 'compose']; + } + const docker = findBinary('docker'); + if (docker && composeVersionOk(docker)) { + return [docker, 'compose']; + } + throw new Error("need 'podman compose' or 'docker compose'"); +} + +function resolveDataDir(): string | undefined { + if (!DATA_DIR) { + return undefined; + } + const absoluteDataDir = resolve(DATA_DIR); + if ( + !existsSync(absoluteDataDir) || + !statSync(absoluteDataDir).isDirectory() + ) { + console.error( + `error: MCP_REGISTRY_DATA_DIR must be an existing directory: ${absoluteDataDir}`, + ); + process.exit(1); + } + return absoluteDataDir; +} + +function buildOverrideYaml(image: string, dataDir?: string): string { + const lines = ['services:', ' registry:', ` image: ${image}`]; + if (dataDir) { + // Replace upstream ./data:/data:ro with a custom host directory. + // `:z` is required for Podman/SELinux so the container (uid 65532) can + // read the bind-mounted seed files; without it open() returns EACCES. + const volumeMount = `${dataDir}:/data:ro,z`; + lines.push(' volumes:', ` - ${JSON.stringify(volumeMount)}`); + } + return `${lines.join('\n')}\n`; +} + +/** Private cache dir under $HOME — avoid world-writable /tmp (S5443). */ +function createPrivateTempDir(prefix: string): string { + mkdirSync(CACHE_ROOT, { recursive: true, mode: 0o700 }); + return mkdtempSync(join(CACHE_ROOT, prefix)); +} + +/** + * Block until the registry HTTP API answers. `compose up -d` returns before + * migrations/seed finish; the process only listens on :8080 after import. + */ +function waitForRegistryReady(baseUrl: string, timeoutMs: number): void { + const curl = requireBinary('curl'); + const sleep = requireBinary('sleep'); + const probeUrl = `${baseUrl.replace( + /\/$/, + '', + )}/${API_VERSION}/servers?limit=1`; + const deadline = Date.now() + timeoutMs; + console.log(`Waiting for MCP Registry at ${probeUrl}...`); + while (Date.now() < deadline) { + const probe = spawnSync( + curl, + ['-sf', '--connect-timeout', '1', '--max-time', '3', probeUrl], + { encoding: 'utf8' }, + ); + if (probe.status === 0) { + console.log('MCP Registry is ready.'); + return; + } + spawnSync(sleep, ['1']); + } + throw new Error( + `MCP Registry did not become ready at ${probeUrl} within ${timeoutMs}ms`, + ); +} + +function runGit(args: string[], cwd?: string): void { + const git = requireBinary('git'); + const result = spawnSync(git, args, { + cwd, + stdio: 'inherit', + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +/** Clone or update the registry checkout to MCP_REGISTRY_REPO_REVISION. */ +function ensureRegistryCheckout(): void { + if (!existsSync(join(REPO_DIR, '.git'))) { + if (existsSync(REPO_DIR)) { + console.error(`error: ${REPO_DIR} exists but is not a git repository`); + process.exit(1); + } + console.log(`Cloning ${REPO_URL} (${REPO_REVISION}) into ${REPO_DIR}...`); + runGit([ + 'clone', + '--branch', + REPO_REVISION, + '--depth', + '1', + REPO_URL, + REPO_DIR, + ]); + return; + } + + console.log(`Checking out ${REPO_REVISION} in ${REPO_DIR}...`); + runGit(['fetch', '--depth', '1', 'origin', REPO_REVISION], REPO_DIR); + runGit(['checkout', '--force', 'FETCH_HEAD'], REPO_DIR); +} + +ensureRegistryCheckout(); + +const dataDir = resolveDataDir(); + +let compose: [string, ...string[]]; +try { + compose = resolveCompose(); +} catch (error) { + console.error(`error: ${error instanceof Error ? error.message : error}`); + process.exit(1); +} + +const overrideDir = createPrivateTempDir('mcp-registry-'); +const overridePath = join(overrideDir, 'override.yml'); +writeFileSync(overridePath, buildOverrideYaml(IMAGE, dataDir), { + encoding: 'utf8', + mode: 0o600, +}); + +const composeEnv = { ...process.env }; +if (dataDir) { + // Match upstream offline seeding: + // MCP_REGISTRY_SEED_FROM=data/seed.json MCP_REGISTRY_ENABLE_REGISTRY_VALIDATION=false + if (!composeEnv.MCP_REGISTRY_SEED_FROM?.trim()) { + composeEnv.MCP_REGISTRY_SEED_FROM = 'data/seed.json'; + } + if (!composeEnv.MCP_REGISTRY_ENABLE_REGISTRY_VALIDATION?.trim()) { + composeEnv.MCP_REGISTRY_ENABLE_REGISTRY_VALIDATION = 'false'; + } +} + +try { + const seedNote = dataDir ? ` with data from ${dataDir}` : ''; + console.log( + `Starting MCP Registry from ${IMAGE}${seedNote} (${REGISTRY_URL})...`, + ); + const [bin, ...prefix] = compose; + const result = spawnSync( + bin, + [...prefix, '-f', 'docker-compose.yml', '-f', overridePath, 'up', '-d'], + { cwd: REPO_DIR, stdio: 'inherit', env: composeEnv }, + ); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + try { + waitForRegistryReady(REGISTRY_URL, READY_TIMEOUT_MS); + } catch (error) { + console.error(`error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + console.log( + `MCP Registry started in background. Use '${compose.join( + ' ', + )} -f ${REPO_DIR}/docker-compose.yml logs' to view logs.`, + ); +} finally { + rmSync(overrideDir, { recursive: true, force: true }); +} diff --git a/workspaces/ai-integrations/scripts/undeploy-local-mcp-registry.ts b/workspaces/ai-integrations/scripts/undeploy-local-mcp-registry.ts new file mode 100755 index 0000000000..4e819cf901 --- /dev/null +++ b/workspaces/ai-integrations/scripts/undeploy-local-mcp-registry.ts @@ -0,0 +1,156 @@ +#!/usr/bin/env node +/* + * Copyright Red Hat, Inc. + * + * 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. + */ + +/** Stop the local MCP Registry started by deploy-local-mcp-registry.ts. */ + +const { spawnSync } = require('node:child_process'); +const { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} = require('node:fs'); +const { homedir } = require('node:os'); +const { join } = require('node:path'); + +/** Fixed, typically non-writable dirs — avoid PATH-based binary lookup (S4036). */ +const SAFE_BIN_DIRS = ['/usr/bin', '/bin', '/usr/local/bin']; + +const CACHE_ROOT = join(homedir(), '.cache', 'rhdh-ai-integrations'); +const DEFAULT_REPO_DIR = join(CACHE_ROOT, 'mcp-registry'); + +const REPO_DIR = process.env.MCP_REGISTRY_REPO_DIR?.trim() || DEFAULT_REPO_DIR; +const REPO_URL = + process.env.MCP_REGISTRY_REPO_URL?.trim() || + 'https://github.com/modelcontextprotocol/registry.git'; +const REPO_REVISION = + process.env.MCP_REGISTRY_REPO_REVISION?.trim() || 'v1.8.1'; +const IMAGE_NAME = + process.env.MCP_REGISTRY_IMAGE_NAME?.trim() || + 'ghcr.io/modelcontextprotocol/registry'; +const IMAGE_TAG = process.env.MCP_REGISTRY_IMAGE_TAG?.trim() || '1.8.1'; +const IMAGE = `${IMAGE_NAME}:${IMAGE_TAG}`; + +function findBinary(name: string): string | undefined { + for (const dir of SAFE_BIN_DIRS) { + const candidate = join(dir, name); + if (existsSync(candidate)) { + return candidate; + } + } + return undefined; +} + +function requireBinary(name: string): string { + const path = findBinary(name); + if (!path) { + throw new Error( + `command not found in ${SAFE_BIN_DIRS.join(', ')}: ${name}`, + ); + } + return path; +} + +function composeVersionOk(binPath: string): boolean { + return ( + spawnSync(binPath, ['compose', 'version'], { stdio: 'ignore' }).status === 0 + ); +} + +function resolveCompose(): [string, ...string[]] { + const podman = findBinary('podman'); + if (podman && composeVersionOk(podman)) { + return [podman, 'compose']; + } + const docker = findBinary('docker'); + if (docker && composeVersionOk(docker)) { + return [docker, 'compose']; + } + throw new Error("need 'podman compose' or 'docker compose'"); +} + +/** Private cache dir under $HOME — avoid world-writable /tmp (S5443). */ +function createPrivateTempDir(prefix: string): string { + mkdirSync(CACHE_ROOT, { recursive: true, mode: 0o700 }); + return mkdtempSync(join(CACHE_ROOT, prefix)); +} + +function runGit(args: string[], cwd?: string): void { + const git = requireBinary('git'); + const result = spawnSync(git, args, { + cwd, + stdio: 'inherit', + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +/** Clone the registry checkout if missing (same URL/revision as deploy). */ +function ensureRegistryCheckout(): void { + if (existsSync(join(REPO_DIR, '.git'))) { + return; + } + if (existsSync(REPO_DIR)) { + console.error(`error: ${REPO_DIR} exists but is not a git repository`); + process.exit(1); + } + console.log(`Cloning ${REPO_URL} (${REPO_REVISION}) into ${REPO_DIR}...`); + runGit([ + 'clone', + '--branch', + REPO_REVISION, + '--depth', + '1', + REPO_URL, + REPO_DIR, + ]); +} + +ensureRegistryCheckout(); + +let compose: [string, ...string[]]; +try { + compose = resolveCompose(); +} catch (error) { + console.error(`error: ${error instanceof Error ? error.message : error}`); + process.exit(1); +} + +const overrideDir = createPrivateTempDir('mcp-registry-'); +const overridePath = join(overrideDir, 'override.yml'); +writeFileSync(overridePath, `services:\n registry:\n image: ${IMAGE}\n`, { + encoding: 'utf8', + mode: 0o600, +}); + +try { + console.log(`Stopping MCP Registry in ${REPO_DIR}...`); + const [bin, ...prefix] = compose; + const result = spawnSync( + bin, + [...prefix, '-f', 'docker-compose.yml', '-f', overridePath, 'down'], + { cwd: REPO_DIR, stdio: 'inherit' }, + ); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + console.log('MCP Registry stopped.'); +} finally { + rmSync(overrideDir, { recursive: true, force: true }); +} diff --git a/workspaces/ai-integrations/yarn.lock b/workspaces/ai-integrations/yarn.lock index d75f623214..cd4301d965 100644 --- a/workspaces/ai-integrations/yarn.lock +++ b/workspaces/ai-integrations/yarn.lock @@ -9863,6 +9863,25 @@ __metadata: languageName: unknown linkType: soft +"@red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider@workspace:^, @red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider@workspace:plugins/catalog-backend-module-mcp-registry-provider": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider@workspace:plugins/catalog-backend-module-mcp-registry-provider" + dependencies: + "@backstage/backend-defaults": "npm:^0.17.8" + "@backstage/backend-plugin-api": "npm:^1.10.0" + "@backstage/backend-test-utils": "npm:^1.11.6" + "@backstage/catalog-model": "npm:^1.10.0" + "@backstage/cli": "npm:^0.36.5" + "@backstage/config": "npm:^1.3.8" + "@backstage/plugin-catalog-backend": "npm:^3.9.0" + "@backstage/plugin-catalog-backend-module-ai-model": "npm:^0.1.3" + "@backstage/plugin-catalog-node": "npm:^2.2.4" + "@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping": "workspace:^" + "@types/supertest": "npm:^2.0.12" + supertest: "npm:^6.2.4" + languageName: unknown + linkType: soft + "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-model-catalog@workspace:^, @red-hat-developer-hub/backstage-plugin-catalog-backend-module-model-catalog@workspace:plugins/catalog-backend-module-model-catalog": version: 0.0.0-use.local resolution: "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-model-catalog@workspace:plugins/catalog-backend-module-model-catalog" @@ -9885,6 +9904,15 @@ __metadata: languageName: unknown linkType: soft +"@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping@workspace:^, @red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping@workspace:plugins/catalog-mcp-registry-server-mapping": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/backstage-plugin-catalog-mcp-registry-server-mapping@workspace:plugins/catalog-mcp-registry-server-mapping" + dependencies: + "@backstage/catalog-model": "npm:^1.10.1" + "@backstage/cli": "npm:^0.36.5" + languageName: unknown + linkType: soft + "@red-hat-developer-hub/backstage-plugin-catalog-model-ai-model-server@workspace:^, @red-hat-developer-hub/backstage-plugin-catalog-model-ai-model-server@workspace:plugins/catalog-model-ai-model-server": version: 0.0.0-use.local resolution: "@red-hat-developer-hub/backstage-plugin-catalog-model-ai-model-server@workspace:plugins/catalog-model-ai-model-server" @@ -9984,15 +10012,6 @@ __metadata: languageName: unknown linkType: soft -"@red-hat-developer-hub/backstage-plugin-mcp-registry-server-mapping-common@workspace:plugins/mcp-registry-server-mapping-common": - version: 0.0.0-use.local - resolution: "@red-hat-developer-hub/backstage-plugin-mcp-registry-server-mapping-common@workspace:plugins/mcp-registry-server-mapping-common" - dependencies: - "@backstage/catalog-model": "npm:^1.10.1" - "@backstage/cli": "npm:^0.36.5" - languageName: unknown - linkType: soft - "@red-hat-developer-hub/backstage-plugin-theme@npm:^0.15.0": version: 0.15.0 resolution: "@red-hat-developer-hub/backstage-plugin-theme@npm:0.15.0" @@ -15081,6 +15100,7 @@ __metadata: "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-ai-model-server": "workspace:^" "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-ai-resource-agent": "workspace:^" "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-ai-resource-extensions": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-mcp-registry-provider": "workspace:^" "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-model-catalog": "workspace:^" "@red-hat-developer-hub/backstage-plugin-catalog-techdoc-url-reader-backend": "workspace:^" "@red-hat-developer-hub/backstage-plugin-kserve-kubeflow-connector-backend": "workspace:^"