From d4f1d49d07bf1aa3abe6290f30f9e833d357e2e1 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 12 Jun 2026 10:25:48 -0400 Subject: [PATCH 1/3] fix(frameworks): let unpinned instances adopt version updates (FRAME-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framework instances with no currentVersion (e.g. created before versioning) dead-ended the update flow: getUpdatePreview threw "Instance is not on any version", so the review-update page got no data and redirected straight back (no Apply button), and the sync threw too. The "Update available" banner never cleared and editor changes never reached the org. Both the preview and the sync now fall back to the framework's earliest published version as the diff baseline when the instance is unpinned. The apply is idempotent (creates only missing controls/tasks, reconciles requirement-map edges against the target) so nothing duplicates, and it pins currentVersionId at the end — healing the unpinned state for future updates. Co-Authored-By: Claude Fable 5 --- .../framework-sync.service.spec.ts | 62 ++++++++++++++++++- .../framework-sync.service.ts | 16 ++++- apps/api/src/frameworks/frameworks.service.ts | 25 +++++--- 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync.service.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-sync.service.spec.ts index af87d5b1e9..246472ee26 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-sync.service.spec.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-sync.service.spec.ts @@ -5,12 +5,12 @@ import { FrameworkSyncService } from './framework-sync.service'; jest.mock('@db', () => { const tx = { frameworkInstance: { findUnique: jest.fn() }, - frameworkVersion: { findUnique: jest.fn() }, + frameworkVersion: { findUnique: jest.fn(), findFirst: jest.fn() }, }; return { db: { frameworkInstance: { findUnique: jest.fn() }, - frameworkVersion: { findUnique: jest.fn() }, + frameworkVersion: { findUnique: jest.fn(), findFirst: jest.fn() }, $transaction: jest.fn((cb: (t: typeof tx) => Promise) => cb(tx)), __tx: tx, }, @@ -21,8 +21,20 @@ jest.mock('./org-advisory-lock', () => ({ lockOrganizationForSync: jest.fn().mockResolvedValue(undefined), })); +jest.mock('./framework-sync-apply', () => ({ + applySync: jest.fn(), +})); + import { db } from '@db'; -const tx = (db as unknown as { __tx: { frameworkInstance: { findUnique: jest.Mock }; frameworkVersion: { findUnique: jest.Mock } } }).__tx; +import { applySync } from './framework-sync-apply'; +const tx = ( + db as unknown as { + __tx: { + frameworkInstance: { findUnique: jest.Mock }; + frameworkVersion: { findUnique: jest.Mock; findFirst: jest.Mock }; + }; + } +).__tx; describe('FrameworkSyncService preconditions', () => { let service: FrameworkSyncService; @@ -62,3 +74,47 @@ describe('FrameworkSyncService preconditions', () => { expect(db.$transaction).not.toHaveBeenCalled(); }); }); + +describe('FrameworkSyncService unpinned instances (FRAME-2)', () => { + let service: FrameworkSyncService; + + beforeEach(async () => { + jest.clearAllMocks(); + const mod = await Test.createTestingModule({ providers: [FrameworkSyncService] }).compile(); + service = mod.get(FrameworkSyncService); + }); + + it('diffs from the earliest published version when the instance has no current version', async () => { + const unpinned = { id: 'frm_1', organizationId: 'org_1', frameworkId: 'frk_soc2', currentVersionId: null }; + (db.frameworkInstance.findUnique as jest.Mock).mockResolvedValue(unpinned); + tx.frameworkInstance.findUnique.mockResolvedValue(unpinned); + tx.frameworkVersion.findUnique.mockResolvedValue({ id: 'fvr_target', frameworkId: 'frk_soc2' }); + tx.frameworkVersion.findFirst.mockResolvedValue({ id: 'fvr_earliest', frameworkId: 'frk_soc2' }); + (applySync as jest.Mock).mockResolvedValue({ syncOperationId: 'sync_1' }); + + const result = await service.sync({ organizationId: 'org_1', frameworkInstanceId: 'frm_1', targetVersionId: 'fvr_target', memberId: 'mem_1' }); + + // Falls back to the earliest version as the "from" instead of throwing. + expect(tx.frameworkVersion.findFirst).toHaveBeenCalled(); + expect(applySync).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + currentVersion: expect.objectContaining({ id: 'fvr_earliest' }), + targetVersion: expect.objectContaining({ id: 'fvr_target' }), + }), + ); + expect(result.kind).toBe('synced'); + }); + + it('400s when an unpinned instance’s framework has no published version to diff from', async () => { + const unpinned = { id: 'frm_1', organizationId: 'org_1', frameworkId: 'frk_soc2', currentVersionId: null }; + (db.frameworkInstance.findUnique as jest.Mock).mockResolvedValue(unpinned); + tx.frameworkInstance.findUnique.mockResolvedValue(unpinned); + tx.frameworkVersion.findUnique.mockResolvedValue({ id: 'fvr_target', frameworkId: 'frk_soc2' }); + tx.frameworkVersion.findFirst.mockResolvedValue(null); + + await expect(service.sync({ organizationId: 'org_1', frameworkInstanceId: 'frm_1', targetVersionId: 'fvr_target', memberId: 'mem_1' })) + .rejects.toBeInstanceOf(BadRequestException); + expect(applySync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync.service.ts b/apps/api/src/frameworks/framework-versioning/framework-sync.service.ts index 0011c1dbdf..2a5b9685cf 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-sync.service.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-sync.service.ts @@ -48,15 +48,27 @@ export class FrameworkSyncService { return { syncOperationId: null, instanceId: instance.id }; } - const [currentVersion, targetVersion] = await Promise.all([ + const [pinnedVersion, targetVersion] = await Promise.all([ instance.currentVersionId ? tx.frameworkVersion.findUnique({ where: { id: instance.currentVersionId } }) : null, tx.frameworkVersion.findUnique({ where: { id: params.targetVersionId } }), ]); if (!targetVersion) throw new NotFoundException('Target version not found'); + + // Unpinned instances (no currentVersion) diff from the framework's + // earliest published version so they can still adopt updates. applySync + // sets currentVersionId at the end, healing the unpinned state. + const currentVersion = + pinnedVersion ?? + (await tx.frameworkVersion.findFirst({ + where: { frameworkId: instance.frameworkId! }, + orderBy: { publishedAt: 'asc' }, + })); if (!currentVersion) { - throw new BadRequestException('Instance is not on any version; backfill v1.0.0 first'); + throw new BadRequestException( + 'Framework has no published version to sync from', + ); } if (currentVersion.frameworkId !== instance.frameworkId) { throw new BadRequestException('Version / framework mismatch'); diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 50e1a95281..2230f4a089 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -1022,9 +1022,6 @@ export class FrameworksService { if (!instance || instance.organizationId !== params.organizationId) { throw new NotFoundException('Framework instance not found'); } - if (!instance.currentVersion) { - throw new BadRequestException('Instance is not on any version'); - } const latest = await db.frameworkVersion.findFirst({ where: { frameworkId: instance.frameworkId! }, @@ -1034,8 +1031,22 @@ export class FrameworksService { throw new NotFoundException('No update available'); } - const fromManifest = instance.currentVersion - .manifest as unknown as FrameworkManifest; + // Instances that were never pinned to a version (e.g. created before + // versioning) have no currentVersion. Rather than dead-ending the update + // flow, diff from the framework's earliest published version so they can + // still adopt the latest — applying the sync pins currentVersionId, which + // heals the state for future updates. + const baseVersion = + instance.currentVersion ?? + (await db.frameworkVersion.findFirst({ + where: { frameworkId: instance.frameworkId! }, + orderBy: { publishedAt: 'asc' }, + })); + if (!baseVersion) { + throw new NotFoundException('No update available'); + } + + const fromManifest = baseVersion.manifest as unknown as FrameworkManifest; const toManifest = latest.manifest as unknown as FrameworkManifest; const templateControlIds = [ ...new Set([ @@ -1109,8 +1120,8 @@ export class FrameworksService { status: p.status, })), fromVersionLabel: { - id: instance.currentVersion.id, - version: instance.currentVersion.version, + id: baseVersion.id, + version: baseVersion.version, }, toVersionLabel: { id: latest.id, version: latest.version }, releaseNotes: latest.releaseNotes, From 9420bf025b213799d3f5754e184de911d24f4f3e Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 12 Jun 2026 11:18:46 -0400 Subject: [PATCH 2/3] feat(api): stable download URLs for MCP server artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP server's .mcpb and platform binaries are published as assets on the `apps/mcp-server/vX.Y.Z` release stream, which is buried among the frequent product releases — so version-pinned doc links rot every release and customers (and CX) struggle to find the current bundle. Add a public, version-neutral redirect endpoint that resolves the latest MCP release at request time and 302s to the requested asset on GitHub's CDN: GET /mcp/download/claude-desktop -> mcp-server.mcpb GET /mcp/download/macos-arm64 -> mcp-server-bun-darwin-arm64 GET /mcp/download/linux-x64 -> mcp-server-bun-linux-x64-modern GET /mcp/download/windows-x64 -> mcp-server-bun-windows-x64-modern.exe - Never goes stale: resolves the newest `apps/mcp-server/*` release each call. - No bandwidth cost: the file still downloads from GitHub's CDN; we only redirect. - No rate-limit risk: the release lookup is cached ~10 min and serves a stale result if GitHub briefly fails. - Excluded from the OpenAPI spec so it never becomes an MCP tool or docs entry. Docs will be pointed at these URLs in a follow-up once this is deployed. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/app.module.ts | 2 + .../mcp-download/mcp-download.controller.ts | 33 +++++ .../src/mcp-download/mcp-download.module.ts | 9 ++ .../mcp-download/mcp-download.service.spec.ts | 140 ++++++++++++++++++ .../src/mcp-download/mcp-download.service.ts | 138 +++++++++++++++++ 5 files changed, 322 insertions(+) create mode 100644 apps/api/src/mcp-download/mcp-download.controller.ts create mode 100644 apps/api/src/mcp-download/mcp-download.module.ts create mode 100644 apps/api/src/mcp-download/mcp-download.service.spec.ts create mode 100644 apps/api/src/mcp-download/mcp-download.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ccb8410374..9582e978b3 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -48,6 +48,7 @@ import { AuditModule } from './audit/audit.module'; import { ControlsModule } from './controls/controls.module'; import { RolesModule } from './roles/roles.module'; import { McpModule } from './mcp/mcp.module'; +import { McpDownloadModule } from './mcp-download/mcp-download.module'; import { EmailModule } from './email/email.module'; import { SecretsModule } from './secrets/secrets.module'; import { SecurityPenetrationTestsModule } from './security-penetration-tests/security-penetration-tests.module'; @@ -128,6 +129,7 @@ import { OffboardingChecklistModule } from './offboarding-checklist/offboarding- TimelinesModule, OffboardingChecklistModule, McpModule, + McpDownloadModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/mcp-download/mcp-download.controller.ts b/apps/api/src/mcp-download/mcp-download.controller.ts new file mode 100644 index 0000000000..44c2670192 --- /dev/null +++ b/apps/api/src/mcp-download/mcp-download.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Param, Res, VERSION_NEUTRAL } from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import type { Response } from 'express'; +import { Public } from '../auth/public.decorator'; +import { McpDownloadService } from './mcp-download.service'; + +/** + * Stable, never-stale download links for the MCP server artifacts. + * + * The `.mcpb` and the platform binaries are published as assets on the MCP + * release stream (`apps/mcp-server/vX.Y.Z`), which is buried among the frequent + * product releases — so a version-pinned link in the docs rots every release and + * is hard for customers to find. These endpoints 302-redirect to the asset on + * whatever the latest MCP release is, so the docs can link here once and forget. + * + * Public + unversioned on purpose (customer-facing download URLs). Excluded from + * the OpenAPI spec so it never becomes an MCP tool or a docs entry. + */ +@ApiExcludeController() +@Controller({ path: 'mcp/download', version: VERSION_NEUTRAL }) +export class McpDownloadController { + constructor(private readonly mcpDownloadService: McpDownloadService) {} + + @Get(':target') + @Public() + async download( + @Param('target') target: string, + @Res() res: Response, + ): Promise { + const url = await this.mcpDownloadService.resolveDownloadUrl(target); + res.redirect(302, url); + } +} diff --git a/apps/api/src/mcp-download/mcp-download.module.ts b/apps/api/src/mcp-download/mcp-download.module.ts new file mode 100644 index 0000000000..9a8c694371 --- /dev/null +++ b/apps/api/src/mcp-download/mcp-download.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { McpDownloadController } from './mcp-download.controller'; +import { McpDownloadService } from './mcp-download.service'; + +@Module({ + controllers: [McpDownloadController], + providers: [McpDownloadService], +}) +export class McpDownloadModule {} diff --git a/apps/api/src/mcp-download/mcp-download.service.spec.ts b/apps/api/src/mcp-download/mcp-download.service.spec.ts new file mode 100644 index 0000000000..c30927c735 --- /dev/null +++ b/apps/api/src/mcp-download/mcp-download.service.spec.ts @@ -0,0 +1,140 @@ +import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; +import { McpDownloadService } from './mcp-download.service'; + +function releasesPayload() { + return [ + // A product release with no MCP assets — must be skipped. + { tag_name: 'v3.79.1', draft: false, assets: [] }, + // The latest MCP release. + { + tag_name: 'apps/mcp-server/v0.2.0', + draft: false, + assets: [ + { + name: 'mcp-server.mcpb', + browser_download_url: 'https://gh/dl/v0.2.0/mcp-server.mcpb', + }, + { + name: 'mcp-server-bun-darwin-arm64', + browser_download_url: + 'https://gh/dl/v0.2.0/mcp-server-bun-darwin-arm64', + }, + ], + }, + // An older MCP release — must not win. + { + tag_name: 'apps/mcp-server/v0.1.0', + draft: false, + assets: [ + { + name: 'mcp-server.mcpb', + browser_download_url: 'https://gh/dl/v0.1.0/mcp-server.mcpb', + }, + ], + }, + ]; +} + +describe('McpDownloadService', () => { + let service: McpDownloadService; + let fetchSpy: jest.SpyInstance, Parameters>; + + beforeEach(() => { + service = new McpDownloadService(); + fetchSpy = jest.spyOn(global, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + function mockReleasesOnce(payload: unknown, status = 200) { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify(payload), { + status, + headers: { 'content-type': 'application/json' }, + }), + ); + } + + it('resolves a target to the asset URL on the latest MCP release', async () => { + mockReleasesOnce(releasesPayload()); + + const url = await service.resolveDownloadUrl('claude-desktop'); + + // Picked v0.2.0, not the older v0.1.0, and ignored the product release. + expect(url).toBe('https://gh/dl/v0.2.0/mcp-server.mcpb'); + }); + + it('maps each platform target to its stable asset', async () => { + mockReleasesOnce(releasesPayload()); + + const macos = await service.resolveDownloadUrl('macos-arm64'); + + expect(macos).toBe('https://gh/dl/v0.2.0/mcp-server-bun-darwin-arm64'); + }); + + it('rejects an unknown target without calling GitHub', async () => { + await expect(service.resolveDownloadUrl('bogus')).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('404s when the asset is missing on the latest release', async () => { + // Latest MCP release ships only the .mcpb, not the windows binary. + mockReleasesOnce([ + { + tag_name: 'apps/mcp-server/v0.2.0', + draft: false, + assets: [ + { + name: 'mcp-server.mcpb', + browser_download_url: 'https://gh/dl/v0.2.0/mcp-server.mcpb', + }, + ], + }, + ]); + + await expect( + service.resolveDownloadUrl('windows-x64'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('caches the release lookup across requests', async () => { + mockReleasesOnce(releasesPayload()); + + await service.resolveDownloadUrl('claude-desktop'); + await service.resolveDownloadUrl('macos-arm64'); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('serves a stale cached release if a later GitHub lookup fails', async () => { + mockReleasesOnce(releasesPayload()); + await service.resolveDownloadUrl('claude-desktop'); // primes the cache + + // Force the cache to look expired, then make the refresh fail. + (service as unknown as { cache: { fetchedAt: number } }).cache.fetchedAt = 0; + fetchSpy.mockRejectedValueOnce(new Error('network down')); + + const url = await service.resolveDownloadUrl('claude-desktop'); + expect(url).toBe('https://gh/dl/v0.2.0/mcp-server.mcpb'); + }); + + it('throws ServiceUnavailable when GitHub fails and there is no cache', async () => { + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 503 })); + + await expect( + service.resolveDownloadUrl('claude-desktop'), + ).rejects.toBeInstanceOf(ServiceUnavailableException); + }); + + it('throws when no MCP-tagged release exists in the page', async () => { + mockReleasesOnce([{ tag_name: 'v3.79.1', draft: false, assets: [] }]); + + await expect( + service.resolveDownloadUrl('claude-desktop'), + ).rejects.toBeInstanceOf(ServiceUnavailableException); + }); +}); diff --git a/apps/api/src/mcp-download/mcp-download.service.ts b/apps/api/src/mcp-download/mcp-download.service.ts new file mode 100644 index 0000000000..ea94e6b533 --- /dev/null +++ b/apps/api/src/mcp-download/mcp-download.service.ts @@ -0,0 +1,138 @@ +import { + Injectable, + Logger, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; + +/** + * Stable, human-friendly download target -> the (version-less) GitHub release + * asset name. The MCP server's release assets keep the same names across + * versions, so we can resolve them by name on whatever the latest release is. + */ +const ASSET_BY_TARGET: Record = { + 'claude-desktop': 'mcp-server.mcpb', + 'macos-arm64': 'mcp-server-bun-darwin-arm64', + 'linux-x64': 'mcp-server-bun-linux-x64-modern', + 'windows-x64': 'mcp-server-bun-windows-x64-modern.exe', +}; + +// The MCP server ships its own release stream (tag `apps/mcp-server/vX.Y.Z`), +// interleaved with the much more frequent product releases (`vX.Y.Z`). We page +// through the newest releases and pick the first one on the MCP stream. +const GITHUB_RELEASES_URL = + 'https://api.github.com/repos/trycompai/comp/releases?per_page=100'; +const MCP_TAG_PREFIX = 'apps/mcp-server/'; + +// Cache the resolved release so a burst of downloads can't exhaust GitHub's +// unauthenticated API budget (60 req/hr/IP). 10 min keeps us at a handful of +// API calls per hour regardless of download volume. +const CACHE_TTL_MS = 10 * 60 * 1000; + +interface ResolvedRelease { + tag: string; + /** asset filename -> browser_download_url */ + assets: Record; + fetchedAt: number; +} + +interface GitHubRelease { + tag_name: string; + draft: boolean; + assets: Array<{ name: string; browser_download_url: string }>; +} + +@Injectable() +export class McpDownloadService { + private readonly logger = new Logger(McpDownloadService.name); + private cache: ResolvedRelease | null = null; + + static readonly TARGETS = Object.keys(ASSET_BY_TARGET); + + /** + * Resolve a stable download target to the current GitHub CDN URL for that + * asset on the latest MCP release. Throws NotFoundException for unknown + * targets or missing assets. + */ + async resolveDownloadUrl(target: string): Promise { + const assetName = ASSET_BY_TARGET[target]; + if (!assetName) { + throw new NotFoundException( + `Unknown download target '${target}'. Valid targets: ${McpDownloadService.TARGETS.join( + ', ', + )}.`, + ); + } + + const release = await this.getLatestMcpRelease(); + const url = release.assets[assetName]; + if (!url) { + throw new NotFoundException( + `Asset '${assetName}' is not present on the latest MCP release (${release.tag}).`, + ); + } + return url; + } + + private async getLatestMcpRelease(): Promise { + if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) { + return this.cache; + } + + try { + this.cache = await this.fetchLatestMcpRelease(); + return this.cache; + } catch (err) { + const message = err instanceof Error ? err.message : 'unknown error'; + // Prefer a slightly stale answer over a hard failure if GitHub blips. + if (this.cache) { + this.logger.warn( + `GitHub lookup failed; serving stale MCP release ${this.cache.tag}: ${message}`, + ); + return this.cache; + } + this.logger.error(`Failed to resolve latest MCP release: ${message}`); + throw new ServiceUnavailableException( + 'Could not resolve the latest MCP server release. Please try again shortly.', + ); + } + } + + private async fetchLatestMcpRelease(): Promise { + const response = await fetch(GITHUB_RELEASES_URL, { + headers: { + // GitHub rejects API requests without a User-Agent. + 'User-Agent': 'comp-ai-mcp-download', + Accept: 'application/vnd.github+json', + // Optional: lifts the rate limit to 5000/hr if a token is configured. + ...(process.env.GITHUB_TOKEN && { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + }), + }, + }); + + if (!response.ok) { + throw new Error(`GitHub releases API returned ${response.status}`); + } + + const releases = (await response.json()) as GitHubRelease[]; + // Releases are returned newest-first, so the first MCP-tagged, non-draft + // release is the latest one. + const latest = releases.find( + (release) => !release.draft && release.tag_name.startsWith(MCP_TAG_PREFIX), + ); + + if (!latest) { + throw new Error( + `No release with tag prefix '${MCP_TAG_PREFIX}' in the latest 100 releases.`, + ); + } + + const assets: Record = {}; + for (const asset of latest.assets) { + assets[asset.name] = asset.browser_download_url; + } + + return { tag: latest.tag_name, assets, fetchedAt: Date.now() }; + } +} From 760ef2f6c8b20c5cf04ba3425d61ce2dc45add54 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 12 Jun 2026 11:47:21 -0400 Subject: [PATCH 3/3] fix(api): paginate + time-bound the MCP release lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the cubic review on PR #3120. - The repo cuts several product releases a day, so a single 100-release page pushes the latest MCP release out of view within ~3 weeks of no MCP release — which would make every download 404. Now page through newest-first (stopping at the first MCP release) up to 500 releases. - The GitHub fetch had no timeout, so a stalled upstream could hang the endpoint. Each request now uses AbortSignal.timeout(5s); a failure falls back to the cached release, or returns a clean 503 when there's no cache. Issues identified by cubic. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mcp-download/mcp-download.service.spec.ts | 36 ++++++++++ .../src/mcp-download/mcp-download.service.ts | 70 +++++++++++++------ 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/apps/api/src/mcp-download/mcp-download.service.spec.ts b/apps/api/src/mcp-download/mcp-download.service.spec.ts index c30927c735..bb06949cdb 100644 --- a/apps/api/src/mcp-download/mcp-download.service.spec.ts +++ b/apps/api/src/mcp-download/mcp-download.service.spec.ts @@ -101,6 +101,42 @@ describe('McpDownloadService', () => { ).rejects.toBeInstanceOf(NotFoundException); }); + it('paginates beyond the first 100 releases to find the MCP release', async () => { + // Page 1: a full page of product releases, no MCP release. + const page1 = Array.from({ length: 100 }, (_, i) => ({ + tag_name: `v3.${i}.0`, + draft: false, + assets: [], + })); + mockReleasesOnce(page1); + // Page 2: contains the MCP release. + mockReleasesOnce(releasesPayload()); + + const url = await service.resolveDownloadUrl('claude-desktop'); + + expect(url).toBe('https://gh/dl/v0.2.0/mcp-server.mcpb'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('bounds each GitHub request with an abort signal (no unbounded hang)', async () => { + mockReleasesOnce(releasesPayload()); + + await service.resolveDownloadUrl('claude-desktop'); + + const init = fetchSpy.mock.calls[0][1]; + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); + + it('handles a GitHub timeout/abort without hanging', async () => { + const timeout = new Error('The operation timed out'); + timeout.name = 'TimeoutError'; + fetchSpy.mockRejectedValueOnce(timeout); + + await expect( + service.resolveDownloadUrl('claude-desktop'), + ).rejects.toBeInstanceOf(ServiceUnavailableException); + }); + it('caches the release lookup across requests', async () => { mockReleasesOnce(releasesPayload()); diff --git a/apps/api/src/mcp-download/mcp-download.service.ts b/apps/api/src/mcp-download/mcp-download.service.ts index ea94e6b533..81d6e7d70d 100644 --- a/apps/api/src/mcp-download/mcp-download.service.ts +++ b/apps/api/src/mcp-download/mcp-download.service.ts @@ -18,12 +18,22 @@ const ASSET_BY_TARGET: Record = { }; // The MCP server ships its own release stream (tag `apps/mcp-server/vX.Y.Z`), -// interleaved with the much more frequent product releases (`vX.Y.Z`). We page -// through the newest releases and pick the first one on the MCP stream. +// interleaved with the much more frequent product releases (`vX.Y.Z`) — the repo +// cuts several product releases a day, so the latest MCP release can sit well +// past the first page. We page through newest-first and stop at the first MCP +// release (which, because the list is newest-first, is the latest one). const GITHUB_RELEASES_URL = - 'https://api.github.com/repos/trycompai/comp/releases?per_page=100'; + 'https://api.github.com/repos/trycompai/comp/releases'; +const RELEASES_PER_PAGE = 100; +// Bounds the work (and the GitHub API budget) while covering many months of +// product-release velocity, so a slow MCP release cadence can't make downloads +// look unavailable. +const MAX_RELEASE_PAGES = 5; const MCP_TAG_PREFIX = 'apps/mcp-server/'; +// Bound each GitHub call so a stalled upstream can't tie up the endpoint. +const GITHUB_TIMEOUT_MS = 5_000; + // Cache the resolved release so a burst of downloads can't exhaust GitHub's // unauthenticated API budget (60 req/hr/IP). 10 min keeps us at a handful of // API calls per hour regardless of download volume. @@ -99,7 +109,39 @@ export class McpDownloadService { } private async fetchLatestMcpRelease(): Promise { - const response = await fetch(GITHUB_RELEASES_URL, { + for (let page = 1; page <= MAX_RELEASE_PAGES; page++) { + const releases = await this.fetchReleasesPage(page); + + // Releases come back newest-first, so the first MCP-tagged, non-draft + // release on any page is the latest one. + const latest = releases.find( + (release) => + !release.draft && release.tag_name.startsWith(MCP_TAG_PREFIX), + ); + if (latest) { + const assets: Record = {}; + for (const asset of latest.assets) { + assets[asset.name] = asset.browser_download_url; + } + return { tag: latest.tag_name, assets, fetchedAt: Date.now() }; + } + + // A short page means we've reached the end of the release history. + if (releases.length < RELEASES_PER_PAGE) break; + } + + throw new Error( + `No release with tag prefix '${MCP_TAG_PREFIX}' in the latest ${ + MAX_RELEASE_PAGES * RELEASES_PER_PAGE + } releases.`, + ); + } + + private async fetchReleasesPage(page: number): Promise { + const url = `${GITHUB_RELEASES_URL}?per_page=${RELEASES_PER_PAGE}&page=${page}`; + const response = await fetch(url, { + // Abort a stalled GitHub call instead of hanging the endpoint. + signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), headers: { // GitHub rejects API requests without a User-Agent. 'User-Agent': 'comp-ai-mcp-download', @@ -115,24 +157,6 @@ export class McpDownloadService { throw new Error(`GitHub releases API returned ${response.status}`); } - const releases = (await response.json()) as GitHubRelease[]; - // Releases are returned newest-first, so the first MCP-tagged, non-draft - // release is the latest one. - const latest = releases.find( - (release) => !release.draft && release.tag_name.startsWith(MCP_TAG_PREFIX), - ); - - if (!latest) { - throw new Error( - `No release with tag prefix '${MCP_TAG_PREFIX}' in the latest 100 releases.`, - ); - } - - const assets: Record = {}; - for (const asset of latest.assets) { - assets[asset.name] = asset.browser_download_url; - } - - return { tag: latest.tag_name, assets, fetchedAt: Date.now() }; + return (await response.json()) as GitHubRelease[]; } }