Repository navigation
[AISAGE-503] feat(ai-gateway): add unused usage enqueue helper #4922
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Kilo MCP catalog | |
| # Keeps services/kilo-mcp/catalog.json and the generated | |
| # .kilo/skills/kilo-mcp/SKILL.md in lockstep with the tRPC router graph. | |
| # | |
| # - PR job: regenerates the catalog with the real dump script, then the | |
| # kilo-mcp skill from that catalog. Same-repo PRs get both artifacts | |
| # committed back to the PR branch (requirement 5); fork PRs cannot be pushed | |
| # to, so the diff ships as a `catalog.patch` artifact, a one-line PR comment | |
| # points at it, and the job fails (requirement 6). A fork PR also gets no | |
| # catalog credential (GitHub withholds secrets from fork events), so when the | |
| # dump itself cannot run, a dedicated step posts the credential-free recovery | |
| # — hand-written summaries in catalog.json, which the keep-edit rule preserves | |
| # — and fails the job, so requirement 6 holds on that path too. The PR job | |
| # never touches Vectorize or the embed script (requirement 10) and never | |
| # generates summaries at runtime — only the dump does, and it preserves | |
| # author-edited summaries (requirement 2, s1 keep-edit rule). | |
| # `catalog (PR)` is a REQUIRED check on main, so this workflow runs on every | |
| # PR (no pull_request path filter). The job is always reported; the | |
| # change-detection step gates the heavy work on the catalog's source paths so | |
| # an unrelated PR still gets a fast, green check instead of "Expected". | |
| # - Merge job: gated to main pushes only, so PRs structurally cannot reach it | |
| # (requirement 10). The workflow triggers on every main push — the merge job | |
| # is required on main and must always report — and its own change-detection | |
| # step diff against `github.event.before` gates the dump, the skill | |
| # regeneration, and the Vectorize upsert, so an unrelated main push still | |
| # gets a fast, green check. Re-runs the dump to fill any summaries that | |
| # slipped through (requirement 8), regenerates the skill from it, and fails | |
| # when either catalog.json or SKILL.md is stale (requirement 22) before | |
| # upserting the Vectorize index (requirement 9). Production deploys from | |
| # main, so the committed bundled catalog and the upserted index stay in | |
| # lockstep. | |
| # | |
| # The dump's `kilo run` never uses a maintainer's personal credential: both jobs | |
| # mint a short-lived benchmarking-service-account token from apps/web | |
| # (POST /api/internal/mcp-catalog/token) using MCP_CATALOG_TOKEN_SECRET, a | |
| # shared secret whose only purpose is that mint. | |
| # | |
| # The CI wiring of this file is asserted by scripts/kilo-mcp-catalog.test.mjs | |
| # (same pattern as scripts/stacked-ci.test.mjs in .github/workflows/ci.yml). | |
| on: | |
| # No paths filter: `catalog (PR)` is a required status check, and a | |
| # path-filtered workflow leaves that check "Expected" (blocking) on every PR | |
| # it skips. The job gates its own work instead. Same for push: the merge job | |
| # is required on main, so every main push triggers the workflow and the job | |
| # gates its own work against the pushed diff. | |
| pull_request: | |
| push: | |
| branches: [main] | |
| # New commits to the same PR supersede in-progress runs; a main push must not | |
| # be cancelled mid-Vectorize-upsert. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| permissions: | |
| contents: read | |
| jobs: | |
| catalog-pr: | |
| name: catalog (PR) | |
| if: github.event_name == 'pull_request' | |
| runs-on: ${{ vars.RUNNER_DEFAULT_LABEL || 'ubuntu-latest' }} | |
| timeout-minutes: 20 | |
| # contents: write lets the bot commit the refreshed catalog back to a | |
| # same-repo PR branch; pull-requests: write lets it comment on fork PRs. | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| # Full history so the change-detection step below can diff against the | |
| # PR's base sha. | |
| - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 | |
| with: | |
| fetch-depth: 0 | |
| # The job always reports (required check); only the catalog's own paths | |
| # justify the expensive dump. Keep the patterns in sync with the merge | |
| # job's detection step and with scripts/kilo-mcp-catalog.test.mjs. | |
| - name: Detect catalog-relevant changes | |
| id: catalog_changes | |
| run: | | |
| set -euo pipefail | |
| # If the base object is unavailable, run the catalog work rather than | |
| # skip it: a false "no changes" would break the required check's | |
| # promise, while a redundant dump is harmless. | |
| base="${{ github.event.pull_request.base.sha }}" | |
| if ! git rev-parse --verify "${base}^{commit}" > /dev/null 2>&1; then | |
| echo "catalog=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if git diff --name-only "$base" HEAD \ | |
| | grep -Eq '^apps/web/src/|^packages/web-shared/src/|^services/kilo-mcp/|^\.kilo/skills/|^\.github/workflows/kilo-mcp-catalog\.yml$|^scripts/kilo-mcp-catalog\.test\.mjs$'; then | |
| echo "catalog=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "catalog=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Setup pnpm | |
| if: steps.catalog_changes.outputs.catalog == 'true' | |
| uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 | |
| - name: Setup Node | |
| if: steps.catalog_changes.outputs.catalog == 'true' | |
| uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 | |
| with: | |
| node-version-file: '.nvmrc' | |
| cache: 'pnpm' | |
| # The dump shells out to `kilo run` to generate missing summaries. | |
| - name: Install Kilo CLI | |
| if: steps.catalog_changes.outputs.catalog == 'true' | |
| run: npm install -g @kilocode/cli | |
| - name: Install root dependencies | |
| if: steps.catalog_changes.outputs.catalog == 'true' | |
| run: pnpm --filter kilocode-monorepo install --frozen-lockfile --ignore-scripts | |
| - name: Check catalog workflow wiring | |
| if: steps.catalog_changes.outputs.catalog == 'true' | |
| run: node --test scripts/kilo-mcp-catalog.test.mjs | |
| - name: Install web dependencies | |
| if: steps.catalog_changes.outputs.catalog == 'true' | |
| run: pnpm install --frozen-lockfile --filter web... | |
| # Same dummies the jest suite already tolerates (ci.yml does the same for | |
| # the production build). The dump itself layers apps/web/.env.test under | |
| # any real env, and dotenv never overrides exported values, so the minted | |
| # token below always wins over any placeholder. | |
| - name: Setup dummy env | |
| if: steps.catalog_changes.outputs.catalog == 'true' | |
| working-directory: apps/web | |
| run: cp .env.test .env | |
| # Mint a short-lived token for the benchmarking service account so the | |
| # dump's `kilo run` never uses a maintainer's personal credential. The | |
| # mint endpoint ships with the PR that adds this step, so before that | |
| # merge the endpoint 404s; the dump only needs the credential when | |
| # summaries are missing, and it still fails loudly if it does. | |
| - name: Mint catalog token | |
| id: mint | |
| if: steps.catalog_changes.outputs.catalog == 'true' | |
| continue-on-error: true | |
| env: | |
| MCP_CATALOG_TOKEN_SECRET: ${{ secrets.MCP_CATALOG_TOKEN_SECRET }} | |
| WEB_API_BASE_URL: https://app.kilo.ai | |
| run: | | |
| set -euo pipefail | |
| # `curl -f` discards the response body on an HTTP error, which turned | |
| # a diagnosable 404 into a bare "curl: (22)". Capture the status and | |
| # surface the body so the next failure names its cause. | |
| status="$(curl -sS -o /tmp/catalog-mint.json -w '%{http_code}' \ | |
| -X POST "$WEB_API_BASE_URL/api/internal/mcp-catalog/token" \ | |
| -H "Authorization: Bearer $MCP_CATALOG_TOKEN_SECRET" \ | |
| -H 'content-type: application/json' \ | |
| -d '{}')" | |
| if [ "$status" != "200" ]; then | |
| echo "::error::The catalog token mint returned HTTP $status: $(cat /tmp/catalog-mint.json)" | |
| exit 1 | |
| fi | |
| response="$(cat /tmp/catalog-mint.json)" | |
| token="$(printf '%s' "$response" | jq -r '.token')" | |
| org="$(printf '%s' "$response" | jq -r '.organizationId')" | |
| if [ -z "$token" ] || [ "$token" = "null" ]; then | |
| echo "::error::The catalog token mint returned no token" | |
| exit 1 | |
| fi | |
| echo "::add-mask::$token" | |
| echo "KILO_API_KEY=$token" >> "$GITHUB_ENV" | |
| echo "KILO_ORG_ID=$org" >> "$GITHUB_ENV" | |
| - name: Regenerate catalog.json | |
| id: dump | |
| if: steps.catalog_changes.outputs.catalog == 'true' | |
| # Fork PRs never receive MCP_CATALOG_TOKEN_SECRET (GitHub withholds | |
| # secrets from fork pull_request events), so a fork PR that adds a | |
| # query without committed summaries always fails the dump here. | |
| # Tolerate that on forks so the guidance step below can still honour | |
| # requirement 6; a same-repo dump failure stays fatal. | |
| continue-on-error: ${{ github.event.pull_request.head.repo.fork == true }} | |
| run: pnpm --filter web script src/scripts/mcp-catalog/dump.ts | |
| # Generate the kilo-mcp skill from the catalog.json the dump just wrote, | |
| # so the skill can never fall behind the catalog. It needs no | |
| # credentials: it reads the committed (and now refreshed) catalog only. | |
| - name: Regenerate kilo-mcp skill | |
| if: steps.catalog_changes.outputs.catalog == 'true' | |
| run: pnpm --filter web script src/scripts/mcp-catalog/skill.ts | |
| # Requirement 6 when the dump itself cannot run: the drift-gated fork | |
| # steps below never see a diff (the dump exits without writing), so this | |
| # step posts the recovery that works without CI credentials — the | |
| # keep-edit rule preserves hand-written summaries in the committed | |
| # catalog — and fails the job. Must run before drift detection. | |
| - name: Guide fork PR past an un-runnable catalog dump | |
| if: >- | |
| steps.catalog_changes.outputs.catalog == 'true' && | |
| steps.dump.outcome == 'failure' && | |
| github.event.pull_request.head.repo.fork == true | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| COMMENT_BODY: >- | |
| The MCP catalog dump failed on this fork PR. Fork PRs never get | |
| CI's catalog credentials (GitHub withholds secrets from fork | |
| events), so summaries for new query paths cannot be generated here. | |
| To recover, hand-write a summary for each new path in | |
| `services/kilo-mcp/catalog.json` (committed summaries are kept by | |
| the dump), or run | |
| `pnpm --filter web script src/scripts/mcp-catalog/dump.ts` | |
| locally with your own signed-in Kilo CLI (`kilo auth login`) and | |
| push. If the log shows a different error than missing credentials, | |
| fix that and push. | |
| run: | | |
| gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body "$COMMENT_BODY" || true | |
| echo "::error::The MCP catalog dump failed on this fork PR (fork PRs get no catalog credentials). Hand-write a summary for each new path in services/kilo-mcp/catalog.json — the dump keeps committed summaries — or run the dump locally with a signed-in Kilo CLI and push." | |
| exit 1 | |
| # Drift covers both generated artifacts: a hand-edited skill marks the PR | |
| # stale exactly like a stale catalog. | |
| - name: Detect catalog and skill drift | |
| id: drift | |
| if: steps.catalog_changes.outputs.catalog == 'true' | |
| run: | | |
| if git diff --exit-code -- services/kilo-mcp/catalog.json .kilo/skills/kilo-mcp/SKILL.md > /dev/null; then | |
| echo "changed=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "changed=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Same-repo PR: commit the catalog and the generated skill to the PR | |
| # branch under the actions-bot identity (requirement 5). The dump's | |
| # keep-edit rule means an author-edited summary in the committed file | |
| # survives the regen, so this push never reverts a human edit. | |
| - name: Commit catalog and skill to PR branch | |
| if: >- | |
| steps.catalog_changes.outputs.catalog == 'true' && | |
| steps.drift.outputs.changed == 'true' && | |
| github.event.pull_request.head.repo.fork == false | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| HEAD_BRANCH: ${{ github.head_ref }} | |
| run: | | |
| set -euo pipefail | |
| cp services/kilo-mcp/catalog.json /tmp/catalog.json | |
| cp .kilo/skills/kilo-mcp/SKILL.md /tmp/kilo-mcp-SKILL.md | |
| # apps/web/.env is tracked and the dummy-env copy dirties it; reset | |
| # the worktree so the branch switch below cannot be refused. | |
| git checkout -- services/kilo-mcp/catalog.json .kilo/skills/kilo-mcp/SKILL.md apps/web/.env | |
| git fetch --no-tags origin "refs/heads/$HEAD_BRANCH" | |
| git checkout -B "$HEAD_BRANCH" FETCH_HEAD | |
| cp /tmp/catalog.json services/kilo-mcp/catalog.json | |
| cp /tmp/kilo-mcp-SKILL.md .kilo/skills/kilo-mcp/SKILL.md | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git add services/kilo-mcp/catalog.json .kilo/skills/kilo-mcp/SKILL.md | |
| git diff --cached --quiet || git commit -m "chore(kilo-mcp): refresh catalog.json and SKILL.md from CI" | |
| git push origin "HEAD:$HEAD_BRANCH" | |
| # Fork PR: GITHUB_TOKEN cannot push to the fork, so hand the author the | |
| # patch and fail loudly instead of silently leaving the artifacts stale | |
| # (requirement 6). | |
| - name: Export catalog and skill patch (fork PRs) | |
| if: >- | |
| steps.catalog_changes.outputs.catalog == 'true' && | |
| steps.drift.outputs.changed == 'true' && | |
| github.event.pull_request.head.repo.fork == true | |
| run: git diff -- services/kilo-mcp/catalog.json .kilo/skills/kilo-mcp/SKILL.md > catalog.patch | |
| - name: Upload catalog patch artifact | |
| if: >- | |
| steps.catalog_changes.outputs.catalog == 'true' && | |
| steps.drift.outputs.changed == 'true' && | |
| github.event.pull_request.head.repo.fork == true | |
| uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 | |
| with: | |
| name: catalog.patch | |
| path: catalog.patch | |
| # Best-effort: GitHub forces GITHUB_TOKEN read-only on fork PR events, | |
| # so this 403s unless a write-capable token is configured. continue-on-error | |
| # keeps the job failing with the deliberate ::error annotation below rather | |
| # than with a raw comment-permission failure. | |
| - name: Comment on fork PR | |
| if: >- | |
| steps.catalog_changes.outputs.catalog == 'true' && | |
| steps.drift.outputs.changed == 'true' && | |
| github.event.pull_request.head.repo.fork == true | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| COMMENT_BODY: >- | |
| The MCP catalog or the kilo-mcp skill is stale and this PR comes | |
| from a fork, so CI cannot commit | |
| `services/kilo-mcp/catalog.json` or | |
| `.kilo/skills/kilo-mcp/SKILL.md`: download the `catalog.patch` | |
| artifact of | |
| ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| and apply it, or run | |
| `pnpm --filter web script src/scripts/mcp-catalog/dump.ts` and | |
| `pnpm --filter web script src/scripts/mcp-catalog/skill.ts` locally | |
| and push. | |
| run: gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body "$COMMENT_BODY" | |
| - name: Fail fork PR with a stale catalog or skill | |
| if: >- | |
| steps.catalog_changes.outputs.catalog == 'true' && | |
| steps.drift.outputs.changed == 'true' && | |
| github.event.pull_request.head.repo.fork == true | |
| run: | | |
| echo "::error::services/kilo-mcp/catalog.json or .kilo/skills/kilo-mcp/SKILL.md is stale and cannot be committed from a fork PR. Apply the catalog.patch artifact attached to this run, or run the dump and the skill generator locally and push." | |
| exit 1 | |
| catalog-merge: | |
| name: catalog + Vectorize (main) | |
| # Main pushes only — a pull_request run can never reach this job, so no PR | |
| # writes to Vectorize (requirement 10). | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| runs-on: ${{ vars.RUNNER_DEFAULT_LABEL || 'ubuntu-latest' }} | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| steps: | |
| # Full history so the change-detection step can diff the pushed commit | |
| # against `github.event.before`. | |
| - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 | |
| with: | |
| fetch-depth: 0 | |
| # The job always reports (required on main); only the catalog's own paths | |
| # justify the expensive dump and the Vectorize upsert. Keep the patterns | |
| # in sync with scripts/kilo-mcp-catalog.test.mjs. | |
| - name: Detect catalog-relevant changes | |
| id: merge_changes | |
| run: | | |
| set -euo pipefail | |
| # If the pushed commit's parent is unavailable (first push, forced | |
| # push, unknown `before`), run the catalog work rather than skip it: a | |
| # false "no changes" would break the required check's promise, while a | |
| # redundant dump is harmless. | |
| before="${{ github.event.before }}" | |
| if ! git rev-parse --verify "${before}^{commit}" > /dev/null 2>&1; then | |
| echo "catalog=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if git diff --name-only "$before" HEAD \ | |
| | grep -Eq '^apps/web/src/|^packages/web-shared/src/|^services/kilo-mcp/|^\.kilo/skills/|^\.github/workflows/kilo-mcp-catalog\.yml$|^scripts/kilo-mcp-catalog\.test\.mjs$'; then | |
| echo "catalog=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "catalog=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Setup pnpm | |
| if: steps.merge_changes.outputs.catalog == 'true' | |
| uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 | |
| - name: Setup Node | |
| if: steps.merge_changes.outputs.catalog == 'true' | |
| uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 | |
| with: | |
| node-version-file: '.nvmrc' | |
| cache: 'pnpm' | |
| # The dump shells out to `kilo run` to fill any straggler summaries. | |
| - name: Install Kilo CLI | |
| if: steps.merge_changes.outputs.catalog == 'true' | |
| run: npm install -g @kilocode/cli | |
| - name: Install web dependencies | |
| if: steps.merge_changes.outputs.catalog == 'true' | |
| run: pnpm install --frozen-lockfile --filter web... | |
| - name: Setup dummy env | |
| if: steps.merge_changes.outputs.catalog == 'true' | |
| working-directory: apps/web | |
| run: cp .env.test .env | |
| # The first main run after this workflow lands may race the apps/web | |
| # deploy that adds the mint endpoint; the dump only needs the credential | |
| # when summaries are missing, so a failed mint is tolerated here too. | |
| - name: Mint catalog token | |
| if: steps.merge_changes.outputs.catalog == 'true' | |
| id: mint | |
| continue-on-error: true | |
| env: | |
| MCP_CATALOG_TOKEN_SECRET: ${{ secrets.MCP_CATALOG_TOKEN_SECRET }} | |
| WEB_API_BASE_URL: https://app.kilo.ai | |
| run: | | |
| set -euo pipefail | |
| # `curl -f` discards the response body on an HTTP error, which turned | |
| # a diagnosable 404 into a bare "curl: (22)". Capture the status and | |
| # surface the body so the next failure names its cause. | |
| status="$(curl -sS -o /tmp/catalog-mint.json -w '%{http_code}' \ | |
| -X POST "$WEB_API_BASE_URL/api/internal/mcp-catalog/token" \ | |
| -H "Authorization: Bearer $MCP_CATALOG_TOKEN_SECRET" \ | |
| -H 'content-type: application/json' \ | |
| -d '{}')" | |
| if [ "$status" != "200" ]; then | |
| echo "::error::The catalog token mint returned HTTP $status: $(cat /tmp/catalog-mint.json)" | |
| exit 1 | |
| fi | |
| response="$(cat /tmp/catalog-mint.json)" | |
| token="$(printf '%s' "$response" | jq -r '.token')" | |
| org="$(printf '%s' "$response" | jq -r '.organizationId')" | |
| if [ -z "$token" ] || [ "$token" = "null" ]; then | |
| echo "::error::The catalog token mint returned no token" | |
| exit 1 | |
| fi | |
| echo "::add-mask::$token" | |
| echo "KILO_API_KEY=$token" >> "$GITHUB_ENV" | |
| echo "KILO_ORG_ID=$org" >> "$GITHUB_ENV" | |
| # Fill any summaries that slipped through before the index is updated | |
| # (requirement 8); keep-edit semantics leave author edits alone. | |
| - name: Regenerate catalog.json | |
| if: steps.merge_changes.outputs.catalog == 'true' | |
| run: pnpm --filter web script src/scripts/mcp-catalog/dump.ts | |
| # Regenerate the skill from the catalog the dump just wrote, so the | |
| # committed skill cannot drift from its source on main either. | |
| - name: Regenerate kilo-mcp skill | |
| if: steps.merge_changes.outputs.catalog == 'true' | |
| run: pnpm --filter web script src/scripts/mcp-catalog/skill.ts | |
| # The bundled catalog a production deploy serves must match the index this | |
| # run upserts. If the dump or the skill generator changes the checked-in | |
| # files, main was stale (the PR job never landed the refresh): fail | |
| # instead of indexing content the Worker bundle does not carry | |
| # (requirement 22). | |
| - name: Fail if the regenerated catalog or skill is not the committed one | |
| if: steps.merge_changes.outputs.catalog == 'true' | |
| run: | | |
| if ! git diff --exit-code -- services/kilo-mcp/catalog.json .kilo/skills/kilo-mcp/SKILL.md > /dev/null; then | |
| echo "::error::The dump or the skill generator changed services/kilo-mcp/catalog.json or .kilo/skills/kilo-mcp/SKILL.md on main. Commit the regenerated artifacts before the Vectorize upsert; otherwise the deployed Worker bundle and the index diverge." | |
| exit 1 | |
| fi | |
| # Vectorize upsert (requirement 9). The index name and account id match | |
| # the production bindings in services/kilo-mcp/wrangler.jsonc; the | |
| # account id is not a secret (it is committed there). | |
| - name: Upsert Vectorize index | |
| if: steps.merge_changes.outputs.catalog == 'true' | |
| run: node services/kilo-mcp/scripts/embed-catalog.ts upsert | |
| env: | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| CLOUDFLARE_ACCOUNT_ID: e115e769bcdd4c3d66af59d3332cb394 | |
| VECTORIZE_INDEX_NAME: kilo-mcp-catalog |