Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/mcp-sdk-tier-audit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ For public repos, any authenticated token works (no special scopes needed — au
--days <n> Limit triage analysis to last N days
--output <format> json | markdown | terminal (default: terminal)
--token <token> GitHub token (defaults to GITHUB_TOKEN or gh auth token)
--tag-prefix <prefix> Only consider releases whose tag starts with this prefix (monorepos with per-package tags, e.g. "package-name-v")
```

### What the CLI Checks
Expand Down
208 changes: 208 additions & 0 deletions src/tier-check/checks/release.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { describe, test, expect, vi } from 'vitest';
import { Octokit } from '@octokit/rest';
import { checkStableRelease, extractSemver } from './release';

function mockOctokit(
releases: Array<{ tag_name: string; draft?: boolean; prerelease?: boolean }>
) {
return {
repos: {
listReleases: vi.fn().mockResolvedValue({ data: releases })
}
} as unknown as Octokit;
}

describe('extractSemver', () => {
test('plain tag', () => {
expect(extractSemver('v1.2.3')).toEqual({
version: '1.2.3',
major: 1,
prerelease: null
});
});

test('no leading v', () => {
expect(extractSemver('1.2.3')).toEqual({
version: '1.2.3',
major: 1,
prerelease: null
});
});

test('crate prefix', () => {
expect(extractSemver('rust-mcp-sdk-v1.0.1')).toEqual({
version: '1.0.1',
major: 1,
prerelease: null
});
});

test('npm-style prefix with prerelease', () => {
expect(extractSemver('mcp-use@1.18.0-canary.3')).toEqual({
version: '1.18.0-canary.3',
major: 1,
prerelease: 'canary.3'
});
});

test('semver prerelease', () => {
expect(extractSemver('pkg-v2.0.0-rc.1')).toEqual({
version: '2.0.0-rc.1',
major: 2,
prerelease: 'rc.1'
});
});

test('0.x version', () => {
expect(extractSemver('v0.9.1')).toEqual({
version: '0.9.1',
major: 0,
prerelease: null
});
});

test('no semver returns null', () => {
expect(extractSemver('nightly')).toBeNull();
});

test('empty string returns null', () => {
expect(extractSemver('')).toBeNull();
});
});

describe('checkStableRelease', () => {
test('plain tag passes', async () => {
const octokit = mockOctokit([{ tag_name: 'v1.2.3' }]);
const result = await checkStableRelease(octokit, 'owner', 'repo');
expect(result).toEqual({
status: 'pass',
version: '1.2.3',
is_stable: true,
is_prerelease: false
});
});

test('no leading v passes', async () => {
const octokit = mockOctokit([{ tag_name: '1.2.3' }]);
const result = await checkStableRelease(octokit, 'owner', 'repo');
expect(result.status).toBe('pass');
expect(result.version).toBe('1.2.3');
});

test('crate prefix passes', async () => {
const octokit = mockOctokit([{ tag_name: 'rust-mcp-sdk-v1.0.1' }]);
const result = await checkStableRelease(octokit, 'owner', 'repo');
expect(result.status).toBe('pass');
expect(result.version).toBe('1.0.1');
});

test('npm-style prefix with canary is prerelease', async () => {
const octokit = mockOctokit([{ tag_name: 'mcp-use@1.18.0-canary.3' }]);
const result = await checkStableRelease(octokit, 'owner', 'repo');
expect(result.is_prerelease).toBe(true);
expect(result.is_stable).toBe(false);
expect(result.status).toBe('fail');
expect(result.version).toBe('1.18.0-canary.3');
});

test('prefix filter picks correct tag', async () => {
const octokit = mockOctokit([
{ tag_name: 'mcp-use@1.18.0-canary.3' },
{ tag_name: 'python-v1.5.2' }
]);
const result = await checkStableRelease(
octokit,
'owner',
'repo',
'python-v'
);
expect(result.status).toBe('pass');
expect(result.version).toBe('1.5.2');
expect(result.is_stable).toBe(true);
});

test('0.x version fails', async () => {
const octokit = mockOctokit([{ tag_name: 'v0.9.1' }]);
const result = await checkStableRelease(octokit, 'owner', 'repo');
expect(result.is_stable).toBe(false);
expect(result.status).toBe('fail');
});

test('semver prerelease is prerelease', async () => {
const octokit = mockOctokit([{ tag_name: 'pkg-v2.0.0-rc.1' }]);
const result = await checkStableRelease(octokit, 'owner', 'repo');
expect(result.is_prerelease).toBe(true);
expect(result.is_stable).toBe(false);
expect(result.status).toBe('fail');
});

test('unparseable tags are skipped in favor of a parseable one', async () => {
const octokit = mockOctokit([
{ tag_name: 'nightly' },
{ tag_name: 'v1.0.0' }
]);
const result = await checkStableRelease(octokit, 'owner', 'repo');
expect(result.status).toBe('pass');
expect(result.version).toBe('1.0.0');
});

test('drafts are ignored when a published release exists', async () => {
const octokit = mockOctokit([
{ tag_name: 'v2.0.0', draft: true },
{ tag_name: 'v1.0.0' }
]);
const result = await checkStableRelease(octokit, 'owner', 'repo');
expect(result.status).toBe('pass');
expect(result.version).toBe('1.0.0');
});

test('empty releases fails', async () => {
const octokit = mockOctokit([]);
const result = await checkStableRelease(octokit, 'owner', 'repo');
expect(result.status).toBe('fail');
expect(result.version).toBeNull();
expect(result.is_stable).toBe(false);
});

test('candidate with prerelease flag on GitHub is prerelease', async () => {
const octokit = mockOctokit([{ tag_name: 'v1.0.0', prerelease: true }]);
const result = await checkStableRelease(octokit, 'owner', 'repo');
expect(result.is_prerelease).toBe(true);
expect(result.is_stable).toBe(false);
expect(result.status).toBe('fail');
});

test('tag with prerelease markers in non-semver portion is prerelease', async () => {
const octokit = mockOctokit([{ tag_name: 'canary-v1.0.0' }]);
const result = await checkStableRelease(octokit, 'owner', 'repo');
expect(result.is_prerelease).toBe(true);
expect(result.is_stable).toBe(false);
});

test('tagPrefix filters out non-matching releases', async () => {
const octokit = mockOctokit([
{ tag_name: 'rust-mcp-transport-v1.0.0' },
{ tag_name: 'rust-mcp-sdk-v1.0.1' }
]);
const result = await checkStableRelease(
octokit,
'owner',
'repo',
'rust-mcp-sdk-v'
);
expect(result.status).toBe('pass');
expect(result.version).toBe('1.0.1');
});

test('tagPrefix with no matching releases fails', async () => {
const octokit = mockOctokit([{ tag_name: 'other-pkg-v1.0.0' }]);
const result = await checkStableRelease(
octokit,
'owner',
'repo',
'my-pkg-v'
);
expect(result.status).toBe('fail');
expect(result.version).toBeNull();
});
});
75 changes: 53 additions & 22 deletions src/tier-check/checks/release.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,40 @@
import { Octokit } from '@octokit/rest';
import { ReleaseResult } from '../types';

/**
* Extracts the first semver from a release tag, tolerating monorepo tag
* prefixes such as `rust-mcp-sdk-v1.0.1`, `mcp-use@1.18.0-canary.3`,
* or plain `v1.2.3`. Returns null when the tag contains no semver.
*/
export function extractSemver(
tag: string
): { version: string; major: number; prerelease: string | null } | null {
const match = tag.match(/(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
if (!match) return null;
return {
version: match[0],
major: parseInt(match[1], 10),
prerelease: match[4] ?? null
};
}

// Word-boundary match so markers are caught both at the start of a tag
// (e.g. `canary-v1.0.0`) and after a `-` separator (e.g. `v1.0.0-alpha`).
const PRERELEASE_MARKERS = /\b(alpha|beta|rc|dev|preview|snapshot|canary)\b/i;

export async function checkStableRelease(
octokit: Octokit,
owner: string,
repo: string
repo: string,
tagPrefix?: string
): Promise<ReleaseResult> {
try {
const { data: releases } = await octokit.repos.listReleases({
owner,
repo,
per_page: 20
// With a prefix filter, widen the window so the package's latest
// release isn't pushed out by other packages' releases.
per_page: tagPrefix ? 100 : 20
});

if (releases.length === 0) {
Expand All @@ -22,31 +46,38 @@ export async function checkStableRelease(
};
}

// Find latest non-draft release
const latest = releases.find((r) => !r.draft);
if (!latest) {
const candidates = releases.filter(
(r) => !r.draft && (!tagPrefix || r.tag_name.startsWith(tagPrefix))
);

// First release (API order) whose tag contains a parseable semver.
// Tags with no semver are skipped , previously they produced a fail with
// the raw tag as version, which broke selection in mixed-tag monorepos.
for (const release of candidates) {
const parsed = extractSemver(release.tag_name);
if (!parsed) continue;

const isPrerelease =
release.prerelease ||
(parsed.prerelease !== null &&
PRERELEASE_MARKERS.test(`-${parsed.prerelease}`)) ||
PRERELEASE_MARKERS.test(release.tag_name);

const isStable = !isPrerelease && parsed.major >= 1;

return {
status: 'fail',
version: null,
is_stable: false,
is_prerelease: false
status: isStable ? 'pass' : 'fail',
version: parsed.version,
is_stable: isStable,
is_prerelease: isPrerelease
};
}

const version = latest.tag_name.replace(/^v/, '');
const isPrerelease =
latest.prerelease ||
/-(alpha|beta|rc|dev|preview|snapshot)/i.test(version);

// Check if version is >= 1.0.0
const parts = version.split('.').map((p) => parseInt(p, 10));
const isStable = !isPrerelease && parts.length >= 2 && parts[0] >= 1;

return {
status: isStable ? 'pass' : 'fail',
version,
is_stable: isStable,
is_prerelease: isPrerelease
status: 'fail',
version: null,
is_stable: false,
is_prerelease: false
};
} catch {
return {
Expand Down
7 changes: 5 additions & 2 deletions src/tier-check/checks/spec-tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { SpecTrackingResult } from '../types';
export async function checkSpecTracking(
octokit: Octokit,
owner: string,
repo: string
repo: string,
tagPrefix?: string
): Promise<SpecTrackingResult> {
try {
// Get latest spec release from modelcontextprotocol/modelcontextprotocol
Expand All @@ -21,7 +22,9 @@ export async function checkSpecTracking(
repo,
per_page: 50
});
const nonDraftSdkReleases = sdkReleases.filter((r) => !r.draft);
const nonDraftSdkReleases = sdkReleases.filter(
(r) => !r.draft && (!tagPrefix || r.tag_name.startsWith(tagPrefix))
);

if (!latestSpec || nonDraftSdkReleases.length === 0) {
return {
Expand Down
16 changes: 11 additions & 5 deletions src/tier-check/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export function createTierCheckCommand(): Command {
'--spec-version <version>',
'Only run conformance scenarios for this spec version'
)
.option(
'--tag-prefix <prefix>',
'Only consider releases whose tag starts with this prefix (monorepos with per-package tags, e.g. "rust-mcp-sdk-v")'
)
.action(async (options) => {
const { owner, repo } = parseRepo(options.repo);
let token = options.token || process.env.GITHUB_TOKEN;
Expand Down Expand Up @@ -125,15 +129,17 @@ export function createTierCheckCommand(): Command {
console.error(' \u2713 P0 Resolution');
return r;
}),
checkStableRelease(octokit, owner, repo).then((r) => {
console.error(' \u2713 Stable Release');
return r;
}),
checkStableRelease(octokit, owner, repo, options.tagPrefix).then(
(r) => {
console.error(' \u2713 Stable Release');
return r;
}
),
checkPolicySignals(octokit, owner, repo, options.branch).then((r) => {
console.error(' \u2713 Policy Signals');
return r;
}),
checkSpecTracking(octokit, owner, repo).then((r) => {
checkSpecTracking(octokit, owner, repo, options.tagPrefix).then((r) => {
console.error(' \u2713 Spec Tracking');
return r;
})
Expand Down