diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 8b1be36e0d..901efc3e56 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -158,6 +158,22 @@ jobs: source_sha: ${{ needs.check-changes.outputs.target_sha }} secrets: inherit + # The kilo-mcp changelog, GitHub Release and SBOM. It runs after every + # successful worker deploy and releases only when the kilo-mcp bundle changed. + kilo-mcp-release: + needs: [check-changes, deploy-workers] + if: ${{ !cancelled() && needs.deploy-workers.result == 'success' }} + # Ceiling for the reusable workflow's GITHUB_TOKEN (a called workflow cannot + # exceed the caller). + permissions: + contents: write + pull-requests: write + issues: write + uses: ./.github/workflows/kilo-mcp-release.yml + with: + source_sha: ${{ needs.check-changes.outputs.target_sha }} + secrets: inherit + detect-gastown-wasteland-changes: needs: check-changes if: needs.check-changes.outputs.should_deploy == 'true' diff --git a/.github/workflows/kilo-mcp-release.yml b/.github/workflows/kilo-mcp-release.yml new file mode 100644 index 0000000000..bd5d8a2fd5 --- /dev/null +++ b/.github/workflows/kilo-mcp-release.yml @@ -0,0 +1,283 @@ +name: kilo-mcp Release + +# One kilo-mcp release per production deploy that changed the deployed bundle. +# deploy-production.yml calls this after deploy-workers succeeds. A release: +# +# - builds the bundle from the deployed commit (`wrangler deploy --dry-run`, +# the same build `wrangler deploy` uploads) and stops when its SHA-256 +# matches the previous release: no bundle change, no release; +# - writes the CycloneDX SBOM of that bundle (scripts/kilo-mcp-sbom.mjs); +# - tags the commit `kilo-mcp-release/-` and publishes a GitHub +# Release with the notes and the SBOM; +# - lands the changelog section on the `kilo-mcp-changelog` branch and opens +# or refreshes its one pull request, which a human merges into +# services/kilo-mcp/CHANGELOG.md. Main is protected, so the pull request is +# the only path to main, the same as the kilo-app version-bump PR. +# +# A rerun of a failed attempt reuses the tag that points at the deployed +# commit, so it completes the release instead of skipping it. + +on: + workflow_call: + inputs: + source_sha: + description: 'The commit deploy-workers deployed' + required: true + type: string + +permissions: + contents: read + +jobs: + release: + runs-on: ${{ vars.RUNNER_DEFAULT_LABEL || 'ubuntu-latest' }} + timeout-minutes: 15 + permissions: + contents: write # push the changelog branch, publish the release + pull-requests: write # open or refresh the changelog PR + issues: write # PR assignees are an Issues API operation + steps: + # Full history and tags: the release range starts at the previous tag. + - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + with: + fetch-depth: 0 + ref: ${{ inputs.source_sha }} + + # The cheap gate, before any install. The inputs mirror the kilo-mcp row + # of deploy-workers.yml minus `packages`: kilo-mcp imports no workspace + # package. CHANGELOG.md is excluded, so a merged changelog PR never + # starts the next release. + - name: Check for kilo-mcp changes since the last release + id: check + run: | + set -euo pipefail + # A tag at this commit means an earlier attempt already decided to + # release it: reuse the tag and finish that release. + EXISTING=$(git tag --points-at HEAD --list 'kilo-mcp-release/*' | head -n 1) + PREVIOUS=$(git tag --list 'kilo-mcp-release/*' --sort=-creatordate --no-contains HEAD | head -n 1) + echo "previous=$PREVIOUS" >> "$GITHUB_OUTPUT" + echo "resume=$([ -n "$EXISTING" ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + if [ -n "$EXISTING" ]; then + echo "tag=$EXISTING" >> "$GITHUB_OUTPUT" + echo "candidate=true" >> "$GITHUB_OUTPUT" + echo "Resuming the release $EXISTING" + exit 0 + fi + echo "tag=kilo-mcp-release/$(date -u +%Y-%m-%d)-$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT" + if [ -z "$PREVIOUS" ]; then + echo "candidate=true" >> "$GITHUB_OUTPUT" + echo "No earlier kilo-mcp release: first release" + exit 0 + fi + CHANGES=$(git diff --name-only "$PREVIOUS" HEAD -- services/kilo-mcp/ pnpm-lock.yaml pnpm-workspace.yaml patches ':(exclude)services/kilo-mcp/CHANGELOG.md') + if [ -z "$CHANGES" ]; then + echo "candidate=false" >> "$GITHUB_OUTPUT" + echo "No kilo-mcp input changed since $PREVIOUS" + else + echo "candidate=true" >> "$GITHUB_OUTPUT" + printf 'Changed since %s:\n%s\n' "$PREVIOUS" "$CHANGES" + fi + + - name: Setup pnpm + if: steps.check.outputs.candidate == 'true' + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + + - name: Setup Node + if: steps.check.outputs.candidate == 'true' + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + # The root package carries js-yaml for the SBOM script. + - name: Install dependencies + if: steps.check.outputs.candidate == 'true' + run: pnpm install --frozen-lockfile --filter kilocode-monorepo --filter kilo-mcp + + - name: Build the deployed bundle + if: steps.check.outputs.candidate == 'true' + working-directory: services/kilo-mcp + run: pnpm exec wrangler deploy --dry-run --outdir "$RUNNER_TEMP/kilo-mcp-bundle" --metafile "$RUNNER_TEMP/kilo-mcp-bundle/meta.json" + + # The lockfile can change without a change to the bundle. The previous + # release records its bundle hash in its tag message. + - name: Compare the bundle with the last release + id: bundle + if: steps.check.outputs.candidate == 'true' + env: + PREVIOUS: ${{ steps.check.outputs.previous }} + RESUME: ${{ steps.check.outputs.resume }} + run: | + set -euo pipefail + SHA=$(sha256sum "$RUNNER_TEMP/kilo-mcp-bundle/index.js" | cut -d' ' -f1) + echo "sha256=$SHA" >> "$GITHUB_OUTPUT" + # A resumed release always completes, even with an unchanged bundle. + LAST="" + if [ -n "$PREVIOUS" ]; then + LAST=$(git for-each-ref --format='%(contents)' "refs/tags/$PREVIOUS" | sed -n 's/^bundle-sha256=//p') + fi + if [ "$RESUME" != "true" ] && [ "$SHA" = "$LAST" ]; then + echo "release=false" >> "$GITHUB_OUTPUT" + echo "::notice::The kilo-mcp bundle is unchanged since $PREVIOUS: no release." + else + echo "release=true" >> "$GITHUB_OUTPUT" + fi + + - name: Notes for the changelog + id: notes + if: steps.bundle.outputs.release == 'true' + env: + PREVIOUS: ${{ steps.check.outputs.previous }} + run: | + set -euo pipefail + if [ -n "$PREVIOUS" ]; then + node scripts/kilo-mcp-release-notes.mjs body --from "$PREVIOUS" --to HEAD > "$RUNNER_TEMP/kilo-mcp-notes.md" + else + node scripts/kilo-mcp-release-notes.mjs body > "$RUNNER_TEMP/kilo-mcp-notes.md" + fi + cat "$RUNNER_TEMP/kilo-mcp-notes.md" + + - name: Generate SBOM + id: sbom + if: steps.bundle.outputs.release == 'true' + env: + TAG: ${{ steps.check.outputs.tag }} + run: | + set -euo pipefail + NAME="kilo-mcp-${TAG#kilo-mcp-release/}.cyclonedx.json" + OUT=$(node scripts/kilo-mcp-sbom.mjs \ + --metafile "$RUNNER_TEMP/kilo-mcp-bundle/meta.json" \ + --bundle "$RUNNER_TEMP/kilo-mcp-bundle/index.js" \ + --worker-dir services/kilo-mcp \ + --lockfile pnpm-lock.yaml \ + --commit "$(git rev-parse HEAD)" \ + --release "$TAG" \ + --out "$RUNNER_TEMP/$NAME") + printf '%s\n' "$OUT" + echo "file=$RUNNER_TEMP/$NAME" >> "$GITHUB_OUTPUT" + printf '%s\n' "$OUT" | grep '^components=' >> "$GITHUB_OUTPUT" + + # The retained second copy, matching sbom.yml's cloud-sbom- pattern. + - name: Upload SBOM + if: steps.bundle.outputs.release == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: kilo-mcp-sbom-${{ inputs.source_sha }} + path: ${{ steps.sbom.outputs.file }} + retention-days: 90 + if-no-files-found: error + + # Same token as the kilo-app release tags: GitHub refuses a GITHUB_TOKEN + # tag push when the tagged commit's .github/workflows differs from every + # branch tip, and the deployed commit is often behind main. The token + # reaches only this step, never .git/config. + - name: Tag the release + if: steps.bundle.outputs.release == 'true' + env: + RELEASE_TOKEN: ${{ secrets.KILO_APP_RELEASE_TOKEN }} + TAG: ${{ steps.check.outputs.tag }} + BUNDLE_SHA256: ${{ steps.bundle.outputs.sha256 }} + run: | + set -euo pipefail + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" > /dev/null 2>&1; then + echo "Tag $TAG already exists — reusing it." + exit 0 + fi + AUTH=$(printf 'x-access-token:%s' "$RELEASE_TOKEN" | base64 -w0) + echo "::add-mask::$AUTH" + export GIT_CONFIG_COUNT=2 + export GIT_CONFIG_KEY_0=http.https://github.com/.extraheader GIT_CONFIG_VALUE_0= + export GIT_CONFIG_KEY_1=http.https://github.com/.extraheader GIT_CONFIG_VALUE_1="AUTHORIZATION: basic $AUTH" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "$TAG" -m "bundle-sha256=$BUNDLE_SHA256" + git push origin "refs/tags/$TAG" + + # --latest=false: a kilo-mcp release must not replace the release that + # GitHub shows as the repository's latest one. + - name: Publish the release + if: steps.bundle.outputs.release == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.check.outputs.tag }} + BUNDLE_SHA256: ${{ steps.bundle.outputs.sha256 }} + COMPONENTS: ${{ steps.sbom.outputs.components }} + SBOM: ${{ steps.sbom.outputs.file }} + run: | + set -euo pipefail + NOTES="$RUNNER_TEMP/kilo-mcp-release.md" + { + echo "## Changes" + echo + cat "$RUNNER_TEMP/kilo-mcp-notes.md" + echo + echo "## SBOM" + echo + echo "| Artifact | SHA-256 | npm components |" + echo "| --- | --- | --- |" + echo "| index.js | \`$BUNDLE_SHA256\` | $COMPONENTS |" + echo + echo "The SHA-256 names the bundle built from this commit, the same bytes the SBOM describes." + } > "$NOTES" + TITLE="kilo-mcp ${TAG#kilo-mcp-release/}" + if gh release view "$TAG" > /dev/null 2>&1; then + gh release edit "$TAG" --title "$TITLE" --notes-file "$NOTES" --latest=false + gh release upload "$TAG" "$SBOM" --clobber + else + gh release create "$TAG" --verify-tag --latest=false --title "$TITLE" --notes-file "$NOTES" "$SBOM" + fi + + - name: Land the changelog section + if: steps.bundle.outputs.release == 'true' + env: + TAG: ${{ steps.check.outputs.tag }} + run: | + set -euo pipefail + # kilo-mcp-release/2026-10-05-abc1234 -> "## 2026-10-05 (abc1234)" + STAMP="${TAG#kilo-mcp-release/}" + HEADING="## ${STAMP%-*} (${STAMP##*-})" + node scripts/kilo-mcp-release-notes.mjs land \ + --heading "$HEADING" \ + --body-file "$RUNNER_TEMP/kilo-mcp-notes.md" \ + --branch kilo-mcp-changelog + + - name: Open or refresh the changelog PR + if: steps.bundle.outputs.release == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + RUN_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + cat > "$RUNNER_TEMP/kilo-mcp-changelog-pr.md" < -# Kilo MCP +# Kilo MCP (beta) The Kilo MCP server exposes the Kilo API through two tools, `kilo_search` and `kilo_call`. +Kilo MCP is in beta. Its tools, catalog paths, and behavior can change without +notice. The changes are recorded in `services/kilo-mcp/CHANGELOG.md`. + ## How to use it 1. **Search first.** `kilo_search` finds catalog endpoints. Never call a path from memory, and never guess one from this skill. diff --git a/apps/web/src/scripts/mcp-catalog/skill-template.md b/apps/web/src/scripts/mcp-catalog/skill-template.md index 2618679c1e..881560aba1 100644 --- a/apps/web/src/scripts/mcp-catalog/skill-template.md +++ b/apps/web/src/scripts/mcp-catalog/skill-template.md @@ -11,11 +11,14 @@ Then regenerate: pnpm --filter web script src/scripts/mcp-catalog/skill.ts --> -# Kilo MCP +# Kilo MCP (beta) The Kilo MCP server exposes the Kilo API through two tools, `kilo_search` and `kilo_call`. +Kilo MCP is in beta. Its tools, catalog paths, and behavior can change without +notice. The changes are recorded in `services/kilo-mcp/CHANGELOG.md`. + ## How to use it 1. **Search first.** `kilo_search` finds catalog endpoints. Never call a path from memory, and never guess one from this skill. diff --git a/docs/sbom.md b/docs/sbom.md index 06ab0eee4d..2718a11aa4 100644 --- a/docs/sbom.md +++ b/docs/sbom.md @@ -9,7 +9,9 @@ All SBOMs are **CycloneDX JSON**. The repo-wide source dependency tree from `.github/workflows/deploy-kiloclaw.yml` are generated with [syft](https://github.com/anchore/syft) via the official `anchore/sbom-action`. The per-artifact mobile SBOMs are generated by `scripts/mobile-sbom.mjs`, with no syft on that path (the coverage limits below explain why a syft -scan does not reproduce their component list). SBOMs are **never committed** to the repo. +scan does not reproduce their component list). The Kilo MCP bundle SBOM is generated by +`scripts/kilo-mcp-sbom.mjs` from the esbuild metafile of the deployed bundle. SBOMs are **never +committed** to the repo. ## Coverage limits @@ -42,6 +44,7 @@ for each build. Treat them as measurements of one artifact, not as a guarantee a | **Source dependency tree** (repo-wide pnpm graph) | `.github/workflows/sbom.yml` | push to `main`, weekly cron, manual dispatch | Retained CI **workflow artifact** (`cloud-sbom-`) | | **KiloClaw container image** (OS packages + Go + Node + OpenClaw + npm) | `.github/workflows/deploy-kiloclaw.yml` | at image **build time** (only when content changes) | Signed **attestation in GHCR**, bound to the image digest | | **Kilo mobile app artifacts** (one SBOM per shipped IPA and AAB) | `scripts/mobile-sbom.mjs` in `.github/workflows/kilo-app-release.yml` job `build-and-submit` | on **every** production build (push to `main` touching `apps/mobile/**` and its workspace inputs, or `workflow_dispatch`) | **GitHub Release** assets on the `kilo-app-release/-` tag, plus the retained workflow artifact `mobile-sbom-` | +| **Kilo MCP Worker bundle** (npm packages esbuild bundled into `services/kilo-mcp`) | `scripts/kilo-mcp-sbom.mjs` in `.github/workflows/kilo-mcp-release.yml`, called by `deploy-production.yml` | after each production deploy that **changed the bundle** (SHA-256 differs from the previous release) | **GitHub Release** asset on the `kilo-mcp-release/-` tag, plus the retained workflow artifact `kilo-mcp-sbom-` | The image SBOM uses the richest source available — the built image — so it captures the OS-package and multi-ecosystem footprint that a lockfile-only SBOM misses. The image step is gated to the @@ -111,6 +114,25 @@ For Android, compare what the AAB's own metadata carries against what syft repor syft scan apps/mobile/artifacts/app.aab -o cyclonedx-json ``` +## Kilo MCP bundle SBOM + +The release job builds the bundle from the deployed commit with `wrangler deploy --dry-run +--metafile`, the same build `wrangler deploy` uploads. A package is a component only when the +bundle carries bytes of it (`kilo:sbom:bundled-bytes`), so a package that esbuild tree-shook out is +not listed. Wrangler's virtual polyfill modules have no package and are not listed. The hash of +each component is its `pnpm-lock.yaml` integrity. + +The same job writes the changelog section to `services/kilo-mcp/CHANGELOG.md` through the +`kilo-mcp-changelog` pull request. To download an SBOM and check it against a bundle: + +```sh +gh release download kilo-mcp-release/- -p '*.cyclonedx.json' +cd services/kilo-mcp && pnpm exec wrangler deploy --dry-run --outdir /tmp/kilo-mcp-bundle +shasum -a 256 /tmp/kilo-mcp-bundle/index.js +``` + +Run the build at the release commit. The hash must equal `kilo:sbom:artifact-sha256`. + ## Verifying an image SBOM attestation ```sh diff --git a/package.json b/package.json index 01184d8237..71d4efc390 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,13 @@ "preinstall": "npx only-allow pnpm", "typecheck": "scripts/typecheck-all.sh", "build": "pnpm --filter web build", - "test": "pnpm --filter web test && pnpm run test:web-env && pnpm run test:dev-local && pnpm run test:mobile-artifacts && pnpm run test:mobile-sbom && pnpm run test:kilo-app-release", + "test": "pnpm --filter web test && pnpm run test:web-env && pnpm run test:dev-local && pnpm run test:mobile-artifacts && pnpm run test:mobile-sbom && pnpm run test:kilo-app-release && pnpm run test:kilo-mcp-release", "test:web-env": "tsx --tsconfig scripts/web-env/tsconfig.json --test scripts/web-env/*.test.ts", "test:setup-smoke": "pnpm --filter web run test:setup-smoke", "test:mobile-artifacts": "node --test scripts/inspect-mobile-artifacts.test.mjs", "test:mobile-sbom": "node --test scripts/mobile-sbom.test.mjs scripts/mobile-sbom-cyclonedx.test.mjs scripts/mobile-sbom-pnpm.test.mjs scripts/mobile-sbom-ipa.test.mjs scripts/mobile-sbom-aab.test.mjs scripts/mobile-sbom-workflow.test.mjs", "test:kilo-app-release": "node --test scripts/kilo-app-release.test.mjs scripts/kilo-app-release-workflow.test.mjs", + "test:kilo-mcp-release": "node --test scripts/kilo-mcp-release.test.mjs", "lint": "scripts/lint-all.sh", "format": "oxfmt", "format:check": "oxfmt --list-different .", diff --git a/scripts/kilo-app-release-notes.mjs b/scripts/kilo-app-release-notes.mjs index cf4aaf1000..67b04a8752 100644 --- a/scripts/kilo-app-release-notes.mjs +++ b/scripts/kilo-app-release-notes.mjs @@ -100,7 +100,7 @@ function git(args, options = {}) { return execFileSync('git', args, { encoding: 'utf8', ...options }); } -function reasonOf(error) { +export function reasonOf(error) { const stderr = error && error.stderr ? String(error.stderr).trim() : ''; const lines = stderr .split('\n') @@ -190,7 +190,7 @@ export function splitSections(text) { } /** The heading line of a section, i.e. its first line. */ -function headingOf(section) { +export function headingOf(section) { return section.split('\n')[0]; } @@ -490,7 +490,7 @@ function treeFile(parent, path) { } /** A commit on top of `parent` that replaces one file with `content`. */ -function commitOn(parent, path, content, message) { +export function commitOn(parent, path, content, message) { const blob = git(['hash-object', '-w', '--stdin'], { input: content }).trim(); const work = mkdtempSync(join(tmpdir(), 'kilo-notes-commit-')); try { diff --git a/scripts/kilo-mcp-release-notes.mjs b/scripts/kilo-mcp-release-notes.mjs new file mode 100755 index 0000000000..40f2ce818d --- /dev/null +++ b/scripts/kilo-mcp-release-notes.mjs @@ -0,0 +1,249 @@ +#!/usr/bin/env node +/** + * Compose and land one changelog section per kilo-mcp production release. + * + * The kilo-mcp Release workflow runs after a production deploy changed the + * kilo-mcp bundle. `body` lists the merged pull requests that touched + * services/kilo-mcp between two refs, and `land` writes the section into + * services/kilo-mcp/CHANGELOG.md on the changelog branch, which one reusable + * pull request takes to main. + * + * Usage: + * node scripts/kilo-mcp-release-notes.mjs body [--from ] [--to ] + * node scripts/kilo-mcp-release-notes.mjs land --heading --body-file [--branch ] [--remote ] [--base ] + * + * `land` always rebuilds the changelog branch as one commit on the newest + * base branch. The commit carries the base changelog, every section the branch + * holds that the base does not, and the new section above them. A merged + * changelog pull request therefore never loses a section, and the open one + * never conflicts with main. The push is a compare-and-swap on the branch tip + * it read, so a branch that moved or was deleted meanwhile is read again. + * + * Exit codes: + * 0 - the section was landed, or was already present + * 1 - the base has no changelog, or every land attempt was refused + * 2 - usage error, or a --from ref that does not resolve + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +import { + commitOn, + composeSection, + hasSectionHeading, + headingOf, + insertSection, + isPullRequestSubject, + linkPullRequest, + readBodyLines, + reasonOf, + splitSections, +} from './kilo-app-release-notes.mjs'; + +export const CHANGELOG = 'services/kilo-mcp/CHANGELOG.md'; +const WORKER_PATH = 'services/kilo-mcp/'; +const CHANGELOG_EXCLUDE = `:(exclude)${CHANGELOG}`; +const BRANCH_DEFAULT = 'kilo-mcp-changelog'; +const MAX_LAND_ATTEMPTS = 4; + +export const INITIAL_MARKER = '- First release: no earlier release to compare against.'; +// A release happens only when the bundle changed. With no kilo-mcp pull +// request in the range, a dependency or lockfile update changed it. +export const NO_PULL_REQUEST_MARKER = + '- No kilo-mcp pull request: a dependency update changed the deployed bundle.'; + +function git(args, options = {}) { + return execFileSync('git', args, { encoding: 'utf8', ...options }); +} + +function resolveRef(ref) { + try { + git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]); + return true; + } catch { + return false; + } +} + +function treeFile(commit, path) { + try { + return git(['show', `${commit}:${path}`]); + } catch { + return ''; + } +} + +/** The body lines for a release: one linked line per shipped pull request. */ +export function bodyLines(subjects) { + const shipped = subjects.filter(isPullRequestSubject); + if (shipped.length === 0) { + return [NO_PULL_REQUEST_MARKER]; + } + return shipped.map(subject => `- ${linkPullRequest(subject)}`); +} + +/** + * The changelog text for `base` plus the new section and every section the + * branch carries that `base` does not. Returns null when `base` already holds + * all of them. The first section gets a blank line after the header. + */ +export function rebuildChangelog(baseContent, branchContent, heading, section) { + const carried = splitSections(branchContent).filter( + item => headingOf(item) !== heading && !hasSectionHeading(baseContent, headingOf(item)) + ); + const block = (hasSectionHeading(baseContent, heading) ? '' : section) + carried.join(''); + if (block === '') { + return null; + } + const hasSection = /(?:^|\n)## /.test(baseContent); + const base = hasSection ? baseContent : `${baseContent.replace(/\n*$/, '')}\n\n`; + return insertSection(base, block); +} + +function remoteSha(remote, ref) { + const out = git(['ls-remote', remote, ref]).trim(); + return out === '' ? '' : out.split(/\s+/)[0]; +} + +function fetchSha(remote, branch) { + git(['fetch', '--no-tags', remote, `refs/heads/${branch}`]); + return git(['rev-parse', 'FETCH_HEAD']).trim(); +} + +function fail(message) { + console.error(`changelog: ${message}`); + return 1; +} + +function usage(message) { + if (message) { + console.error(`changelog: ${message}`); + } + console.error('Usage:'); + console.error(' node scripts/kilo-mcp-release-notes.mjs body [--from ] [--to ]'); + console.error( + ' node scripts/kilo-mcp-release-notes.mjs land --heading --body-file [--branch ] [--remote ] [--base ]' + ); + return 2; +} + +function parseArgs(args, names) { + const options = {}; + for (let index = 0; index < args.length; index += 1) { + const name = args[index].replace(/^--/, ''); + if (!args[index].startsWith('--') || !names.includes(name)) { + return { error: `unknown argument: ${args[index]}` }; + } + options[name] = args[(index += 1)]; + if (options[name] === undefined) { + return { error: `${args[index - 1]} needs a value` }; + } + } + return { options }; +} + +function runBody(args) { + const { options, error } = parseArgs(args, ['from', 'to']); + if (error) { + return usage(error); + } + if (!options.from) { + console.log(INITIAL_MARKER); + return 0; + } + if (!resolveRef(options.from)) { + return usage(`--from ref does not resolve: ${options.from}`); + } + const log = git([ + 'log', + '--reverse', + '--format=%s', + `${options.from}..${options.to ?? 'HEAD'}`, + '--', + WORKER_PATH, + CHANGELOG_EXCLUDE, + ]); + for (const line of bodyLines(log.split('\n').filter(Boolean))) { + console.log(line); + } + return 0; +} + +function runLand(args) { + const { options, error } = parseArgs(args, ['heading', 'body-file', 'branch', 'remote', 'base']); + if (error) { + return usage(error); + } + const heading = options.heading; + if (!heading || !heading.startsWith('## ') || heading.includes('\n')) { + return usage('land requires a one-line --heading that starts with "## "'); + } + if (!options['body-file']) { + return usage('land requires --body-file'); + } + const branch = options.branch ?? BRANCH_DEFAULT; + const remote = options.remote ?? 'origin'; + const base = options.base ?? 'main'; + + let bodyText; + try { + bodyText = readFileSync(options['body-file'], 'utf8'); + } catch (readError) { + return fail(`cannot read --body-file ${options['body-file']}: ${readError.message}`); + } + const section = composeSection(heading, readBodyLines(bodyText)); + console.log(section.replace(/\n$/, '')); + + let lastFailure = 'unknown error'; + for (let attempt = 1; attempt <= MAX_LAND_ATTEMPTS; attempt += 1) { + try { + const baseSha = fetchSha(remote, base); + const baseContent = treeFile(baseSha, CHANGELOG); + if (baseContent === '') { + return fail(`${base} has no ${CHANGELOG}`); + } + // An empty sha is the lease for "the branch must not exist yet". + const branchSha = remoteSha(remote, `refs/heads/${branch}`); + const branchContent = branchSha === '' ? '' : treeFile(fetchSha(remote, branch), CHANGELOG); + const content = rebuildChangelog(baseContent, branchContent, heading, section); + if (content === null || content === branchContent) { + console.log(`changelog: landed ${heading} on ${branch} (already present)`); + return 0; + } + const sha = commitOn( + baseSha, + CHANGELOG, + content, + `docs(kilo-mcp): changelog for ${heading.slice(3)}` + ); + git([ + 'push', + `--force-with-lease=refs/heads/${branch}:${branchSha}`, + remote, + `${sha}:refs/heads/${branch}`, + ]); + console.log(`changelog: landed ${heading} on ${branch} as ${sha}`); + return 0; + } catch (landError) { + lastFailure = reasonOf(landError); + console.log(`changelog: attempt ${attempt} rejected (${lastFailure})`); + } + } + return fail(`could not land ${heading} on ${branch} (${lastFailure})`); +} + +function main() { + const [command, ...args] = process.argv.slice(2); + if (command === 'body') { + return runBody(args); + } + if (command === 'land') { + return runLand(args); + } + return usage(); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(main()); +} diff --git a/scripts/kilo-mcp-release.test.mjs b/scripts/kilo-mcp-release.test.mjs new file mode 100644 index 0000000000..194446b967 --- /dev/null +++ b/scripts/kilo-mcp-release.test.mjs @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + CHANGELOG, + NO_PULL_REQUEST_MARKER, + bodyLines, + rebuildChangelog, +} from './kilo-mcp-release-notes.mjs'; +import { bundledPackages, packageRootOf } from './kilo-mcp-sbom.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const NOTES = join(HERE, 'kilo-mcp-release-notes.mjs'); +const HEADER = '# Kilo MCP Changelog\n\nNewest first.\n\n'; + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); +} + +function writeFile(dir, rel, content) { + const full = join(dir, rel); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); + return full; +} + +/** A bare `origin` with a main branch that holds the changelog, and a clone of it. */ +function remoteWithChangelog(content) { + const root = mkdtempSync(join(tmpdir(), 'kilo-mcp-land-')); + const origin = join(root, 'origin.git'); + const clone = join(root, 'clone'); + git(root, ['init', '-q', '--bare', '-b', 'main', origin]); + git(root, ['clone', '-q', origin, clone]); + for (const [key, value] of [ + ['user.name', 'Kilo Test'], + ['user.email', 'test@kilo.ai'], + ['commit.gpgsign', 'false'], + ]) { + git(clone, ['config', key, value]); + } + writeFile(clone, CHANGELOG, content); + git(clone, ['add', '-A']); + git(clone, ['commit', '-q', '-m', 'init']); + git(clone, ['push', '-q', 'origin', 'HEAD:main']); + return { root, clone }; +} + +function land(clone, heading, body) { + const bodyFile = writeFile(clone, '../body.md', `${body}\n`); + return spawnSync( + process.execPath, + [NOTES, 'land', '--heading', heading, '--body-file', bodyFile], + { cwd: clone, encoding: 'utf8' } + ); +} + +function branchChangelog(clone) { + git(clone, ['fetch', '-q', 'origin', 'kilo-mcp-changelog']); + return git(clone, ['show', `FETCH_HEAD:${CHANGELOG}`]); +} + +test('bodyLines links each pull request and drops direct commits', () => { + assert.deepEqual(bodyLines(['feat(kilo-mcp): add x (#12)', 'chore: direct push']), [ + '- feat(kilo-mcp): add x ([#12](https://github.com/Kilo-Org/cloud/pull/12))', + ]); + assert.deepEqual(bodyLines(['chore: direct push']), [NO_PULL_REQUEST_MARKER]); +}); + +test('rebuildChangelog keeps unmerged branch sections under the new one', () => { + const merged = '## 2026-10-01 (aaaaaaa)\n\n- merged\n\n'; + const pending = '## 2026-10-02 (bbbbbbb)\n\n- pending\n\n'; + const base = HEADER + merged; + const branch = HEADER + pending + merged; + const section = '## 2026-10-03 (ccccccc)\n\n- new\n\n'; + assert.equal( + rebuildChangelog(base, branch, '## 2026-10-03 (ccccccc)', section), + HEADER + section + pending + merged + ); +}); + +test('rebuildChangelog returns null when the base already holds every section', () => { + const section = '## 2026-10-03 (ccccccc)\n\n- new\n\n'; + const base = HEADER + section; + assert.equal(rebuildChangelog(base, base, '## 2026-10-03 (ccccccc)', section), null); +}); + +test('land creates the branch, then carries its sections after the PR merged', () => { + // The committed file ends with one newline; the first section still gets a blank line. + const { root, clone } = remoteWithChangelog(HEADER.replace(/\n+$/, '\n')); + try { + const first = land(clone, '## 2026-10-01 (aaaaaaa)', '- one'); + assert.equal(first.status, 0, first.stderr); + assert.equal(branchChangelog(clone), `${HEADER}## 2026-10-01 (aaaaaaa)\n\n- one\n\n`); + + // A second release before the merge stacks above the first. + const second = land(clone, '## 2026-10-02 (bbbbbbb)', '- two'); + assert.equal(second.status, 0, second.stderr); + const both = branchChangelog(clone); + assert.equal( + both, + `${HEADER}## 2026-10-02 (bbbbbbb)\n\n- two\n\n## 2026-10-01 (aaaaaaa)\n\n- one\n\n` + ); + + // A squash merge of the PR deletes the branch; main moves on. + git(clone, ['fetch', '-q', 'origin', 'main']); + git(clone, ['checkout', '-q', 'FETCH_HEAD']); + writeFile(clone, CHANGELOG, both); + writeFile(clone, 'other.txt', 'unrelated\n'); + git(clone, ['add', '-A']); + git(clone, ['commit', '-q', '-m', 'squash (#1)']); + git(clone, ['push', '-q', 'origin', 'HEAD:main']); + git(clone, ['push', '-q', 'origin', '--delete', 'kilo-mcp-changelog']); + + const third = land(clone, '## 2026-10-03 (ccccccc)', '- three'); + assert.equal(third.status, 0, third.stderr); + assert.equal( + branchChangelog(clone), + `${HEADER}## 2026-10-03 (ccccccc)\n\n- three\n\n${both.slice(HEADER.length)}` + ); + // A rerun of a landed release pushes nothing and keeps one copy of its section. + const rerun = land(clone, '## 2026-10-03 (ccccccc)', '- three'); + assert.equal(rerun.status, 0, rerun.stderr); + assert.match(rerun.stdout, /landed .* \(already present\)/); + assert.equal(branchChangelog(clone).split('## 2026-10-03').length, 2); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('packageRootOf resolves plain and scoped packages under the last node_modules', () => { + assert.equal( + packageRootOf('/r/node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/core/index.js'), + '/r/node_modules/.pnpm/zod@4.4.3/node_modules/zod' + ); + assert.equal( + packageRootOf('/r/node_modules/@cfworker/json-schema/dist/index.js'), + '/r/node_modules/@cfworker/json-schema' + ); + assert.equal(packageRootOf('/r/services/kilo-mcp/src/index.ts'), null); +}); + +test('bundledPackages lists only packages the bundle carries bytes of', () => { + const dir = mkdtempSync(join(tmpdir(), 'kilo-mcp-sbom-')); + try { + writeFile(dir, 'node_modules/kept/package.json', '{"name":"kept","version":"1.0.0"}'); + writeFile(dir, 'node_modules/kept/a.js', ''); + writeFile(dir, 'node_modules/kept/b.js', ''); + writeFile(dir, 'node_modules/shaken/package.json', '{"name":"shaken","version":"2.0.0"}'); + writeFile(dir, 'node_modules/shaken/index.js', ''); + writeFile(dir, 'src/index.ts', ''); + const metafile = { + outputs: { + 'out/index.js': { + inputs: { + 'node_modules/kept/a.js': { bytesInOutput: 10 }, + 'node_modules/kept/b.js': { bytesInOutput: 5 }, + 'node_modules/shaken/index.js': { bytesInOutput: 0 }, + 'node_modules/virtual/_virtual_polyfill': { bytesInOutput: 7 }, + 'src/index.ts': { bytesInOutput: 99 }, + }, + }, + }, + }; + const lockfile = { + packages: { 'kept@1.0.0': { resolution: { integrity: 'sha512-AAAA' } } }, + }; + const components = bundledPackages({ + metafile, + workerDir: dir, + bundlePath: join(dir, 'out/index.js'), + lockfile, + }); + assert.deepEqual(components, [ + { + ecosystem: 'npm', + name: 'kept', + version: '1.0.0', + purl: 'pkg:npm/kept@1.0.0', + hashes: [{ alg: 'SHA-512', content: '000000' }], + extraProperties: [{ name: 'kilo:sbom:bundled-bytes', value: '15' }], + }, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/kilo-mcp-sbom.mjs b/scripts/kilo-mcp-sbom.mjs new file mode 100755 index 0000000000..2eb947791e --- /dev/null +++ b/scripts/kilo-mcp-sbom.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node +/** + * Generate the CycloneDX SBOM of one kilo-mcp production bundle. + * + * The source is the esbuild metafile that `wrangler deploy --dry-run + * --metafile` writes beside the bundle. A package is a component only when the + * bundle carries bytes of it, so a package that esbuild tree-shook out is not + * listed. Inputs that do not exist on disk (wrangler's virtual polyfill + * modules) and first-party inputs outside node_modules are not components: the + * first party is the document's own `metadata.component`. + * + * Usage: + * node scripts/kilo-mcp-sbom.mjs --metafile --bundle + * --worker-dir --lockfile --commit + * --release --out + * + * `--worker-dir` is the directory wrangler ran in: the metafile paths are + * relative to it. The script prints `components=` and + * `artifact_sha256=` on stdout. + * + * Exit codes: + * 0 - the SBOM was written + * 1 - an input is unreadable, or the bundle carries no package + * 2 - usage error + */ +import { randomUUID } from 'node:crypto'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { load } from 'js-yaml'; + +import { sha256File, toCycloneDxComponents } from './mobile-sbom-cyclonedx.mjs'; +import { compareComponents, integrityHashes, toPurl } from './mobile-sbom-pnpm.mjs'; + +const SPEC_VERSION = '1.6'; +const APP_NAME = 'kilo-mcp'; +const NODE_MODULES = '/node_modules/'; +const ARTIFACT_HASH_SOURCE = + 'hash of the wrangler bundle built from the deployed commit with the locked wrangler'; +const METAFILE_SOURCE = 'esbuild metafile of the wrangler bundle, inputs with bytes in the output'; + +/** + * The package root of a bundled file: the directory right below its last + * `node_modules/`, two levels deep for a scoped package. Null for a file + * outside node_modules. + */ +export function packageRootOf(absolutePath) { + const index = absolutePath.lastIndexOf(NODE_MODULES); + if (index < 0) { + return null; + } + const rest = absolutePath.slice(index + NODE_MODULES.length).split('/'); + const depth = rest[0].startsWith('@') ? 2 : 1; + if (rest.length <= depth) { + return null; + } + return absolutePath.slice(0, index + NODE_MODULES.length) + rest.slice(0, depth).join('/'); +} + +/** The output entry of the bundle file in an esbuild metafile. */ +function bundleOutput(metafile, workerDir, bundlePath) { + const target = resolve(bundlePath); + for (const [path, output] of Object.entries(metafile.outputs ?? {})) { + if (resolve(workerDir, path) === target) { + return output; + } + } + throw new Error(`the metafile has no output for ${bundlePath}`); +} + +/** + * One npm component per package the bundle carries bytes of, sorted by name + * then version, with the lockfile integrity as its hash when the lockfile has + * one. + */ +export function bundledPackages({ metafile, workerDir, bundlePath, lockfile }) { + const output = bundleOutput(metafile, workerDir, bundlePath); + const packages = lockfile && typeof lockfile.packages === 'object' ? lockfile.packages : {}; + const byPurl = new Map(); + for (const [input, { bytesInOutput }] of Object.entries(output.inputs ?? {})) { + const absolute = resolve(workerDir, input); + if (!(bytesInOutput > 0) || !existsSync(absolute)) { + continue; + } + const root = packageRootOf(absolute); + if (root === null) { + continue; + } + const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + const purl = toPurl(manifest.name, manifest.version); + const known = byPurl.get(purl); + if (known) { + known.bytes += bytesInOutput; + continue; + } + const hashes = integrityHashes(packages[`${manifest.name}@${manifest.version}`]); + byPurl.set(purl, { + ecosystem: 'npm', + name: manifest.name, + version: manifest.version, + purl, + ...(hashes ? { hashes } : {}), + bytes: bytesInOutput, + }); + } + return [...byPurl.values()].sort(compareComponents).map(({ bytes, ...component }) => ({ + ...component, + extraProperties: [{ name: 'kilo:sbom:bundled-bytes', value: String(bytes) }], + })); +} + +export function buildWorkerSbom({ commit, release, artifactName, artifactSha256, components }) { + return { + bomFormat: 'CycloneDX', + specVersion: SPEC_VERSION, + serialNumber: `urn:uuid:${randomUUID()}`, + version: 1, + metadata: { + timestamp: new Date().toISOString(), + component: { + type: 'application', + name: APP_NAME, + version: commit, + 'bom-ref': `pkg:generic/${APP_NAME}@${commit}`, + }, + properties: [ + { name: 'kilo:sbom:commit', value: commit }, + { name: 'kilo:sbom:release-tag', value: release }, + { name: 'kilo:sbom:artifact-name', value: artifactName }, + { name: 'kilo:sbom:artifact-sha256', value: artifactSha256 }, + { name: 'kilo:sbom:artifact-sha256-source', value: ARTIFACT_HASH_SOURCE }, + { name: 'kilo:sbom:source:npm', value: METAFILE_SOURCE }, + ], + }, + components: toCycloneDxComponents(components), + }; +} + +function usage(message) { + console.error(`kilo-mcp-sbom: ${message}`); + console.error( + 'Usage: node scripts/kilo-mcp-sbom.mjs --metafile --bundle --worker-dir --lockfile --commit --release --out ' + ); + return 2; +} + +function main() { + const names = ['metafile', 'bundle', 'worker-dir', 'lockfile', 'commit', 'release', 'out']; + const args = process.argv.slice(2); + const options = {}; + for (let index = 0; index < args.length; index += 2) { + const name = args[index].replace(/^--/, ''); + if (!args[index].startsWith('--') || !names.includes(name) || args[index + 1] === undefined) { + return usage(`bad argument: ${args[index]}`); + } + options[name] = args[index + 1]; + } + const missing = names.filter(name => !options[name]); + if (missing.length > 0) { + return usage(`missing --${missing.join(', --')}`); + } + if (!/^[0-9a-f]{40}$/.test(options.commit)) { + return usage(`--commit must be a full 40-character sha, got ${options.commit}`); + } + + try { + const components = bundledPackages({ + metafile: JSON.parse(readFileSync(options.metafile, 'utf8')), + workerDir: resolve(options['worker-dir']), + bundlePath: options.bundle, + lockfile: load(readFileSync(options.lockfile, 'utf8')), + }); + if (components.length === 0) { + console.error('kilo-mcp-sbom: the bundle carries no npm package; refusing an empty SBOM'); + return 1; + } + const artifactSha256 = sha256File(options.bundle); + const doc = buildWorkerSbom({ + commit: options.commit, + release: options.release, + artifactName: basename(options.bundle), + artifactSha256, + components, + }); + writeFileSync(options.out, `${JSON.stringify(doc, null, 2)}\n`); + console.log(`components=${components.length}`); + console.log(`artifact_sha256=${artifactSha256}`); + return 0; + } catch (error) { + console.error(`kilo-mcp-sbom: ${error.message}`); + return 1; + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(main()); +} diff --git a/scripts/mobile-sbom-pnpm.mjs b/scripts/mobile-sbom-pnpm.mjs index da367bc447..2b742b4743 100644 --- a/scripts/mobile-sbom-pnpm.mjs +++ b/scripts/mobile-sbom-pnpm.mjs @@ -73,12 +73,12 @@ function workspaceImporterKey(version, baseDir) { return undefined; } -function toPurl(name, version) { +export function toPurl(name, version) { const encoded = name.startsWith('@') ? `%40${name.slice(1)}` : name; return `pkg:npm/${encoded}@${version}`; } -function integrityHashes(entry) { +export function integrityHashes(entry) { const integrity = isRecord(entry) && isRecord(entry.resolution) ? entry.resolution.integrity : undefined; if (typeof integrity !== 'string' || !integrity.startsWith(SHA512_PREFIX)) return undefined; @@ -86,7 +86,7 @@ function integrityHashes(entry) { return [{ alg: 'SHA-512', content }]; } -function compareComponents(a, b) { +export function compareComponents(a, b) { if (a.name !== b.name) return a.name < b.name ? -1 : 1; if (a.version !== b.version) return a.version < b.version ? -1 : 1; return 0; diff --git a/services/kilo-mcp/CHANGELOG.md b/services/kilo-mcp/CHANGELOG.md new file mode 100644 index 0000000000..999d3fdf3f --- /dev/null +++ b/services/kilo-mcp/CHANGELOG.md @@ -0,0 +1,6 @@ +# Kilo MCP Changelog + +Kilo MCP is in beta. Its tools, catalog paths, and behavior can change without notice. + +Newest first: one section per production release that changed the deployed bundle. The kilo-mcp +Release job in the Deploy to Production workflow writes each section. Do not edit this file by hand. diff --git a/services/kilo-mcp/src/index.ts b/services/kilo-mcp/src/index.ts index 780ac4470a..a073344409 100644 --- a/services/kilo-mcp/src/index.ts +++ b/services/kilo-mcp/src/index.ts @@ -73,7 +73,10 @@ const INTERNAL_ERROR = -32000; const UNAUTHORIZED = -32001; const PROTOCOL_VERSION = '2025-06-18'; -const SERVER_INFO = { name: 'kilo-mcp', version: '1.0.0' } as const; +// `title` is the name an MCP client shows to a person; it carries the beta +// label, and the initialize instructions repeat it for the agent. +const SERVER_INFO = { name: 'kilo-mcp', title: 'Kilo MCP (beta)', version: '1.0.0' } as const; +const BETA_NOTICE = 'Kilo MCP is in beta: its tools and behavior can change.'; /** * The published tool names. `tools/call` accepts any string as `name`, so the @@ -708,8 +711,8 @@ async function handleRpcMessage( capabilities: { tools: {} }, serverInfo: SERVER_INFO, instructions: canUseProtectedActions(auth) - ? `This server exposes the Kilo API through two tools: search (find catalog endpoints) and call (invoke one by path). Search before every call. Each result carries a kind: "query" reads data, "mutation" changes it. Call a mutation path only when the user asked for that change, and if it fails with an ambiguous transport error, check the current state before retrying. This connection may also run admin and debug endpoints: use call_protected to submit one, then submit_otp with the code the user reads from their authenticator app to approve it. The endpoint and payload are fixed once call_protected returns. ${feedback}` - : `This server exposes the Kilo API through two tools: search (find catalog endpoints) and call (invoke one by path). Search before every call. Each result carries a kind: "query" reads data, "mutation" changes it. Call a mutation path only when the user asked for that change, and if it fails with an ambiguous transport error, check the current state before retrying. ${feedback}`, + ? `${BETA_NOTICE} This server exposes the Kilo API through two tools: search (find catalog endpoints) and call (invoke one by path). Search before every call. Each result carries a kind: "query" reads data, "mutation" changes it. Call a mutation path only when the user asked for that change, and if it fails with an ambiguous transport error, check the current state before retrying. This connection may also run admin and debug endpoints: use call_protected to submit one, then submit_otp with the code the user reads from their authenticator app to approve it. The endpoint and payload are fixed once call_protected returns. ${feedback}` + : `${BETA_NOTICE} This server exposes the Kilo API through two tools: search (find catalog endpoints) and call (invoke one by path). Search before every call. Each result carries a kind: "query" reads data, "mutation" changes it. Call a mutation path only when the user asked for that change, and if it fails with an ambiguous transport error, check the current state before retrying. ${feedback}`, }); } case 'ping': diff --git a/services/kilo-mcp/src/oauth/pages.ts b/services/kilo-mcp/src/oauth/pages.ts index ddc11cb8a9..0bf3e4e8be 100644 --- a/services/kilo-mcp/src/oauth/pages.ts +++ b/services/kilo-mcp/src/oauth/pages.ts @@ -54,6 +54,7 @@ export function consentPage(input: { `

Connect to Kilo MCP

` + `

${escapeHtml(input.clientName)} is asking to connect to Kilo MCP ` + `with your Kilo account.

` + + `

Beta: Kilo MCP is in beta. Its tools and behavior can change.

` + `

Requested access: ${escapeHtml(input.scope)}

` + `Continue with Kilo sign-in` +