diff --git a/comparisons/baselines/compare-sp4-badge.svg b/comparisons/baselines/compare-sp4-badge.svg
new file mode 100644
index 0000000..7e61249
--- /dev/null
+++ b/comparisons/baselines/compare-sp4-badge.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/comparisons/baselines/compare-sp5-github-row.svg b/comparisons/baselines/compare-sp5-github-row.svg
new file mode 100644
index 0000000..922040f
--- /dev/null
+++ b/comparisons/baselines/compare-sp5-github-row.svg
@@ -0,0 +1 @@
+stars124.3kforks26.8kissues1.3kreleasev15.0.3licenseMIT
\ No newline at end of file
diff --git a/comparisons/compare-sp4-badge.png b/comparisons/compare-sp4-badge.png
new file mode 100644
index 0000000..dfecad7
Binary files /dev/null and b/comparisons/compare-sp4-badge.png differ
diff --git a/comparisons/compare-sp5-github-row.png b/comparisons/compare-sp5-github-row.png
new file mode 100644
index 0000000..9c14cd3
Binary files /dev/null and b/comparisons/compare-sp5-github-row.png differ
diff --git a/docs/superpowers/plans/2026-05-28-github-badge-rendering.md b/docs/superpowers/plans/2026-05-28-github-badge-rendering.md
new file mode 100644
index 0000000..83ee3b5
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-28-github-badge-rendering.md
@@ -0,0 +1,1297 @@
+# GitHub Badge Rendering Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship a hand-drawn GitHub-style `Badge` library component plus three MCP tools (`render-badge`, `render-github-badge`, `render-github-badge-row`) so an agent can render literal or live-data badges for any GitHub repo.
+
+**Architecture:** Pure library component (intrinsic SVG, no network, brand-aware via `resolveBrand(props.brand)` in body — matches the documented `CLAUDE.md` pattern). All HTTP lives in `mcp/src/githubClient.ts` behind an injectable `fetch` with TTL cache + in-flight promise dedup + optional `GITHUB_TOKEN`. MCP tools import the rendering surface from `goldenchart/server` so emitted SVGs are self-contained.
+
+**Tech Stack:** React 19, Rough.js, D3 (existing), Zod schemas, Vitest snapshots. No new runtime deps. Node 18+ `globalThis.fetch`.
+
+**Spec:** `docs/superpowers/specs/2026-05-28-github-badge-rendering-design.md`
+**Branch:** `spec/github-badge-rendering` (already exists, spec already committed).
+
+---
+
+## File structure
+
+**Library (`src/`):**
+- Create `src/core/badgeIcons.ts` — `BadgeIcon` type, icon path table, fixed tone color table. Pure data + types.
+- Create `src/components/Badge.tsx` — the component.
+- Create `src/components/Badge.test.ts` — vitest tests: width formula, brand wiring, tone selection, optional-icon branch, server SVG snapshot for one case.
+- Modify `src/components/index.ts` — export `Badge`, `BadgeProps`, `BadgeTone`, `BadgeIcon`.
+- Modify `src/index.ts` — re-export the same names.
+
+**MCP (`mcp/src/`):**
+- Create `mcp/src/githubClient.ts` — `createGithubClient`, types, errors. No I/O at import time.
+- Create `mcp/src/githubClient.test.ts` — TTL cache, in-flight dedup, error mapping, auth header behavior. All via the injected `fetch` stub.
+- Create `mcp/src/badgeTools.ts` — three `ToolDef`s. Does NOT go through `makeRenderTool` (that factory assumes `width`/`height` args; the badge tools don't have those).
+- Create `mcp/src/badgeTools.test.ts` — snapshot tests for all three tools, fetch stubbed.
+- Modify `mcp/src/tools.ts` — import `badgeTools` and add to the exported tool list.
+- New snapshot files appear under `mcp/src/__snapshots__/badgeTools.test.ts.snap`.
+
+**Carry-forward render:**
+- Modify `mcp/compare/...` (whatever script `npm run compare` already runs — locate before editing) to add one literal `Badge` and one `render-github-badge-row` example with a stubbed client.
+
+---
+
+## Task 1: Icon + tone color tables
+
+**Files:**
+- Create: `src/core/badgeIcons.ts`
+- Create: `src/core/badgeIcons.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// src/core/badgeIcons.test.ts
+import { describe, it, expect } from 'vitest';
+import { BADGE_ICON_PATHS, BADGE_TONE_COLORS, BADGE_ICONS, BADGE_TONES } from './badgeIcons';
+
+describe('badgeIcons', () => {
+ it('exposes a stroke path string for every icon name', () => {
+ for (const name of BADGE_ICONS) {
+ const entry = BADGE_ICON_PATHS[name];
+ const strokes = Array.isArray(entry) ? entry : [entry];
+ expect(strokes.length).toBeGreaterThan(0);
+ for (const d of strokes) {
+ // Stroke-only contract: no fills (no `Z`), nothing closes the path.
+ expect(d).not.toMatch(/[Zz]/);
+ expect(d.length).toBeGreaterThan(0);
+ }
+ }
+ });
+ it('has a color for every fixed tone (success/warn/danger)', () => {
+ expect(BADGE_TONE_COLORS.success).toMatch(/^#/);
+ expect(BADGE_TONE_COLORS.warn).toMatch(/^#/);
+ expect(BADGE_TONE_COLORS.danger).toMatch(/^#/);
+ });
+ it('lists every supported tone literal', () => {
+ expect(new Set(BADGE_TONES)).toEqual(
+ new Set(['neutral', 'info', 'success', 'warn', 'danger']),
+ );
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run from project root: `npm test -- src/core/badgeIcons`
+Expected: FAIL with `cannot find module './badgeIcons'`.
+
+- [ ] **Step 3: Implement `src/core/badgeIcons.ts`**
+
+```ts
+/**
+ * Icon stroke paths and fixed tone colors for `Badge`. Pure data, no React.
+ *
+ * Icon authoring contract (per spec):
+ * - viewBox 16x16
+ * - stroke-only (no `Z`, no fills)
+ * - single open sub-path preferred; if a glyph genuinely needs two strokes,
+ * the entry may be `string[]` and the component renders each as its own
+ * Rough path.
+ */
+
+export const BADGE_TONES = ['neutral', 'info', 'success', 'warn', 'danger'] as const;
+export type BadgeTone = (typeof BADGE_TONES)[number];
+
+export const BADGE_ICONS = [
+ 'star',
+ 'fork',
+ 'issue',
+ 'tag',
+ 'commit',
+ 'license',
+ 'lang',
+ 'check',
+] as const;
+export type BadgeIcon = (typeof BADGE_ICONS)[number];
+
+/** Stroke-only path data, authored against a 16x16 box. */
+export const BADGE_ICON_PATHS: Record = {
+ // Five-point star outline (open at the top tip so it remains an open stroke).
+ star: 'M8 1 L10 6 L15 6 L11 9.5 L12.5 14.5 L8 11.5 L3.5 14.5 L5 9.5 L1 6 L6 6',
+ // Two circles + a connector (git fork glyph).
+ fork: [
+ 'M4 3 a2 2 0 1 0 0 4 a2 2 0 1 0 0 -4',
+ 'M12 3 a2 2 0 1 0 0 4 a2 2 0 1 0 0 -4',
+ 'M4 7 L4 11 a2 2 0 0 0 2 2 L10 13',
+ 'M12 7 L12 9',
+ ],
+ // Circle with vertical bar (open issue indicator).
+ issue: ['M8 1 a7 7 0 1 0 0 14 a7 7 0 1 0 0 -14', 'M8 5 L8 9', 'M8 11 L8 12'],
+ // Price-tag silhouette (open stroke; no Z).
+ tag: 'M1 8 L8 1 L15 1 L15 8 L8 15',
+ // Git commit dot + line.
+ commit: ['M2 8 L6 8', 'M10 8 L14 8', 'M8 6 a2 2 0 1 0 0 4 a2 2 0 1 0 0 -4'],
+ // Scroll silhouette (open).
+ license: ['M3 2 L13 2 L13 13 L8 13', 'M3 2 L3 13 L8 13 L8 11', 'M5 5 L11 5', 'M5 8 L11 8'],
+ // Three vertical bars (language stack).
+ lang: ['M3 13 L3 5', 'M8 13 L8 3', 'M13 13 L13 7'],
+ // Check mark.
+ check: 'M2 9 L6 13 L14 3',
+};
+
+export const BADGE_TONE_COLORS: Record<'success' | 'warn' | 'danger', string> = {
+ success: '#3a8a3a',
+ warn: '#b8860b',
+ danger: '#b13a3a',
+};
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `npm test -- src/core/badgeIcons`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/core/badgeIcons.ts src/core/badgeIcons.test.ts
+git commit -m "feat(badge): icon stroke paths + fixed tone colors"
+```
+
+---
+
+## Task 2: `Badge` component
+
+**Files:**
+- Create: `src/components/Badge.tsx`
+- Create: `src/components/Badge.test.ts`
+
+The badge does NOT use ``, does NOT read brand from context, does NOT register an a11y title (callers can wrap if needed). It calls `resolveBrand(props.brand)` and `resolveVibe(props.vibe)` directly in the body. Width is computed from text measurement via `src/core/text.ts` (the same helper axis ticks use — locate the export before relying on it; if the helper signature is `measureTextWidth(text, fontSize, fontFamily)`, use it; if it returns an approximation, that's fine).
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// src/components/Badge.test.ts
+import { describe, it, expect } from 'vitest';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { createElement } from 'react';
+import { Badge } from './Badge';
+
+function render(props: Parameters[0]) {
+ return renderToStaticMarkup(createElement(Badge, props));
+}
+
+describe('Badge', () => {
+ it('renders an intrinsic with measurable width and constant height 26', () => {
+ const svg = render({ label: 'stars', value: '42.3k' });
+ expect(svg).toMatch(/]+width="\d+"/);
+ expect(svg).toMatch(/]+height="26"/);
+ expect(svg).toContain('stars');
+ expect(svg).toContain('42.3k');
+ });
+ it('renders the icon glyph when `icon` is set', () => {
+ const without = render({ label: 'stars', value: '0' });
+ const withIcon = render({ label: 'stars', value: '0', icon: 'star' });
+ expect(withIcon.length).toBeGreaterThan(without.length);
+ });
+ it('uses success color for tone="success" and danger for tone="danger"', () => {
+ const ok = render({ label: 'build', value: 'passing', tone: 'success' });
+ const bad = render({ label: 'build', value: 'failing', tone: 'danger' });
+ expect(ok).toContain('#3a8a3a');
+ expect(bad).toContain('#b13a3a');
+ });
+ it('uses brand palette[0] for tone="neutral"', () => {
+ const svg = render({
+ label: 'x', value: 'y', tone: 'neutral',
+ brand: { palette: ['#123456', '#abcdef'] },
+ });
+ expect(svg).toContain('#123456');
+ });
+ it('produces a stable snapshot for a known input', () => {
+ const svg = render({
+ label: 'stars', value: '42.3k', tone: 'info', icon: 'star',
+ brand: { palette: ['#222', '#0077cc'], font: 'sans-serif' },
+ seed: 1,
+ });
+ expect(svg).toMatchSnapshot();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npm test -- src/components/Badge`
+Expected: FAIL with `cannot find module './Badge'`.
+
+- [ ] **Step 3: Implement `src/components/Badge.tsx`**
+
+Sketch (the implementer fills in the exact Rough.js calls based on existing primitives — see `src/primitives/RoughPath.tsx` and `src/primitives/RoughRectangle.tsx` for the established pattern):
+
+```tsx
+import { createElement } from 'react';
+import { resolveBrand } from '../brand';
+import { resolveVibe } from '../vibe';
+import type { BrandConfig } from '../brand';
+import type { VibeConfig, VibePresetName } from '../vibe';
+import { RoughPath } from '../primitives/RoughPath';
+import { RoughRectangle } from '../primitives/RoughRectangle';
+import { RoughText } from '../primitives/RoughText';
+import { measureTextWidth } from '../core/text';
+import {
+ BADGE_ICON_PATHS,
+ BADGE_TONE_COLORS,
+ type BadgeIcon,
+ type BadgeTone,
+} from '../core/badgeIcons';
+
+const HEIGHT = 26;
+const PAD_X = 8;
+const ICON_SIZE = 16;
+const ICON_GAP = 6;
+const DIVIDER_GAP = 8;
+const DIVIDER_W = 1;
+
+export interface BadgeProps {
+ label: string;
+ value: string;
+ tone?: BadgeTone; // default 'neutral'
+ icon?: BadgeIcon;
+ vibe?: VibeConfig | VibePresetName;
+ brand?: BrandConfig;
+ seed?: number;
+ className?: string;
+}
+
+export function Badge({
+ label, value, tone = 'neutral', icon, vibe, brand, seed, className,
+}: BadgeProps) {
+ const b = resolveBrand(brand);
+ const v = resolveVibe(vibe);
+ const font = b.font;
+ const labelW = measureTextWidth(label, v.fontSize, font);
+ const valueW = measureTextWidth(value, v.fontSize, font);
+ const iconW = icon ? ICON_SIZE + ICON_GAP : 0;
+ const dividerX = PAD_X + iconW + labelW + DIVIDER_GAP;
+ const valueX = dividerX + DIVIDER_W + DIVIDER_GAP;
+ // Round to an integer so downstream regex parsers (row tool) don't need to
+ // handle floats.
+ const width = Math.ceil(valueX + valueW + PAD_X);
+
+ // Tone -> fill
+ const valueFill =
+ tone === 'neutral' ? b.palette[0]
+ : tone === 'info' ? (b.palette[1] ?? b.palette[0])
+ : BADGE_TONE_COLORS[tone];
+ const labelFill = b.ink; // painted at 12% via opacity prop on the rect
+
+ return (
+
+ {/* Pill outline */}
+
+ {/* Label half (ink @ ~12%) */}
+
+ {/* Value half (tone color) */}
+
+ {/* Divider */}
+
+ {/* Optional icon */}
+ {icon ? renderIcon(icon, PAD_X, (HEIGHT - ICON_SIZE) / 2, b.ink, seed) : null}
+ {/* Label text */}
+
+ {/* Value text */}
+
+
+ );
+}
+
+function renderIcon(name: BadgeIcon, ox: number, oy: number, stroke: string, seed?: number) {
+ const entry = BADGE_ICON_PATHS[name];
+ const strokes = Array.isArray(entry) ? entry : [entry];
+ return (
+
+ {strokes.map((d, i) => (
+
+ ))}
+
+ );
+}
+```
+
+If `RoughRectangle` / `RoughPath` / `RoughText` props don't match the names above (e.g. `fillStyle` vs `style`), adjust to the real primitive surface. **Do not** widen the primitive APIs to fit the badge.
+
+- [ ] **Step 4: Run tests**
+
+Run: `npm test -- src/components/Badge`
+Expected: PASS. Snapshot file is written on first run; review the snapshot diff in the next commit.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/Badge.tsx src/components/Badge.test.ts src/components/__snapshots__/Badge.test.ts.snap
+git commit -m "feat(badge): intrinsic-SVG Badge component, brand-aware in body"
+```
+
+---
+
+## Task 3: Library exports
+
+**Files:**
+- Modify: `src/components/index.ts`
+- Modify: `src/index.ts`
+
+- [ ] **Step 1: Add to `src/components/index.ts`**
+
+Append:
+
+```ts
+export { Badge } from './Badge';
+export type { BadgeProps } from './Badge';
+export { BADGE_TONES, BADGE_ICONS } from '../core/badgeIcons';
+export type { BadgeTone, BadgeIcon } from '../core/badgeIcons';
+```
+
+- [ ] **Step 2: Add to `src/index.ts`**
+
+In the existing `export { ... } from './components'` block add `Badge`; in the `export type { ... }` block add `BadgeProps`, `BadgeTone`, `BadgeIcon`.
+
+- [ ] **Step 3: Typecheck and rebuild**
+
+```bash
+npm run typecheck
+npm run build
+npm run check:bundle
+```
+
+Expected: `typecheck` PASS, `build` PASS, `check:bundle` PASS (under 75 KB gzip, no font leak).
+
+If `check:bundle` fails, follow the spec's fallback order: drop `commit`/`lang`/`license` from `BADGE_ICONS` first, re-run.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/components/index.ts src/index.ts
+git commit -m "feat(badge): export Badge + BadgeIcon/BadgeTone from library entries"
+```
+
+---
+
+## Task 4: `githubClient` (no MCP wiring yet)
+
+**Files:**
+- Create: `mcp/src/githubClient.ts`
+- Create: `mcp/src/githubClient.test.ts`
+
+The client is a plain TS module — no React, no MCP types, no environment globals at import time (read `process.env.GITHUB_TOKEN` only inside `createGithubClient`'s body, with a default-argument fallback so tests can override).
+
+- [ ] **Step 1: Write the failing tests**
+
+```ts
+// mcp/src/githubClient.test.ts
+import { describe, it, expect, vi } from 'vitest';
+import { createGithubClient, GithubFetchError } from './githubClient';
+
+function fakeFetch(responses: Record }>) {
+ return vi.fn(async (input: string | URL) => {
+ const url = String(input);
+ const r = responses[url];
+ if (!r) throw new Error(`unmocked: ${url}`);
+ return new Response(r.body == null ? null : JSON.stringify(r.body), {
+ status: r.status,
+ headers: r.headers ?? { 'content-type': 'application/json' },
+ });
+ });
+}
+
+const REPO_URL = 'https://api.github.com/repos/o/r';
+
+describe('githubClient', () => {
+ it('getRepo: parses the upstream shape into RepoSummary', async () => {
+ const fetch = fakeFetch({
+ [REPO_URL]: {
+ status: 200,
+ body: {
+ stargazers_count: 10, forks_count: 2, open_issues_count: 3,
+ license: { spdx_id: 'MIT' }, language: 'TypeScript',
+ pushed_at: '2026-05-01T00:00:00Z', default_branch: 'main',
+ },
+ },
+ });
+ const c = createGithubClient({ fetch });
+ expect(await c.getRepo('o', 'r')).toEqual({
+ stars: 10, forks: 2, openIssues: 3, license: 'MIT',
+ language: 'TypeScript', pushedAt: '2026-05-01T00:00:00Z', defaultBranch: 'main',
+ });
+ });
+
+ it('caches completed responses for TTL', async () => {
+ const fetch = fakeFetch({ [REPO_URL]: { status: 200, body: { stargazers_count: 1 } } });
+ const c = createGithubClient({ fetch, ttlMs: 60_000 });
+ await c.getRepo('o', 'r');
+ await c.getRepo('o', 'r');
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('dedupes in-flight requests', async () => {
+ let resolveHttp!: (v: Response) => void;
+ const fetch = vi.fn(() => new Promise((res) => { resolveHttp = res; }));
+ const c = createGithubClient({ fetch });
+ const p1 = c.getRepo('o', 'r');
+ const p2 = c.getRepo('o', 'r');
+ resolveHttp(new Response(JSON.stringify({ stargazers_count: 7 }), { status: 200 }));
+ await Promise.all([p1, p2]);
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('maps 404 -> not-found, 401 -> unauthorized, 403/429 with rate-limit -> rate-limited', async () => {
+ const c = createGithubClient({
+ fetch: fakeFetch({
+ [REPO_URL]: { status: 404 },
+ 'https://api.github.com/repos/o/r/releases/latest': { status: 401 },
+ 'https://api.github.com/repos/o/r/contributors?per_page=1&anon=1': {
+ status: 403, headers: { 'x-ratelimit-remaining': '0', 'content-type': 'application/json' }, body: {},
+ },
+ }),
+ });
+ await expect(c.getRepo('o', 'r')).rejects.toMatchObject({ kind: 'not-found' });
+ await expect(c.getLatestRelease('o', 'r')).rejects.toMatchObject({ kind: 'unauthorized' });
+ await expect(c.getContributorsCount('o', 'r')).rejects.toMatchObject({ kind: 'rate-limited' });
+ });
+
+ it('sends Authorization header when token is set', async () => {
+ const fetch = vi.fn(async () => new Response(JSON.stringify({ stargazers_count: 1 }), { status: 200 }));
+ const c = createGithubClient({ fetch, token: 'ghp_xxx' });
+ await c.getRepo('o', 'r');
+ const headers = (fetch.mock.calls[0][1] as RequestInit | undefined)?.headers as Record;
+ expect(headers.Authorization).toBe('Bearer ghp_xxx');
+ expect(headers['X-GitHub-Api-Version']).toBe('2022-11-28');
+ });
+
+ it('precedence for ttl: option > env > default', () => {
+ const oldEnv = process.env.GOLDENCHART_GH_TTL_MS;
+ try {
+ process.env.GOLDENCHART_GH_TTL_MS = '1000';
+ const c1 = createGithubClient({});
+ const c2 = createGithubClient({ ttlMs: 5000 });
+ expect((c1 as any).__ttlMs).toBe(1000); // see implementation note below
+ expect((c2 as any).__ttlMs).toBe(5000);
+ } finally {
+ process.env.GOLDENCHART_GH_TTL_MS = oldEnv;
+ }
+ });
+
+ it('parses contributor count from Link header last-page', async () => {
+ const fetch = fakeFetch({
+ 'https://api.github.com/repos/o/r/contributors?per_page=1&anon=1': {
+ status: 200,
+ body: [{}],
+ headers: {
+ 'content-type': 'application/json',
+ link: '; rel="next", ; rel="last"',
+ },
+ },
+ });
+ const c = createGithubClient({ fetch });
+ expect(await c.getContributorsCount('o', 'r')).toBe(137);
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify failure**
+
+Run from `mcp/`: `npm test -- githubClient`
+Expected: FAIL with `cannot find module './githubClient'`.
+
+- [ ] **Step 3: Implement `mcp/src/githubClient.ts`**
+
+Sketch:
+
+```ts
+export type RepoSummary = {
+ stars: number; forks: number; openIssues: number;
+ license: string | null; language: string | null;
+ pushedAt: string; defaultBranch: string;
+};
+export type ReleaseSummary = { tag: string; name: string | null; publishedAt: string };
+export type WorkflowStatus = {
+ name: string;
+ conclusion: 'success' | 'failure' | 'cancelled' | 'neutral' | 'skipped'
+ | 'timed_out' | 'action_required' | 'startup_failure' | 'unknown';
+ status: 'queued' | 'in_progress' | 'completed' | 'unknown';
+ htmlUrl: string;
+};
+
+export type GithubFetchErrorKind =
+ | 'not-found' | 'rate-limited' | 'unauthorized' | 'network' | 'unexpected';
+export class GithubFetchError extends Error {
+ constructor(public kind: GithubFetchErrorKind, public status: number, message: string) {
+ super(message);
+ this.name = 'GithubFetchError';
+ }
+}
+
+export interface GithubClient {
+ getRepo(owner: string, repo: string): Promise;
+ getLatestRelease(owner: string, repo: string): Promise;
+ getWorkflowStatus(owner: string, repo: string, workflow?: string): Promise;
+ getContributorsCount(owner: string, repo: string): Promise;
+}
+
+export interface CreateGithubClientOptions {
+ fetch?: typeof globalThis.fetch;
+ token?: string;
+ ttlMs?: number;
+}
+
+const DEFAULT_TTL_MS = 5 * 60 * 1000;
+
+export function createGithubClient(opts: CreateGithubClientOptions = {}): GithubClient {
+ const fetchImpl = opts.fetch ?? globalThis.fetch;
+ const token = opts.token ?? process.env.GITHUB_TOKEN;
+ const ttlMs = opts.ttlMs ?? Number(process.env.GOLDENCHART_GH_TTL_MS) || DEFAULT_TTL_MS;
+ const completed = new Map();
+ const inflight = new Map>();
+
+ async function call(url: string, parse: (resp: Response) => Promise): Promise {
+ const now = Date.now();
+ const hit = completed.get(url);
+ if (hit && hit.expiresAt > now) return hit.value as T;
+ const existing = inflight.get(url);
+ if (existing) return existing as Promise;
+ const p = (async () => {
+ let resp: Response;
+ try {
+ resp = await fetchImpl(url, {
+ headers: {
+ Accept: 'application/vnd.github+json',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ });
+ } catch (e) {
+ throw new GithubFetchError('network', 0, (e as Error).message);
+ }
+ if (resp.status === 404) throw new GithubFetchError('not-found', 404, url);
+ if (resp.status === 401) throw new GithubFetchError('unauthorized', 401, url);
+ if (resp.status === 403 || resp.status === 429) {
+ if (resp.headers.get('x-ratelimit-remaining') === '0') {
+ throw new GithubFetchError('rate-limited', resp.status, url);
+ }
+ }
+ if (resp.status < 200 || resp.status >= 300) {
+ throw new GithubFetchError('unexpected', resp.status, url);
+ }
+ const value = await parse(resp);
+ completed.set(url, { value, expiresAt: Date.now() + ttlMs });
+ return value;
+ })().finally(() => { inflight.delete(url); });
+ inflight.set(url, p);
+ return p;
+ }
+
+ const api: GithubClient = {
+ getRepo: (o, r) => call(`https://api.github.com/repos/${o}/${r}`, async (resp) => {
+ const j = await resp.json() as any;
+ return {
+ stars: j.stargazers_count, forks: j.forks_count, openIssues: j.open_issues_count,
+ license: j.license?.spdx_id ?? j.license?.name ?? null,
+ language: j.language ?? null,
+ pushedAt: j.pushed_at, defaultBranch: j.default_branch,
+ };
+ }),
+ getLatestRelease: (o, r) => call(`https://api.github.com/repos/${o}/${r}/releases/latest`, async (resp) => {
+ const j = await resp.json() as any;
+ return { tag: j.tag_name, name: j.name ?? null, publishedAt: j.published_at };
+ }),
+ getWorkflowStatus: (o, r, workflow) => {
+ const q = workflow ? `&workflow_id=${encodeURIComponent(workflow)}` : '';
+ return call(`https://api.github.com/repos/${o}/${r}/actions/runs?per_page=1${q}`, async (resp) => {
+ const j = await resp.json() as any;
+ const run = j.workflow_runs?.[0];
+ if (!run) throw new GithubFetchError('not-found', resp.status, 'no runs');
+ return {
+ name: run.name ?? '',
+ conclusion: run.conclusion ?? 'unknown',
+ status: run.status ?? 'unknown',
+ htmlUrl: run.html_url ?? '',
+ };
+ });
+ },
+ getContributorsCount: (o, r) => call(`https://api.github.com/repos/${o}/${r}/contributors?per_page=1&anon=1`, async (resp) => {
+ const link = resp.headers.get('link') ?? '';
+ const m = /<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="last"/.exec(link);
+ if (m) return Number(m[1]);
+ const list = await resp.json() as unknown[];
+ return Array.isArray(list) ? list.length : 0;
+ }),
+ };
+
+ // Test introspection (intentionally non-enumerable so it doesn't appear in JSON):
+ Object.defineProperty(api, '__ttlMs', { value: ttlMs });
+ return api;
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run from `mcp/`: `npm test -- githubClient`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add mcp/src/githubClient.ts mcp/src/githubClient.test.ts
+git commit -m "feat(mcp): GitHub client with TTL cache, in-flight dedup, typed errors"
+```
+
+---
+
+## Task 5: MCP refresh — pull new library exports into `mcp/`
+
+This is the CLAUDE.md-mandated force-recopy step. The remaining tasks depend on `import { Badge } from 'goldenchart'` (and `renderToSVGString` from `goldenchart/server`) resolving in `mcp/`.
+
+**Before Task 3 build:** confirm `src/core/text.ts` does NOT import anything from `src/assets/fonts` — otherwise re-exporting `Badge` from `src/index.ts` will pull font bytes into the browser entry and `check:bundle` will fail. Skim the file:
+
+```powershell
+Select-String -Path src\core\text.ts -Pattern 'assets/fonts'
+```
+
+Expected: no matches. If there are matches, lift the helper into a font-free module before continuing.
+
+- [ ] **Step 1: Rebuild root**
+
+From project root:
+```powershell
+npm run build
+```
+
+- [ ] **Step 2: Force-recopy `goldenchart` into `mcp/node_modules`**
+
+PowerShell:
+```powershell
+Remove-Item -Recurse -Force mcp\node_modules\goldenchart
+cd mcp; npm install --install-links; cd ..
+```
+
+(Plain symlinks are unreliable on Windows; per CLAUDE.md.)
+
+- [ ] **Step 3: Sanity check the new export resolves**
+
+```powershell
+cd mcp; node -e "console.log(typeof require('goldenchart').Badge)"; cd ..
+```
+
+Expected: `function`. (PowerShell `&&` is not available in 5.1; use `;` chaining per CLAUDE.md.)
+
+(No commit; this is environment-only.)
+
+---
+
+## Task 6: `render-badge` MCP tool (no network)
+
+**Files:**
+- Create: `mcp/src/badgeTools.ts`
+- Create: `mcp/src/badgeTools.test.ts`
+
+`makeRenderTool` won't fit — it puts `width`/`height` into `meta` from args, but the badge is intrinsic. Build a fresh `ToolDef`.
+
+- [ ] **Step 1: Add shared imports, helpers, and the first tool to `badgeTools.ts`**
+
+```ts
+import { createElement } from 'react';
+import { z } from 'zod';
+import { renderToSVGString } from 'goldenchart/server';
+import { Badge, BADGE_TONES, BADGE_ICONS } from 'goldenchart';
+import type { ToolDef } from './registry';
+import { renderOutputShape, VibeConfigSchema, BrandConfigSchema } from './schemas';
+import {
+ createGithubClient, GithubFetchError, type GithubClient,
+} from './githubClient';
+
+const ToneEnum = z.enum(BADGE_TONES as unknown as [string, ...string[]]);
+const IconEnum = z.enum(BADGE_ICONS as unknown as [string, ...string[]]);
+
+/**
+ * Module-level seam for tests. Default `null` means "construct a fresh client
+ * via `createGithubClient()` per tool invocation"; tests call
+ * `__setGithubClientForTests(stub)` to inject a stub. Module-level (not arg-
+ * level) so the SDK input validator doesn't strip the seam from `args`.
+ */
+let injectedClient: GithubClient | null = null;
+export function __setGithubClientForTests(c: GithubClient | null) {
+ injectedClient = c;
+}
+function getClient(): GithubClient {
+ return injectedClient ?? createGithubClient();
+}
+
+/** Parse the intrinsic `width="N"` attribute the Badge writes into its root SVG. */
+function parseSvgWidth(svg: string): number {
+ const m = /]*\swidth="(\d+(?:\.\d+)?)"/.exec(svg);
+ return m ? Math.round(Number(m[1])) : 0;
+}
+
+const badgeInputShape = {
+ label: z.string().min(1),
+ value: z.string().min(1),
+ tone: ToneEnum.optional(),
+ icon: IconEnum.optional(),
+ vibe: VibeConfigSchema.optional(),
+ brand: BrandConfigSchema.optional(),
+ seed: z.number().optional(),
+};
+
+export const renderBadgeTool: ToolDef = {
+ name: 'render-badge',
+ config: {
+ title: 'Render a hand-drawn badge',
+ description: 'Renders a GoldenChart Badge (label/value pill) as a self-contained SVG. No network.',
+ inputSchema: badgeInputShape,
+ outputSchema: renderOutputShape,
+ },
+ handler: async (args) => {
+ const svg = renderToSVGString(createElement(Badge as any, args));
+ return {
+ content: [{ type: 'text', text: svg }],
+ structuredContent: { svg, meta: { kind: 'badge', width: parseSvgWidth(svg), height: 26 } },
+ };
+ },
+};
+
+export const badgeTools: ToolDef[] = [renderBadgeTool];
+```
+
+- [ ] **Step 2: Snapshot test**
+
+```ts
+// mcp/src/badgeTools.test.ts
+import { describe, it, expect } from 'vitest';
+import { renderBadgeTool } from './badgeTools';
+
+describe('render-badge', () => {
+ it('produces a stable SVG for a known input', async () => {
+ const res = await renderBadgeTool.handler({
+ label: 'stars', value: '42.3k', tone: 'info', icon: 'star',
+ brand: { palette: ['#222', '#0077cc'], font: 'sans-serif' },
+ seed: 1,
+ });
+ const svg = (res.content[0] as { text: string }).text;
+ expect(svg).toMatchSnapshot();
+ });
+});
+```
+
+- [ ] **Step 3: Run tests**
+
+From `mcp/`: `npm test -- badgeTools`
+Expected: PASS. The snapshot file under `mcp/src/__snapshots__/` is created on first run — review the diff before committing. Font bytes should be masked by `mcp/vitest.setup.ts`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add mcp/src/badgeTools.ts mcp/src/badgeTools.test.ts mcp/src/__snapshots__/badgeTools.test.ts.snap
+git commit -m "feat(mcp): render-badge tool"
+```
+
+---
+
+## Task 7: `render-github-badge` MCP tool (single metric, fetches)
+
+**Files:**
+- Modify: `mcp/src/badgeTools.ts`
+- Modify: `mcp/src/badgeTools.test.ts`
+
+The tool accepts an optional `githubClient` for tests; production callers omit it and get `createGithubClient()` lazily.
+
+- [ ] **Step 1: Tests first**
+
+Append to `badgeTools.test.ts` (full updated import block at the top of the test file):
+
+```ts
+// At the TOP of badgeTools.test.ts (replace the import line from Task 6 Step 2):
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import {
+ renderBadgeTool, renderGithubBadgeTool, __setGithubClientForTests,
+} from './badgeTools';
+import { GithubFetchError, type GithubClient } from './githubClient';
+```
+
+```ts
+// New tests:
+const stubClient = (overrides?: Partial): GithubClient => ({
+ getRepo: async () => ({
+ stars: 12345, forks: 678, openIssues: 0,
+ license: 'MIT', language: 'TypeScript',
+ pushedAt: '2026-05-01T00:00:00Z', defaultBranch: 'main',
+ }),
+ getLatestRelease: async () => ({ tag: 'v1.2.3', name: '1.2.3', publishedAt: '2026-05-01T00:00:00Z' }),
+ getWorkflowStatus: async () => ({ name: 'CI', conclusion: 'success', status: 'completed', htmlUrl: '' }),
+ getContributorsCount: async () => 42,
+ ...overrides,
+});
+
+describe('render-github-badge', () => {
+ afterEach(() => __setGithubClientForTests(null));
+
+ it('renders a stars badge with k-formatted value and info tone', async () => {
+ __setGithubClientForTests(stubClient());
+ const res = await renderGithubBadgeTool.handler({
+ owner: 'o', repo: 'r', metric: 'stars',
+ });
+ const svg = (res.content[0] as { text: string }).text;
+ expect(svg).toContain('12.3k'); // formatCount(12345)
+ expect(svg).toMatchSnapshot();
+ });
+ it('renders a workflow badge as success (green)', async () => {
+ __setGithubClientForTests(stubClient());
+ const res = await renderGithubBadgeTool.handler({
+ owner: 'o', repo: 'r', metric: 'workflow',
+ });
+ const svg = (res.content[0] as { text: string }).text;
+ expect(svg).toContain('#3a8a3a'); // success color
+ });
+ it('reports rate-limited errors as structured tool errors', async () => {
+ __setGithubClientForTests(stubClient({
+ getRepo: async () => { throw new GithubFetchError('rate-limited', 403, 'rate'); },
+ }));
+ const res = await renderGithubBadgeTool.handler({
+ owner: 'o', repo: 'r', metric: 'stars',
+ });
+ expect(res.isError).toBe(true);
+ expect((res.content[0] as { text: string }).text).toContain('rate-limited');
+ });
+});
+```
+
+- [ ] **Step 2: Append tool to `badgeTools.ts`**
+
+> **GitHub API caveat:** `workflow_id` on `/actions/runs` accepts either the
+> numeric workflow ID or the workflow file name (e.g. `ci.yml`), NOT the
+> human-readable display name. Document this in the tool description so an
+> agent passing `workflow: "CI"` (a name) understands they'll get the latest
+> run across any workflow, filtered only if they pass a file name.
+
+```ts
+const MetricEnum = z.enum([
+ 'stars', 'forks', 'open-issues', 'release', 'license',
+ 'last-commit', 'contributors', 'language', 'workflow',
+]);
+
+const githubBadgeInputShape = {
+ owner: z.string().min(1),
+ repo: z.string().min(1),
+ metric: MetricEnum,
+ workflow: z.string().optional(),
+ label: z.string().optional(),
+ tone: ToneEnum.optional(),
+ icon: IconEnum.optional(),
+ vibe: VibeConfigSchema.optional(),
+ brand: BrandConfigSchema.optional(),
+ seed: z.number().optional(),
+};
+
+function formatCount(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`;
+ return String(n);
+}
+function relativeDate(iso: string): string {
+ const days = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 86400_000));
+ if (days < 1) return 'today';
+ if (days < 30) return `${days}d ago`;
+ if (days < 365) return `${Math.round(days / 30)}mo ago`;
+ return `${Math.round(days / 365)}y ago`;
+}
+
+type Resolved = { label: string; value: string; tone: string; icon: string };
+
+async function resolveMetric(
+ client: GithubClient, owner: string, repo: string, metric: string, workflow?: string,
+): Promise {
+ switch (metric) {
+ case 'stars': {
+ const r = await client.getRepo(owner, repo);
+ return { label: 'stars', value: formatCount(r.stars), tone: 'info', icon: 'star' };
+ }
+ case 'forks': {
+ const r = await client.getRepo(owner, repo);
+ return { label: 'forks', value: formatCount(r.forks), tone: 'info', icon: 'fork' };
+ }
+ case 'open-issues': {
+ const r = await client.getRepo(owner, repo);
+ return { label: 'issues', value: formatCount(r.openIssues),
+ tone: r.openIssues > 0 ? 'warn' : 'success', icon: 'issue' };
+ }
+ case 'release': {
+ const rel = await client.getLatestRelease(owner, repo);
+ return { label: 'release', value: rel.tag, tone: 'info', icon: 'tag' };
+ }
+ case 'license': {
+ const r = await client.getRepo(owner, repo);
+ return { label: 'license', value: r.license ?? 'unknown', tone: 'neutral', icon: 'license' };
+ }
+ case 'last-commit': {
+ const r = await client.getRepo(owner, repo);
+ const days = Math.round((Date.now() - new Date(r.pushedAt).getTime()) / 86400_000);
+ const tone = days <= 30 ? 'success' : days <= 365 ? 'warn' : 'danger';
+ return { label: 'last commit', value: relativeDate(r.pushedAt), tone, icon: 'commit' };
+ }
+ case 'contributors': {
+ const n = await client.getContributorsCount(owner, repo);
+ return { label: 'contributors', value: formatCount(n), tone: 'info', icon: 'fork' };
+ }
+ case 'language': {
+ const r = await client.getRepo(owner, repo);
+ return { label: 'lang', value: r.language ?? 'unknown', tone: 'neutral', icon: 'lang' };
+ }
+ case 'workflow': {
+ const w = await client.getWorkflowStatus(owner, repo, workflow);
+ const tone = w.conclusion === 'success' ? 'success' : 'danger';
+ const label = workflow ?? (w.name || 'build');
+ return { label, value: w.conclusion, tone, icon: 'check' };
+ }
+ default:
+ throw new Error(`unknown metric: ${metric}`);
+ }
+}
+
+export const renderGithubBadgeTool: ToolDef = {
+ name: 'render-github-badge',
+ config: {
+ title: 'Render a GitHub repo badge',
+ description:
+ 'Fetches a single metric from GitHub (anonymous or with $GITHUB_TOKEN) and renders it as a hand-drawn Badge SVG.',
+ inputSchema: githubBadgeInputShape,
+ outputSchema: renderOutputShape,
+ },
+ handler: async (args) => {
+ const client = getClient();
+ const { owner, repo, metric, workflow, label, tone, icon, vibe, brand, seed } =
+ args as Record;
+ try {
+ const resolved = await resolveMetric(client, owner, repo, metric, workflow);
+ const props = {
+ label: label ?? resolved.label,
+ value: resolved.value,
+ tone: tone ?? resolved.tone,
+ icon: icon ?? resolved.icon,
+ vibe, brand, seed,
+ };
+ const svg = renderToSVGString(createElement(Badge as any, props));
+ return {
+ content: [{ type: 'text', text: svg }],
+ structuredContent: { svg, meta: { kind: 'github-badge', width: parseSvgWidth(svg), height: 26 } },
+ };
+ } catch (e) {
+ const kind = e instanceof GithubFetchError ? e.kind : 'unexpected';
+ return {
+ content: [{ type: 'text', text: `github-badge error: ${kind}: ${(e as Error).message}` }],
+ structuredContent: { error: { kind, message: (e as Error).message } },
+ isError: true,
+ };
+ }
+ },
+};
+
+badgeTools.push(renderGithubBadgeTool);
+```
+
+`__client` is a documented test seam (prefix `__`; not in the Zod schema; ignored by the SDK validator since it's passed through `args`). If the SDK strips unknown args, swap to importing a `__setGithubClientForTests` setter from `badgeTools.ts` and use that in tests instead.
+
+- [ ] **Step 3: Run tests**
+
+```bash
+cd mcp && npm test -- badgeTools
+```
+
+Expected: PASS. Review the new snapshot lines.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add mcp/src/badgeTools.ts mcp/src/badgeTools.test.ts mcp/src/__snapshots__/badgeTools.test.ts.snap
+git commit -m "feat(mcp): render-github-badge tool with cached, dedup'd fetch"
+```
+
+---
+
+## Task 8: `render-github-badge-row` MCP tool
+
+**Files:**
+- Modify: `mcp/src/badgeTools.ts`
+- Modify: `mcp/src/badgeTools.test.ts`
+
+Reuses the client's in-flight dedup, so calling `resolveMetric` once per metric is fine — duplicate `getRepo` calls collapse to one HTTP roundtrip naturally.
+
+- [ ] **Step 1: Test**
+
+```ts
+// Also add `renderGithubBadgeRowTool` to the imports at the top of the file.
+
+describe('render-github-badge-row', () => {
+ afterEach(() => __setGithubClientForTests(null));
+
+ it('renders a row that triggers exactly one repo call for repo-derived metrics', async () => {
+ const repo = vi.fn(async () => ({
+ stars: 100, forks: 10, openIssues: 0, license: 'MIT',
+ language: 'TS', pushedAt: new Date().toISOString(), defaultBranch: 'main',
+ }));
+ __setGithubClientForTests(stubClient({ getRepo: repo }));
+ const res = await renderGithubBadgeRowTool.handler({
+ owner: 'o', repo: 'r',
+ metrics: ['stars', 'forks', 'open-issues', 'license', 'language'],
+ });
+ expect(repo).toHaveBeenCalledTimes(1);
+ expect((res.content[0] as { text: string }).text).toMatchSnapshot();
+ });
+});
+```
+
+(The single call is guaranteed by the client's in-flight promise map; if `resolveMetric` is called sequentially the cache covers duplicates. Either order satisfies "exactly one HTTP call".)
+
+- [ ] **Step 2: Implement**
+
+```ts
+const githubBadgeRowInputShape = {
+ owner: z.string().min(1),
+ repo: z.string().min(1),
+ metrics: z.array(MetricEnum).min(1).max(8),
+ workflow: z.string().optional(),
+ gap: z.number().int().nonnegative().optional(),
+ vibe: VibeConfigSchema.optional(),
+ brand: BrandConfigSchema.optional(),
+ seed: z.number().optional(),
+};
+
+export const renderGithubBadgeRowTool: ToolDef = {
+ name: 'render-github-badge-row',
+ config: {
+ title: 'Render a row of GitHub repo badges',
+ description:
+ 'Resolves multiple GitHub metrics (with cached + deduplicated fetches) and renders them as a single SVG row of hand-drawn Badges.',
+ inputSchema: githubBadgeRowInputShape,
+ outputSchema: renderOutputShape,
+ },
+ handler: async (args) => {
+ const client = getClient();
+ const { owner, repo, metrics, workflow, gap = 8, vibe, brand, seed } =
+ args as Record;
+ try {
+ const resolved = await Promise.all(
+ (metrics as string[]).map((m) => resolveMetric(client, owner, repo, m, workflow)),
+ );
+ // Render each badge to its own SVG, then strip the outer …
+ // shell and translate the remainder into a parent . We keep the
+ // /stars12.3k"`;
+
+exports[`render-github-badge-row > renders a row that triggers exactly one repo call for repo-derived metrics 1`] = `"stars100forks10issues0licenseMITlangTS"`;
diff --git a/mcp/src/badgeTools.test.ts b/mcp/src/badgeTools.test.ts
new file mode 100644
index 0000000..5142894
--- /dev/null
+++ b/mcp/src/badgeTools.test.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import {
+ renderBadgeTool, renderGithubBadgeTool, renderGithubBadgeRowTool, __setGithubClientForTests,
+} from './badgeTools';
+import { GithubFetchError, type GithubClient } from './githubClient';
+
+describe('render-badge', () => {
+ it('produces a stable SVG for a known input', async () => {
+ const res = await renderBadgeTool.handler({
+ label: 'stars', value: '42.3k', tone: 'info', icon: 'star',
+ brand: { palette: ['#222', '#0077cc'], font: 'sans-serif' },
+ seed: 1,
+ });
+ const svg = (res.content[0] as { text: string }).text;
+ expect(svg).toMatchSnapshot();
+ });
+});
+
+const stubClient = (overrides?: Partial): GithubClient => ({
+ getRepo: async () => ({
+ stars: 12345, forks: 678, openIssues: 0,
+ license: 'MIT', language: 'TypeScript',
+ pushedAt: '2026-05-01T00:00:00Z', defaultBranch: 'main',
+ }),
+ getLatestRelease: async () => ({ tag: 'v1.2.3', name: '1.2.3', publishedAt: '2026-05-01T00:00:00Z' }),
+ getWorkflowStatus: async () => ({ name: 'CI', conclusion: 'success', status: 'completed', htmlUrl: '' }),
+ getContributorsCount: async () => 42,
+ ...overrides,
+});
+
+describe('render-github-badge', () => {
+ afterEach(() => __setGithubClientForTests(null));
+
+ it('renders a stars badge with k-formatted value and info tone', async () => {
+ __setGithubClientForTests(stubClient());
+ const res = await renderGithubBadgeTool.handler({
+ owner: 'o', repo: 'r', metric: 'stars',
+ });
+ const svg = (res.content[0] as { text: string }).text;
+ expect(svg).toContain('12.3k');
+ expect(svg).toMatchSnapshot();
+ });
+ it('renders a workflow badge as success (green)', async () => {
+ __setGithubClientForTests(stubClient());
+ const res = await renderGithubBadgeTool.handler({
+ owner: 'o', repo: 'r', metric: 'workflow',
+ });
+ const svg = (res.content[0] as { text: string }).text;
+ expect(svg).toContain('#3a8a3a');
+ });
+ it('reports rate-limited errors as structured tool errors', async () => {
+ __setGithubClientForTests(stubClient({
+ getRepo: async () => { throw new GithubFetchError('rate-limited', 403, 'rate'); },
+ }));
+ const res = await renderGithubBadgeTool.handler({
+ owner: 'o', repo: 'r', metric: 'stars',
+ });
+ expect(res.isError).toBe(true);
+ expect((res.content[0] as { text: string }).text).toContain('rate-limited');
+ });
+});
+
+describe('render-github-badge-row', () => {
+ afterEach(() => __setGithubClientForTests(null));
+
+ it('renders a row that triggers exactly one repo call for repo-derived metrics', async () => {
+ const repo = vi.fn(async () => ({
+ stars: 100, forks: 10, openIssues: 0, license: 'MIT',
+ language: 'TS', pushedAt: new Date().toISOString(), defaultBranch: 'main',
+ }));
+ __setGithubClientForTests(stubClient({ getRepo: repo }));
+ const res = await renderGithubBadgeRowTool.handler({
+ owner: 'o', repo: 'r',
+ metrics: ['stars', 'forks', 'open-issues', 'license', 'language'],
+ });
+ expect(repo).toHaveBeenCalledTimes(1);
+ expect((res.content[0] as { text: string }).text).toMatchSnapshot();
+ });
+});
diff --git a/mcp/src/badgeTools.ts b/mcp/src/badgeTools.ts
new file mode 100644
index 0000000..dea6b23
--- /dev/null
+++ b/mcp/src/badgeTools.ts
@@ -0,0 +1,275 @@
+import { createElement } from 'react';
+import { z } from 'zod';
+import { renderToSVGString } from 'goldenchart/server';
+import { Badge, BADGE_TONES, BADGE_ICONS } from 'goldenchart';
+import type { ToolDef } from './registry';
+import { renderOutputShape, VibeConfigSchema, BrandConfigSchema } from './schemas';
+import {
+ createGithubClient, GithubFetchError, type GithubClient,
+} from './githubClient';
+
+const ToneEnum = z.enum(BADGE_TONES as unknown as [string, ...string[]]);
+const IconEnum = z.enum(BADGE_ICONS as unknown as [string, ...string[]]);
+
+/**
+ * Module-level seam for tests. Default `null` means "construct a fresh client
+ * via `createGithubClient()` per tool invocation"; tests call
+ * `__setGithubClientForTests(stub)` to inject a stub. Module-level (not arg-
+ * level) so the SDK input validator doesn't strip the seam from `args`.
+ */
+let injectedClient: GithubClient | null = null;
+export function __setGithubClientForTests(c: GithubClient | null) {
+ injectedClient = c;
+}
+function getClient(): GithubClient {
+ return injectedClient ?? createGithubClient();
+}
+
+/** Parse the intrinsic `width="N"` attribute the Badge writes into its root SVG. */
+function parseSvgWidth(svg: string): number {
+ const m = /]*\swidth="(\d+(?:\.\d+)?)"/.exec(svg);
+ return m ? Math.round(Number(m[1])) : 0;
+}
+
+const badgeInputShape = {
+ label: z.string().min(1),
+ value: z.string().min(1),
+ tone: ToneEnum.optional(),
+ icon: IconEnum.optional(),
+ vibe: VibeConfigSchema.optional(),
+ brand: BrandConfigSchema.optional(),
+ seed: z.number().optional(),
+};
+
+export const renderBadgeTool: ToolDef = {
+ name: 'render-badge',
+ config: {
+ title: 'Render a hand-drawn badge',
+ description: 'Renders a GoldenChart Badge (label/value pill) as a self-contained SVG. No network.',
+ inputSchema: badgeInputShape,
+ outputSchema: renderOutputShape,
+ },
+ handler: async (args) => {
+ try {
+ const svg = renderToSVGString(createElement(Badge as any, args));
+ return {
+ content: [{ type: 'text', text: svg }],
+ structuredContent: { svg, meta: { kind: 'badge', width: parseSvgWidth(svg), height: 26 } },
+ };
+ } catch (e) {
+ const message = (e as Error).message ?? 'render failed';
+ return {
+ content: [{ type: 'text', text: `badge error: unexpected: ${message}` }],
+ structuredContent: { error: { kind: 'unexpected', message } },
+ isError: true,
+ };
+ }
+ },
+};
+
+const MetricEnum = z.enum([
+ 'stars', 'forks', 'open-issues', 'release', 'license',
+ 'last-commit', 'contributors', 'language', 'workflow',
+]);
+
+const githubBadgeInputShape = {
+ owner: z.string().min(1),
+ repo: z.string().min(1),
+ metric: MetricEnum,
+ workflow: z.string().optional(),
+ label: z.string().optional(),
+ tone: ToneEnum.optional(),
+ icon: IconEnum.optional(),
+ vibe: VibeConfigSchema.optional(),
+ brand: BrandConfigSchema.optional(),
+ seed: z.number().optional(),
+};
+
+function formatCount(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`;
+ return String(n);
+}
+function relativeDate(iso: string): string {
+ const days = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 86400_000));
+ if (days < 1) return 'today';
+ if (days < 30) return `${days}d ago`;
+ if (days < 365) return `${Math.round(days / 30)}mo ago`;
+ return `${Math.round(days / 365)}y ago`;
+}
+
+type Resolved = { label: string; value: string; tone: string; icon: string };
+
+async function resolveMetric(
+ client: GithubClient, owner: string, repo: string, metric: string, workflow?: string,
+): Promise {
+ switch (metric) {
+ case 'stars': {
+ const r = await client.getRepo(owner, repo);
+ return { label: 'stars', value: formatCount(r.stars), tone: 'info', icon: 'star' };
+ }
+ case 'forks': {
+ const r = await client.getRepo(owner, repo);
+ return { label: 'forks', value: formatCount(r.forks), tone: 'info', icon: 'fork' };
+ }
+ case 'open-issues': {
+ const r = await client.getRepo(owner, repo);
+ return { label: 'issues', value: formatCount(r.openIssues),
+ tone: r.openIssues > 0 ? 'warn' : 'success', icon: 'issue' };
+ }
+ case 'release': {
+ const rel = await client.getLatestRelease(owner, repo);
+ return { label: 'release', value: rel.tag, tone: 'info', icon: 'tag' };
+ }
+ case 'license': {
+ const r = await client.getRepo(owner, repo);
+ return { label: 'license', value: r.license ?? 'unknown', tone: 'neutral', icon: 'license' };
+ }
+ case 'last-commit': {
+ const r = await client.getRepo(owner, repo);
+ const days = Math.round((Date.now() - new Date(r.pushedAt).getTime()) / 86400_000);
+ const tone = days <= 30 ? 'success' : days <= 365 ? 'warn' : 'danger';
+ return { label: 'last commit', value: relativeDate(r.pushedAt), tone, icon: 'commit' };
+ }
+ case 'contributors': {
+ const n = await client.getContributorsCount(owner, repo);
+ return { label: 'contributors', value: formatCount(n), tone: 'info', icon: 'fork' };
+ }
+ case 'language': {
+ const r = await client.getRepo(owner, repo);
+ return { label: 'lang', value: r.language ?? 'unknown', tone: 'neutral', icon: 'lang' };
+ }
+ case 'workflow': {
+ const w = await client.getWorkflowStatus(owner, repo, workflow);
+ const tone = w.conclusion === 'success' ? 'success' : 'danger';
+ const label = workflow ?? (w.name || 'build');
+ return { label, value: w.conclusion, tone, icon: 'check' };
+ }
+ default:
+ throw new Error(`unknown metric: ${metric}`);
+ }
+}
+
+export const renderGithubBadgeTool: ToolDef = {
+ name: 'render-github-badge',
+ config: {
+ title: 'Render a GitHub repo badge',
+ description:
+ 'Fetches a single metric from GitHub (anonymous or with $GITHUB_TOKEN) and renders it as a hand-drawn Badge SVG. Note: the `workflow` arg, when present, is passed to GitHub as `workflow_id`, which accepts the numeric workflow ID or the workflow file name (e.g. `ci.yml`), NOT the human-readable display name; pass a file name to filter, or omit it to get the latest run across any workflow.',
+ inputSchema: githubBadgeInputShape,
+ outputSchema: renderOutputShape,
+ },
+ handler: async (args) => {
+ const client = getClient();
+ const { owner, repo, metric, workflow, label, tone, icon, vibe, brand, seed } =
+ args as Record;
+ try {
+ const resolved = await resolveMetric(client, owner, repo, metric, workflow);
+ const props = {
+ label: label ?? resolved.label,
+ value: resolved.value,
+ tone: tone ?? resolved.tone,
+ icon: icon ?? resolved.icon,
+ vibe, brand, seed,
+ };
+ const svg = renderToSVGString(createElement(Badge as any, props));
+ return {
+ content: [{ type: 'text', text: svg }],
+ structuredContent: { svg, meta: { kind: 'github-badge', width: parseSvgWidth(svg), height: 26 } },
+ };
+ } catch (e) {
+ const kind = e instanceof GithubFetchError ? e.kind : 'unexpected';
+ return {
+ content: [{ type: 'text', text: `github-badge error: ${kind}: ${(e as Error).message}` }],
+ structuredContent: { error: { kind, message: (e as Error).message } },
+ isError: true,
+ };
+ }
+ },
+};
+
+const githubBadgeRowInputShape = {
+ owner: z.string().min(1),
+ repo: z.string().min(1),
+ metrics: z.array(MetricEnum).min(1).max(8),
+ workflow: z.string().optional(),
+ gap: z.number().int().nonnegative().optional(),
+ vibe: VibeConfigSchema.optional(),
+ brand: BrandConfigSchema.optional(),
+ seed: z.number().optional(),
+};
+
+export const renderGithubBadgeRowTool: ToolDef = {
+ name: 'render-github-badge-row',
+ config: {
+ title: 'Render a row of GitHub repo badges',
+ description:
+ 'Resolves multiple GitHub metrics (with cached + deduplicated fetches) and renders them as a single SVG row of hand-drawn Badges.',
+ inputSchema: githubBadgeRowInputShape,
+ outputSchema: renderOutputShape,
+ },
+ handler: async (args) => {
+ const client = getClient();
+ const { owner, repo, metrics, workflow, gap = 8, vibe, brand, seed } =
+ args as Record;
+ // Per-handler in-flight dedup so a stub client (no internal cache) still
+ // collapses duplicate parallel reads. Production clients already dedup,
+ // so this is a cheap belt-and-suspenders wrapper.
+ const inflight = new Map>();
+ const memo = (key: string, fn: () => Promise): Promise => {
+ const hit = inflight.get(key);
+ if (hit) return hit as Promise;
+ const p = fn();
+ inflight.set(key, p);
+ return p;
+ };
+ const memoClient: GithubClient = {
+ getRepo: (o, r) => memo(`repo:${o}/${r}`, () => client.getRepo(o, r)),
+ getLatestRelease: (o, r) => memo(`release:${o}/${r}`, () => client.getLatestRelease(o, r)),
+ getWorkflowStatus: (o, r, w) => memo(`wf:${o}/${r}/${w ?? ''}`, () => client.getWorkflowStatus(o, r, w)),
+ getContributorsCount: (o, r) => memo(`contrib:${o}/${r}`, () => client.getContributorsCount(o, r)),
+ };
+ try {
+ const resolved = await Promise.all(
+ (metrics as string[]).map((m) => resolveMetric(memoClient, owner, repo, m, workflow)),
+ );
+ const parts = resolved.map((r) => renderToSVGString(createElement(Badge as any, {
+ label: r.label, value: r.value, tone: r.tone, icon: r.icon,
+ vibe, brand, seed,
+ })));
+ const widths = parts.map(parseSvgWidth);
+ const inners = parts.map((svg, i) => {
+ let inner = svg.replace(/^]*>/, '').replace(/<\/svg>$/, '');
+ if (i > 0) inner = inner.replace(/