diff --git a/github/README.md b/github/README.md index ab415150..360f3020 100644 --- a/github/README.md +++ b/github/README.md @@ -62,6 +62,34 @@ This MCP requires a **GitHub App** (not a plain OAuth App) because webhook routi - Webhook permissions for the desired events - A callback URL matching your deployment +#### Repository permissions the App must declare + +`MINT_REPO_TOKEN` can only mint what the App itself declares — GitHub `422`s a +mint whose permission set exceeds the installation's grant. So the App's +**Repository permissions** must include every key in `ALLOWED_PERMISSIONS` +(`server/lib/repo-token.ts`): + +| Permission | Level | Why | +| -------------- | ----- | ---------------------------------------------------------- | +| Contents | Read & write | clone, read, push | +| Metadata | Read | mandatory for every GitHub App | +| Pull requests | Read & write | open/update PRs | +| Issues | Read & write | open/comment issues | +| Checks | Read | `GET_CHECK_RUN`, the PR panel's Checks tab | +| Deployments | Read | `GET_PREVIEW_DEPLOYMENT` — the only place a VTEX FastStore WebOps preview URL is published | + +Adding a permission to an existing App is **not** self-applying: GitHub marks +every existing installation as having a pending permission request, and an +owner/admin of each account must accept it (`Settings → Applications → → +Review request`, or the emailed prompt). Until an installation accepts, its +mints simply shed the un-approved optional (see `OPTIONAL_READ_UPGRADES`) and +keep working with the narrower set — nothing breaks, the corresponding feature +just stays dark for that org. + +Once an installation does accept, its existing repo grants pick the permission +up on their next `/repo-grant/token` refresh (within ~1h). No re-import, no +re-install, no user action. + ### Environment Variables ```bash diff --git a/github/server/lib/repo-grant.test.ts b/github/server/lib/repo-grant.test.ts index df09cada..e9768507 100644 --- a/github/server/lib/repo-grant.test.ts +++ b/github/server/lib/repo-grant.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { + buildUpgradeLadder, handleRepoGrantRevokeRequest, handleRepoGrantTokenRequest, issueRepoGrant, @@ -195,6 +196,95 @@ async function seedGrant( return { creds, meta }; } +describe("buildUpgradeLadder", () => { + test("widens first, then sheds the newest optional one at a time", () => { + expect(buildUpgradeLadder({ contents: "write", metadata: "read" })).toEqual( + [ + { + contents: "write", + metadata: "read", + checks: "read", + deployments: "read", + }, + { contents: "write", metadata: "read", checks: "read" }, + { contents: "write", metadata: "read" }, + ], + ); + }); + + test("dedupes rungs a grant already satisfies", () => { + // A grant that already carries checks has nothing to gain from the + // checks-only rung — asking twice would burn a GitHub call per refresh. + expect( + buildUpgradeLadder({ + contents: "write", + metadata: "read", + checks: "read", + }), + ).toEqual([ + { + contents: "write", + metadata: "read", + checks: "read", + deployments: "read", + }, + { contents: "write", metadata: "read", checks: "read" }, + ]); + }); + + test("a fully-upgraded grant needs exactly one rung", () => { + const full = { + contents: "write", + metadata: "read", + checks: "read", + deployments: "read", + }; + expect(buildUpgradeLadder(full)).toEqual([full]); + }); + + test("keeps the verbatim stored set as the last rung when capping changes it", () => { + // A legacy grant holding checks:write is now capped to read — but the + // stored set is the one GitHub is known to have honoured, so it stays as + // the final fallback rather than being capped away. + expect( + buildUpgradeLadder({ + contents: "write", + metadata: "read", + checks: "write", + }), + ).toEqual([ + { + contents: "write", + metadata: "read", + checks: "read", + deployments: "read", + }, + { contents: "write", metadata: "read", checks: "read" }, + { contents: "write", metadata: "read", checks: "write" }, + ]); + }); + + test("refuses to build a ladder for a grant with no stored permissions", () => { + // Both ways of expressing "nothing" would ESCALATE here: `permissions: {}` + // reads as omitted to GitHub (every permission the installation holds), and + // capPermissions({}) returns the default contents:write set. No rung is the + // only safe answer. + expect(buildUpgradeLadder({})).toEqual([]); + }); + + test("a stored key the allowlist no longer permits still yields a usable rung", () => { + // The widened rungs throw while capping; that must degrade to re-minting + // exactly what the grant holds, never escape as a 500 from /repo-grant/token. + expect( + buildUpgradeLadder({ + contents: "write", + metadata: "read", + actions: "read", + }), + ).toEqual([{ contents: "write", metadata: "read", actions: "read" }]); + }); +}); + describe("refreshRepoGrant — request validation", () => { test("missing grant_type or refresh_token → 400 invalid_request", async () => { const store = getRepoGrantStore(fakeKV()); @@ -330,12 +420,14 @@ describe("refreshRepoGrant — minting", () => { if (/\/app\/installations\/42\/access_tokens/.test(url)) { const body = JSON.parse((init as { body?: string }).body ?? "{}"); expect(body.repository_ids).toEqual([999]); - // Refresh widens a pre-checks grant to include checks:read so the PR - // panel can read CI check runs — no re-install needed. + // Refresh widens a legacy grant into every optional read the PR panel + // needs — checks:read (CI check runs) and deployments:read (the preview + // URL) — so it self-heals with no re-import and no re-install. expect(body.permissions).toEqual({ contents: "write", metadata: "read", checks: "read", + deployments: "read", }); return json( { @@ -373,30 +465,19 @@ describe("refreshRepoGrant — minting", () => { expect(stored?.expiresAt).toBe("2026-09-08T00:00:00.000Z"); }); - test("checks upgrade unavailable (422) falls back to the grant's permissions without revoking", async () => { + test("no optional upgrade available (422) falls back to the grant's permissions without revoking", async () => { const kv = fakeKV(); const store = getRepoGrantStore(kv); - const { creds, meta } = await seedGrant(store); // no checks in the grant - let calls = 0; + const { creds, meta } = await seedGrant(store); // no checks, no deployments + const asked: Array> = []; setFetch(async (input, init) => { const url = urlOf(input); if (/\/app\/installations\/42\/access_tokens/.test(url)) { const body = JSON.parse((init as { body?: string }).body ?? "{}"); - calls++; - if (calls === 1) { - // Widened attempt asks for checks the installation hasn't granted. - expect(body.permissions).toEqual({ - contents: "write", - metadata: "read", - checks: "read", - }); + asked.push(body.permissions); + if (asked.length < 3) { return json({ message: "permissions exceed grant" }, 422); } - // Fallback attempt uses exactly what the grant was issued with. - expect(body.permissions).toEqual({ - contents: "write", - metadata: "read", - }); return json( { token: "ghs_fallback", @@ -421,11 +502,140 @@ describe("refreshRepoGrant — minting", () => { expect(r.ok).toBe(true); if (!r.ok) throw new Error("expected ok"); expect(r.success.access_token).toBe("ghs_fallback"); - expect(calls).toBe(2); - // The still-valid grant must survive the failed checks upgrade. + // Newest optional shed first, then the next — the last rung is exactly what + // the grant was issued with, so the connection always keeps working. + expect(asked).toEqual([ + { + contents: "write", + metadata: "read", + checks: "read", + deployments: "read", + }, + { contents: "write", metadata: "read", checks: "read" }, + { contents: "write", metadata: "read" }, + ]); + // The still-valid grant must survive the failed upgrades. + expect(kv.store.has(`grant:${meta.grantId}`)).toBe(true); + }); + + test("an installation granting checks but not deployments keeps checks", async () => { + // The regression this ladder exists for: adding deployments to the widened + // set without shedding it one at a time would 422 the whole mint for every + // grant that already had checks, and (before the ladder) go straight to + // handleMintFailure — revoking a perfectly good grant on a 422. + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const { creds, meta } = await seedGrant(store, { + permissions: { contents: "write", metadata: "read", checks: "read" }, + }); + const asked: Array> = []; + setFetch(async (input, init) => { + const url = urlOf(input); + if (/\/app\/installations\/42\/access_tokens/.test(url)) { + const body = JSON.parse((init as { body?: string }).body ?? "{}"); + asked.push(body.permissions); + if (body.permissions.deployments) { + return json({ message: "permissions exceed grant" }, 422); + } + return json( + { + token: "ghs_with_checks", + expires_at: "2026-06-10T01:00:00.000Z", + permissions: body.permissions, + }, + 201, + ); + } + throw new Error(`unexpected url ${url}`); + }); + + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: creds.refreshToken, + clientId: "Iv1.abc", + expectedClientId: "Iv1.abc", + jwt: "fake.jwt", + }); + + expect(r.ok).toBe(true); + if (!r.ok) throw new Error("expected ok"); + expect(r.success.access_token).toBe("ghs_with_checks"); + expect(asked).toEqual([ + { + contents: "write", + metadata: "read", + checks: "read", + deployments: "read", + }, + { contents: "write", metadata: "read", checks: "read" }, + ]); + expect(kv.store.has(`grant:${meta.grantId}`)).toBe(true); + }); + + test("a non-422 mint failure stops the ladder after one attempt", async () => { + // A 5xx says nothing about the permission set, so retrying narrower would + // just multiply GitHub calls during an outage — and must stay transient + // (503), never invalidate the grant. + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const { creds, meta } = await seedGrant(store); + let calls = 0; + setFetch(async (input) => { + const url = urlOf(input); + if (/\/app\/installations\/42\/access_tokens/.test(url)) { + calls++; + return json({ message: "server error" }, 500); + } + throw new Error(`unexpected url ${url}`); + }); + + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: creds.refreshToken, + clientId: "Iv1.abc", + expectedClientId: "Iv1.abc", + jwt: "fake.jwt", + }); + + expect(r).toMatchObject({ + ok: false, + status: 503, + error: "temporarily_unavailable", + }); + expect(calls).toBe(1); expect(kv.store.has(`grant:${meta.grantId}`)).toBe(true); }); + test("the last rung 422ing is grant-invalidating and revokes", async () => { + // Every rung down to the grant's own stored permissions was refused: the + // repo left the installation (or the App lost it), so the grant can never + // work again and must not linger. + const kv = fakeKV(); + const store = getRepoGrantStore(kv); + const { creds, meta } = await seedGrant(store); + setFetch(async (input) => { + const url = urlOf(input); + if (/\/app\/installations\/42\/access_tokens/.test(url)) { + return json({ message: "permissions exceed grant" }, 422); + } + throw new Error(`unexpected url ${url}`); + }); + + const r = await refreshRepoGrant({ + store, + grantType: "refresh_token", + refreshToken: creds.refreshToken, + clientId: "Iv1.abc", + expectedClientId: "Iv1.abc", + jwt: "fake.jwt", + }); + + expect(r).toMatchObject({ ok: false, status: 400, error: "invalid_grant" }); + expect(kv.store.has(`grant:${meta.grantId}`)).toBe(false); + }); + test("omitted client_id is allowed (public-client model)", async () => { const store = getRepoGrantStore(fakeKV()); const { creds } = await seedGrant(store); diff --git a/github/server/lib/repo-grant.ts b/github/server/lib/repo-grant.ts index 4dc5f22c..cd7c44bd 100644 --- a/github/server/lib/repo-grant.ts +++ b/github/server/lib/repo-grant.ts @@ -29,7 +29,11 @@ import { type RepoGrantStore, verifySecret, } from "./repo-grant-store.ts"; -import { capPermissions, mintRepoScopedToken } from "./repo-token.ts"; +import { + capPermissions, + mintRepoScopedToken, + OPTIONAL_READ_UPGRADES, +} from "./repo-token.ts"; import type { Env } from "../types/env.ts"; export interface IssuedRepoGrant { @@ -182,6 +186,81 @@ export type RefreshResult = const INVALID_GRANT_MESSAGE = "Repo grant is expired, revoked, unknown, or no longer valid."; +const samePermissions = ( + a: Record, + b: Record, +): boolean => { + const keys = Object.keys(a); + return ( + keys.length === Object.keys(b).length && keys.every((k) => a[k] === b[k]) + ); +}; + +/** + * The permission maps a refresh tries, in order, when re-minting a grant. + * + * Rung 0 widens the grant into every {@link OPTIONAL_READ_UPGRADES} permission + * — `checks:read` (CI check runs) and `deployments:read` (a PR's preview URL, + * the ONLY place a VTEX FastStore WebOps deploy publishes it). That is what + * lets a grant issued before a permission joined the allowlist pick it up on + * its next refresh, riding the ~1h token cycle: no re-import, no re-install, + * no user action. + * + * The rungs below it exist because GitHub 422s the WHOLE mint when ANY + * requested permission exceeds what the installation granted — it does not + * partially fulfil. So each rung drops one more optional (newest first) and the + * last is exactly the grant's stored permissions. An installation that has + * approved `checks` but not yet `deployments` therefore keeps `checks` instead + * of losing both, and a grant that is still valid for its own scope is never + * revoked because a widening failed. + * + * Cost: until an installation approves a newer permission, each refresh burns + * one extra 422'd mint per un-approved optional (~1/hour per connection). That + * is the deliberate price of picking the permission up automatically the moment + * an org approves it, rather than requiring every connection to be re-imported. + * + * Exported pure so the ladder — the part with the ordering and dedup rules — is + * unit-testable without mocking GitHub. + */ +export function buildUpgradeLadder( + stored: Record, +): Record[] { + const ladder: Record[] = []; + // A grant with NO stored permissions can't be widened safely: `permissions: + // {}` reads as "omitted" to GitHub (minting every permission the installation + // holds), and `capPermissions({})` returns the DEFAULT coding-agent set + // (contents:write) — so both a literal and a capped empty map would hand the + // grant more than it was ever issued. Refuse to build a ladder; the caller + // then reports a transient failure and keeps the grant rather than escalating + // it. Not reachable today (GitHub always echoes metadata back at issue time), + // but this is KV data written by past versions of the code. + if (Object.keys(stored).length === 0) return ladder; + const push = (perms: Record) => { + if (!ladder.some((p) => samePermissions(p, perms))) ladder.push(perms); + }; + for (let drop = 0; drop <= OPTIONAL_READ_UPGRADES.length; drop++) { + const widened = { ...stored }; + for (const permission of OPTIONAL_READ_UPGRADES.slice(drop)) { + widened[permission] = "read"; + } + // A stored key the allowlist no longer permits makes capping throw. That + // must not escape into the token endpoint as a 500 — it would bypass the + // transient-vs-permanent mapping the whole refresh path is built around. + // Skip the widened rung; the verbatim rung below still re-mints the grant. + try { + push(capPermissions(widened)); + } catch { + // Not widenable — fall through to the stored set. + } + } + // Last resort: exactly what the grant was issued with. Usually already + // deduped away by the final loop rung; it differs only for a legacy grant + // holding a permission `capPermissions` now caps (e.g. `checks:write`), and + // there the stored set is the one we know the installation honoured. + push({ ...stored }); + return ladder; +} + function oauthError( status: number, error: string, @@ -291,18 +370,6 @@ export async function refreshRepoGrant(opts: { ); } - // Ensure the standard repo-scoped grant carries checks:read so the PR panel - // can read CI check runs (GET /commits/{sha}/check-runs). Grants issued - // before checks joined the allowlist are upgraded here on their next refresh - // — no re-install, riding the ~1h token cycle. If the installation hasn't - // granted checks yet, GitHub 422s; we fall back to the grant's stored - // permissions and never revoke a grant that is still valid for its own scope. - const upgradedPermissions = capPermissions({ - ...grant.permissions, - checks: "read", - }); - const addedChecks = !!upgradedPermissions.checks && !grant.permissions.checks; - const mintWith = (permissions: Record) => mintInstallationAccessToken( grant.installationId, @@ -327,23 +394,28 @@ export async function refreshRepoGrant(opts: { return mapped; }; + // Widen the grant into the optional reads the PR panel needs, shedding one at + // a time when the installation hasn't approved it (see buildUpgradeLadder). let minted; - try { - minted = await mintWith(upgradedPermissions); - } catch (err) { - // A 422 from the checks widening means the installation hasn't granted - // checks — retry with exactly the grant's stored permissions so the - // connection keeps working, and only then treat a failure as terminal. - if (addedChecks && err instanceof GitHubAppApiError && err.status === 422) { - try { - minted = await mintWith(grant.permissions); - } catch (retryErr) { - return handleMintFailure(retryErr); - } - } else { - return handleMintFailure(err); + let lastErr: unknown; + for (const permissions of buildUpgradeLadder(grant.permissions)) { + try { + minted = await mintWith(permissions); + break; + } catch (err) { + lastErr = err; + // Only 422 ("permissions exceed what the App was granted", or the repo + // left the installation) is worth retrying narrower. A 5xx/429 outage or + // a 401/403 from our own App credentials says nothing about the requested + // permission set, so burning the rest of the ladder on it would turn one + // transient blip into N pointless GitHub calls per refresh. + if (!(err instanceof GitHubAppApiError && err.status === 422)) break; } } + // Every rung 422'd (or the first failed hard): the last error is the one that + // decides transient-vs-permanent, and the last rung asked for exactly what + // the grant was issued with — so a 422 there really is grant-invalidating. + if (!minted) return handleMintFailure(lastErr); // --- slide TTL and respond --- const newExpiresAt = new Date(now + GRANT_TTL_SECONDS * 1000).toISOString(); diff --git a/github/server/lib/repo-token.test.ts b/github/server/lib/repo-token.test.ts index d989a690..a91e673b 100644 --- a/github/server/lib/repo-token.test.ts +++ b/github/server/lib/repo-token.test.ts @@ -1,11 +1,13 @@ import { afterEach, describe, expect, test } from "bun:test"; import { GitHubAppApiError } from "./github-app-auth.ts"; import { + ALLOWED_PERMISSIONS, authorizeAndResolveRepoId, capPermissions, DEFAULT_PERMISSIONS, mapMintError, mintRepoScopedToken, + OPTIONAL_READ_UPGRADES, RepoTokenError, } from "./repo-token.ts"; @@ -70,10 +72,43 @@ describe("capPermissions", () => { }); }); + test("allows deployments:read so the PR panel can read preview URLs", () => { + // VTEX FastStore WebOps publishes a PR's preview ONLY as a GitHub + // Deployment; without this, GET_PREVIEW_DEPLOYMENT 403s and the card shows + // no preview link at all. + expect(capPermissions({ deployments: "read" })).toEqual({ + deployments: "read", + metadata: "read", + }); + }); + test("forces metadata to read even if write is requested", () => { expect(capPermissions({ metadata: "write" })).toEqual({ metadata: "read" }); }); + test.each(["checks", "deployments"])( + "caps the read-only permission %s down to read", + (perm) => { + // checks:write would let a token post a green check run — and Studio + // gates PR merges on check status. deployments:write would let it write + // the environment_url the PR panel renders as a preview link. Neither is + // ever needed, so neither is ever minted. + expect(capPermissions({ [perm]: "write" })).toEqual({ + [perm]: "read", + metadata: "read", + }); + }, + ); + + test("every optional read upgrade is itself an allowed permission", () => { + // OPTIONAL_READ_UPGRADES marks which permissions the refresh ladder may + // shed — it must never smuggle in a key capPermissions would reject, which + // would make rung 0 of the ladder throw instead of 422. + for (const perm of OPTIONAL_READ_UPGRADES) { + expect(ALLOWED_PERMISSIONS.has(perm)).toBe(true); + } + }); + test.each([ "administration", "members", @@ -81,7 +116,6 @@ describe("capPermissions", () => { "secrets", "actions", "environments", - "deployments", ])("hard-rejects the disallowed permission %s", (perm) => { let caught: unknown; try { diff --git a/github/server/lib/repo-token.ts b/github/server/lib/repo-token.ts index 18337c52..2390d19b 100644 --- a/github/server/lib/repo-token.ts +++ b/github/server/lib/repo-token.ts @@ -48,13 +48,18 @@ export class RepoTokenError extends Error { /** * Positive allowlist of permissions we are willing to mint — strictly - * repo-content / PR / issue / CI-checks level. Anything outside this list is - * hard-rejected, which by construction also rejects every escalation vector the - * spec bans (administration, members, organization_*, secrets, actions, ...). + * repo-content / PR / issue level plus two read-only CI/deploy signals. + * Anything outside this list is hard-rejected, which by construction also + * rejects every escalation vector the spec bans (administration, members, + * organization_*, secrets, actions, environments, ...). * * `checks` is needed so the PR panel can read CI check runs - * (`GET /commits/{sha}/check-runs`); without it the minted installation token - * gets `403 Resource not accessible by integration`. + * (`GET /commits/{sha}/check-runs`); `deployments` so it can read a PR's preview + * URL from the Deployments API (`GET /repos/{o}/{r}/deployments` + `/statuses`, + * via GET_PREVIEW_DEPLOYMENT) — the ONLY place a VTEX FastStore WebOps preview + * is published (not a commit-status `target_url`, not a bot comment). Without + * each, the minted installation token gets `403 Resource not accessible by + * integration` on that endpoint. */ export const ALLOWED_PERMISSIONS = new Set([ "contents", @@ -62,8 +67,37 @@ export const ALLOWED_PERMISSIONS = new Set([ "pull_requests", "issues", "checks", + "deployments", ]); +/** + * Permissions we only ever mint at `read`, whatever the caller asks for. These + * are observability signals, and write on either is an escalation with teeth: + * `checks:write` lets a token POST a green check run — and Studio gates PR + * merges on check status, so that is a forged ship signal — while + * `deployments:write` lets it create deployments and deployment statuses, + * including the `environment_url` that the PR panel then renders as a preview + * link. Capped rather than rejected, matching this function's contract (and how + * `metadata` has always been handled): a stored grant is re-capped on every + * refresh, so a throw here would turn a legacy over-broad grant into a hard + * refresh failure instead of quietly narrowing it. + */ +export const READ_ONLY_PERMISSIONS = new Set([ + "metadata", + "checks", + "deployments", +]); + +/** + * The optional read permissions a refresh tries to widen an existing grant + * into, MOST-DROPPABLE FIRST. `deployments` is the newest, so an installation + * that has approved `checks` but not yet `deployments` sheds only the latter. + * Every entry must also be in {@link ALLOWED_PERMISSIONS} (asserted in the unit + * test): this list marks which permissions are droppable, it does not add new + * ones. See `buildUpgradeLadder` in repo-grant.ts for how it is applied. + */ +export const OPTIONAL_READ_UPGRADES = ["deployments", "checks"] as const; + /** GitHub permission levels we allow. `admin` is never granted. */ const ALLOWED_VALUES = new Set(["read", "write"]); @@ -113,6 +147,7 @@ function isTransientGitHubResponse(res: Response): boolean { * - Each key must be in {@link ALLOWED_PERMISSIONS}; otherwise hard-reject. * - Each value must be "read" or "write"; `admin` (or anything else) is * rejected. + * - A {@link READ_ONLY_PERMISSIONS} key is capped down to "read". * - `metadata:read` is always required by GitHub, so it is forced in (and * never elevated to write). */ @@ -138,7 +173,7 @@ export function capPermissions( `Permission "${key}" must be "read" or "write" (got "${value}").`, ); } - out[key] = value; + out[key] = READ_ONLY_PERMISSIONS.has(key) ? "read" : value; } // GitHub always requires metadata:read; never grant metadata:write. diff --git a/github/server/tools/mint-repo-token.ts b/github/server/tools/mint-repo-token.ts index a821dae8..61da5cdd 100644 --- a/github/server/tools/mint-repo-token.ts +++ b/github/server/tools/mint-repo-token.ts @@ -19,6 +19,11 @@ import { repoGrantClientId, } from "../lib/repo-grant.ts"; import { getRepoGrantStore } from "../lib/repo-grant-store.ts"; +import { + ALLOWED_PERMISSIONS, + DEFAULT_PERMISSIONS, + READ_ONLY_PERMISSIONS, +} from "../lib/repo-token.ts"; import type { Env } from "../types/env.ts"; export function createMintRepoTokenTool() { @@ -29,7 +34,8 @@ export function createMintRepoTokenTool() { "with least-privilege permissions, using the GitHub App. The authenticated " + "caller must already be entitled to the installation and repository — the " + "tool verifies this against the caller's own GitHub context before minting. " + - "The token grants only repo-content / pull-request / issue access. Also " + + "The token grants only repo-content / pull-request / issue access plus " + + "read-only CI checks and deployments. Also " + "returns a durable refresh token (refreshToken) plus tokenEndpoint and " + "clientId: POST grant_type=refresh_token to tokenEndpoint to mint a fresh " + "token later without the caller's GitHub login.", @@ -58,11 +64,16 @@ export function createMintRepoTokenTool() { permissions: z .record(z.string(), z.string()) .optional() + // Derived from the allowlist itself: a hand-written list silently went + // stale when `checks` was added, so callers were told to expect a + // rejection the server would in fact have accepted. .describe( "Optional GitHub permission map, capped to least privilege. Allowed " + - "keys: contents, metadata, pull_requests, issues; values: read | " + - 'write. Defaults to { contents: "write", metadata: "read", ' + - 'pull_requests: "write" }. Anything broader is rejected.', + `keys: ${[...ALLOWED_PERMISSIONS].join(", ")}; values: read | ` + + `write, except ${[...READ_ONLY_PERMISSIONS].join(", ")}, which are ` + + `always capped to read. Defaults to ` + + `${JSON.stringify(DEFAULT_PERMISSIONS)}. Anything broader is ` + + "rejected.", ), }), outputSchema: z.object({ diff --git a/google-analytics-sa/app.json b/google-analytics-sa/app.json index fd1a9bb7..b76124a3 100644 --- a/google-analytics-sa/app.json +++ b/google-analytics-sa/app.json @@ -24,7 +24,7 @@ "reporting", "service-account" ], - "short_description": "Query Google Analytics 4 with a service account — no OAuth login required. ", + "short_description": "Query Google Analytics 4 with a service account — no OAuth login required.", "mesh_description": "The Google Analytics Service Account MCP gives server-to-server access to GA4 — the same reporting tools as the OAuth variant, with no per-user login and no refresh token to keep alive. **Setup (easy path)** - Leave SERVICE_ACCOUNT_JSON empty and run the `check-service-account-access` tool. It returns the managed service account email; add that email as a Viewer under GA4 Admin > Property access management, then run the tool again to confirm. **Setup (bring your own)** - Prefer your own Google Cloud project? Create a service account there, enable the Analytics Data and Admin APIs, paste its JSON key into SERVICE_ACCOUNT_JSON, and grant that email Viewer access on the property. **Key Features** - Custom and funnel reports via the Data API, realtime active users, custom dimensions and metrics metadata, account hierarchy, Google Ads links, and property annotations. Access tokens are minted and refreshed automatically." } }