From d353b96de2480d004cd54f0aeea7925786b3e073 Mon Sep 17 00:00:00 2001 From: Valorie Date: Thu, 12 Mar 2026 13:18:58 -0500 Subject: [PATCH 1/4] feat(nimbus-mcp): implement get_tokens tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `get_tokens` MCP tool that provides three modes: - No params: lists all token categories with counts - category param: returns tokens in that category (large categories like color are summarised to 20 tokens by default, controllable via limit param) - value param: reverse-lookup to find which tokens resolve to a given value (e.g. "16px" → spacing.400) Includes 15 behavioural tests covering all acceptance criteria. CRAFT-2135 Co-Authored-By: Claude Sonnet 4.6 --- packages/nimbus-mcp/src/server.ts | 2 + .../nimbus-mcp/src/tools/get-tokens.spec.ts | 217 ++++++++++++++++++ packages/nimbus-mcp/src/tools/get-tokens.ts | 146 ++++++++++++ 3 files changed, 365 insertions(+) create mode 100644 packages/nimbus-mcp/src/tools/get-tokens.spec.ts create mode 100644 packages/nimbus-mcp/src/tools/get-tokens.ts diff --git a/packages/nimbus-mcp/src/server.ts b/packages/nimbus-mcp/src/server.ts index 4286de9a1..762d26237 100644 --- a/packages/nimbus-mcp/src/server.ts +++ b/packages/nimbus-mcp/src/server.ts @@ -1,5 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerGetComponent } from "./tools/get-component.js"; +import { registerGetTokens } from "./tools/get-tokens.js"; import { registerListComponents } from "./tools/list-components.js"; /** @@ -24,6 +25,7 @@ export function createServer(): McpServer { // Register all tools registerGetComponent(server); + registerGetTokens(server); registerListComponents(server); return server; diff --git a/packages/nimbus-mcp/src/tools/get-tokens.spec.ts b/packages/nimbus-mcp/src/tools/get-tokens.spec.ts new file mode 100644 index 000000000..7015bad4b --- /dev/null +++ b/packages/nimbus-mcp/src/tools/get-tokens.spec.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { createTestClient } from "../test-utils.js"; + +/** + * Behavioral tests for the get_tokens tool. + * + * Reads flattened token data from data/tokens.json (populated by the prebuild step). + * Tests assert shapes and minimums, not exact values, to stay resilient to token updates. + */ + +type CategorySummary = { category: string; count: number }; + +type TokenEntry = { + name: string; + value: string; + category: string; + path: string[]; +}; + +type CategoryResponse = { + category: string; + total: number; + showing: number; + tokens: TokenEntry[]; + note?: string; +}; + +type ReverseLookupResponse = { value: string; tokens: string[] }; + +async function callGetTokens( + client: Client, + args: { category?: string; value?: string; limit?: number } = {} +): Promise<{ text: string; isError?: boolean }> { + const result = await client.callTool({ name: "get_tokens", arguments: args }); + const content = result.content as Array<{ type: string; text: string }>; + const text = content.find((c) => c.type === "text")?.text ?? ""; + return { text, isError: result.isError as boolean | undefined }; +} + +describe("get_tokens — no params", () => { + let client: Client; + let close: () => Promise; + + beforeAll(async () => { + const ctx = createTestClient(); + await ctx.connect(); + client = ctx.client; + close = ctx.close; + }); + + afterAll(() => close()); + + it("returns a non-empty list of categories", async () => { + const { text } = await callGetTokens(client); + const categories = JSON.parse(text) as CategorySummary[]; + expect(Array.isArray(categories)).toBe(true); + expect(categories.length).toBeGreaterThan(0); + }); + + it("each entry has category (string) and count (positive integer)", async () => { + const { text } = await callGetTokens(client); + const categories = JSON.parse(text) as CategorySummary[]; + for (const entry of categories) { + expect(typeof entry.category).toBe("string"); + expect(entry.category.length).toBeGreaterThan(0); + expect(typeof entry.count).toBe("number"); + expect(entry.count).toBeGreaterThan(0); + } + }); + + it("includes expected categories: spacing, color, fontSize", async () => { + const { text } = await callGetTokens(client); + const categories = JSON.parse(text) as CategorySummary[]; + const names = categories.map((c) => c.category); + expect(names).toContain("spacing"); + expect(names).toContain("color"); + expect(names).toContain("fontSize"); + }); + + it("results are sorted alphabetically by category", async () => { + const { text } = await callGetTokens(client); + const categories = JSON.parse(text) as CategorySummary[]; + const names = categories.map((c) => c.category); + const sorted = [...names].sort((a, b) => a.localeCompare(b)); + expect(names).toEqual(sorted); + }); +}); + +describe("get_tokens — category param", () => { + let client: Client; + let close: () => Promise; + + beforeAll(async () => { + const ctx = createTestClient(); + await ctx.connect(); + client = ctx.client; + close = ctx.close; + }); + + afterAll(() => close()); + + it("returns spacing tokens", async () => { + const { text } = await callGetTokens(client, { category: "spacing" }); + const response = JSON.parse(text) as CategoryResponse; + expect(response.category).toBe("spacing"); + expect(response.total).toBeGreaterThan(0); + expect(response.showing).toBe(response.total); + expect(Array.isArray(response.tokens)).toBe(true); + expect(response.tokens.length).toBe(response.showing); + }); + + it("spacing.400 resolves to 16px", async () => { + const { text } = await callGetTokens(client, { category: "spacing" }); + const response = JSON.parse(text) as CategoryResponse; + const token400 = response.tokens.find((t) => t.name === "spacing.400"); + expect(token400).toBeDefined(); + expect(token400?.value).toBe("16px"); + }); + + it("each token has required fields", async () => { + const { text } = await callGetTokens(client, { category: "spacing" }); + const response = JSON.parse(text) as CategoryResponse; + for (const token of response.tokens) { + expect(typeof token.name).toBe("string"); + expect(typeof token.value).toBe("string"); + expect(typeof token.category).toBe("string"); + expect(Array.isArray(token.path)).toBe(true); + } + }); + + it("is case-insensitive for category lookup", async () => { + const lower = await callGetTokens(client, { category: "spacing" }); + const upper = await callGetTokens(client, { category: "SPACING" }); + const lowerData = JSON.parse(lower.text) as CategoryResponse; + const upperData = JSON.parse(upper.text) as CategoryResponse; + expect(lowerData.total).toBe(upperData.total); + }); + + it("summarises large categories by default (color has > 50 tokens, shows 20)", async () => { + const { text } = await callGetTokens(client, { category: "color" }); + const response = JSON.parse(text) as CategoryResponse; + expect(response.total).toBeGreaterThan(50); + expect(response.showing).toBe(20); + expect(response.tokens.length).toBe(20); + expect(typeof response.note).toBe("string"); + }); + + it("respects limit param for large categories", async () => { + const { text } = await callGetTokens(client, { + category: "color", + limit: 5, + }); + const response = JSON.parse(text) as CategoryResponse; + expect(response.showing).toBe(5); + expect(response.tokens.length).toBe(5); + }); + + it("limit param can return all tokens from a large category", async () => { + // Get total count first + const summary = await callGetTokens(client, { category: "color" }); + const { total } = JSON.parse(summary.text) as CategoryResponse; + + const { text } = await callGetTokens(client, { + category: "color", + limit: total, + }); + const response = JSON.parse(text) as CategoryResponse; + expect(response.showing).toBe(total); + expect(response.note).toBeUndefined(); + }); + + it("returns isError for an unknown category", async () => { + const result = await callGetTokens(client, { + category: "nonexistent-category", + }); + expect(result.isError).toBe(true); + expect(result.text).toContain("not found"); + }); +}); + +describe("get_tokens — value reverse-lookup", () => { + let client: Client; + let close: () => Promise; + + beforeAll(async () => { + const ctx = createTestClient(); + await ctx.connect(); + client = ctx.client; + close = ctx.close; + }); + + afterAll(() => close()); + + it("resolves 16px to spacing.400", async () => { + const { text } = await callGetTokens(client, { value: "16px" }); + const response = JSON.parse(text) as ReverseLookupResponse; + expect(response.value).toBe("16px"); + expect(Array.isArray(response.tokens)).toBe(true); + expect(response.tokens.some((n) => n === "spacing.400")).toBe(true); + }); + + it("is case-insensitive for hex values", async () => { + const lower = await callGetTokens(client, { value: "#fefdfb" }); + const upper = await callGetTokens(client, { value: "#FEFDFB" }); + const lowerData = JSON.parse(lower.text) as ReverseLookupResponse; + const upperData = JSON.parse(upper.text) as ReverseLookupResponse; + expect(lowerData.tokens.sort()).toEqual(upperData.tokens.sort()); + }); + + it("returns a plain string message for values with no match", async () => { + const { text } = await callGetTokens(client, { + value: "999999px-nonexistent", + }); + expect(text).toContain("No tokens found"); + }); +}); diff --git a/packages/nimbus-mcp/src/tools/get-tokens.ts b/packages/nimbus-mcp/src/tools/get-tokens.ts new file mode 100644 index 000000000..4ee6df361 --- /dev/null +++ b/packages/nimbus-mcp/src/tools/get-tokens.ts @@ -0,0 +1,146 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getFlatTokenData, reverseLookup } from "../data-loader.js"; + +/** Threshold above which a category is considered "large". */ +const LARGE_CATEGORY_THRESHOLD = 50; + +/** Default number of tokens to show for large categories when no limit is specified. */ +const LARGE_CATEGORY_DEFAULT_LIMIT = 20; + +/** + * Registers the `get_tokens` tool on the given MCP server. + * + * - No params: returns all token categories with counts + * - `category` param: returns tokens in that category (summarised for large categories) + * - `value` param: reverse-lookup to find which tokens resolve to that value + */ +export function registerGetTokens(server: McpServer): void { + server.registerTool( + "get_tokens", + { + title: "Get Tokens", + description: + "Returns Nimbus design tokens. " + + "No params: lists all categories with counts. " + + "With category: returns tokens in that category (large categories like color are summarised by default). " + + 'With value: reverse-lookup to find which tokens resolve to that value (e.g. "16px" → spacing.400).', + inputSchema: { + category: z + .string() + .optional() + .describe( + 'Token category to retrieve, e.g. "spacing", "color", "fontSize". Case-insensitive.' + ), + value: z + .string() + .optional() + .describe( + 'Reverse-lookup: find tokens whose resolved value matches this string, e.g. "16px" or "#0969DA". Case-insensitive.' + ), + limit: z + .number() + .int() + .positive() + .optional() + .describe( + "Maximum number of tokens to return for a category. Defaults to all tokens for small categories, and 20 for large categories (> 50 tokens)." + ), + }, + }, + async ({ category, value, limit }) => { + try { + const data = await getFlatTokenData(); + + // value param: reverse-lookup + if (value !== undefined) { + const matches = reverseLookup(data, value); + return { + content: [ + { + type: "text" as const, + text: + matches.length > 0 + ? JSON.stringify({ value, tokens: matches }, null, 2) + : `No tokens found for value "${value}".`, + }, + ], + }; + } + + // category param: list tokens in that category + if (category !== undefined) { + const needle = category.toLowerCase(); + const matchKey = Object.keys(data.byCategory).find( + (k) => k.toLowerCase() === needle + ); + + if (!matchKey) { + const available = Object.keys(data.byCategory).sort().join(", "); + return { + content: [ + { + type: "text" as const, + text: `Category "${category}" not found. Available categories: ${available}`, + }, + ], + isError: true, + }; + } + + const tokens = data.byCategory[matchKey]; + const isLarge = tokens.length > LARGE_CATEGORY_THRESHOLD; + const effectiveLimit = + limit ?? (isLarge ? LARGE_CATEGORY_DEFAULT_LIMIT : undefined); + const displayed = effectiveLimit + ? tokens.slice(0, effectiveLimit) + : tokens; + + const response: Record = { + category: matchKey, + total: tokens.length, + showing: displayed.length, + tokens: displayed, + }; + + if (displayed.length < tokens.length) { + response.note = `Showing ${displayed.length} of ${tokens.length} tokens. Use the limit param to retrieve more.`; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(response, null, 2), + }, + ], + }; + } + + // No params: list all categories with counts + const categories = Object.entries(data.byCategory) + .map(([cat, tokens]) => ({ category: cat, count: tokens.length })) + .sort((a, b) => a.category.localeCompare(b.category)); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(categories, null, 2), + }, + ], + }; + } catch { + return { + content: [ + { + type: "text" as const, + text: "Token data is not available in this environment.", + }, + ], + isError: true, + }; + } + } + ); +} From 90d439688948a0c79d8b962de7183efc48bd64cc Mon Sep 17 00:00:00 2001 From: Valorie Date: Thu, 12 Mar 2026 15:07:34 -0500 Subject: [PATCH 2/4] chore(mcp): update threshhold --- packages/nimbus-mcp/src/tools/get-tokens.spec.ts | 4 ++-- packages/nimbus-mcp/src/tools/get-tokens.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/nimbus-mcp/src/tools/get-tokens.spec.ts b/packages/nimbus-mcp/src/tools/get-tokens.spec.ts index 7015bad4b..c716deca6 100644 --- a/packages/nimbus-mcp/src/tools/get-tokens.spec.ts +++ b/packages/nimbus-mcp/src/tools/get-tokens.spec.ts @@ -137,10 +137,10 @@ describe("get_tokens — category param", () => { expect(lowerData.total).toBe(upperData.total); }); - it("summarises large categories by default (color has > 50 tokens, shows 20)", async () => { + it("summarises large categories by default (color has > 55 tokens, shows 20)", async () => { const { text } = await callGetTokens(client, { category: "color" }); const response = JSON.parse(text) as CategoryResponse; - expect(response.total).toBeGreaterThan(50); + expect(response.total).toBeGreaterThan(55); expect(response.showing).toBe(20); expect(response.tokens.length).toBe(20); expect(typeof response.note).toBe("string"); diff --git a/packages/nimbus-mcp/src/tools/get-tokens.ts b/packages/nimbus-mcp/src/tools/get-tokens.ts index 4ee6df361..667901669 100644 --- a/packages/nimbus-mcp/src/tools/get-tokens.ts +++ b/packages/nimbus-mcp/src/tools/get-tokens.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { getFlatTokenData, reverseLookup } from "../data-loader.js"; /** Threshold above which a category is considered "large". */ -const LARGE_CATEGORY_THRESHOLD = 50; +const LARGE_CATEGORY_THRESHOLD = 55; /** Default number of tokens to show for large categories when no limit is specified. */ const LARGE_CATEGORY_DEFAULT_LIMIT = 20; @@ -12,7 +12,7 @@ const LARGE_CATEGORY_DEFAULT_LIMIT = 20; * Registers the `get_tokens` tool on the given MCP server. * * - No params: returns all token categories with counts - * - `category` param: returns tokens in that category (summarised for large categories) + * - `category` param: returns tokens in that category (summarized for large categories) * - `value` param: reverse-lookup to find which tokens resolve to that value */ export function registerGetTokens(server: McpServer): void { @@ -23,7 +23,7 @@ export function registerGetTokens(server: McpServer): void { description: "Returns Nimbus design tokens. " + "No params: lists all categories with counts. " + - "With category: returns tokens in that category (large categories like color are summarised by default). " + + "With category: returns tokens in that category (large categories like color are summarized by default). " + 'With value: reverse-lookup to find which tokens resolve to that value (e.g. "16px" → spacing.400).', inputSchema: { category: z From 685ee417f18bfb2fb7266e6c5daae39e2ee49a87 Mon Sep 17 00:00:00 2001 From: Valorie Date: Thu, 12 Mar 2026 15:08:52 -0500 Subject: [PATCH 3/4] chore(mcp): update specs helps --- packages/nimbus-mcp/src/tools/get-tokens.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nimbus-mcp/src/tools/get-tokens.spec.ts b/packages/nimbus-mcp/src/tools/get-tokens.spec.ts index c716deca6..6ad68374e 100644 --- a/packages/nimbus-mcp/src/tools/get-tokens.spec.ts +++ b/packages/nimbus-mcp/src/tools/get-tokens.spec.ts @@ -137,10 +137,10 @@ describe("get_tokens — category param", () => { expect(lowerData.total).toBe(upperData.total); }); - it("summarises large categories by default (color has > 55 tokens, shows 20)", async () => { + it("summarizes large categories by default (color has > 50 tokens, shows 20)", async () => { const { text } = await callGetTokens(client, { category: "color" }); const response = JSON.parse(text) as CategoryResponse; - expect(response.total).toBeGreaterThan(55); + expect(response.total).toBeGreaterThan(50); expect(response.showing).toBe(20); expect(response.tokens.length).toBe(20); expect(typeof response.note).toBe("string"); From 4faaa4fc3396b76e821cd69928ba214b6ca2a5e6 Mon Sep 17 00:00:00 2001 From: Valorie Date: Thu, 12 Mar 2026 15:20:38 -0500 Subject: [PATCH 4/4] fix(tests): update category threshold in get_tokens tests to match new large category definition --- packages/nimbus-mcp/src/tools/get-tokens.spec.ts | 4 ++-- packages/nimbus-mcp/src/tools/get-tokens.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nimbus-mcp/src/tools/get-tokens.spec.ts b/packages/nimbus-mcp/src/tools/get-tokens.spec.ts index 6ad68374e..b5bcc6eef 100644 --- a/packages/nimbus-mcp/src/tools/get-tokens.spec.ts +++ b/packages/nimbus-mcp/src/tools/get-tokens.spec.ts @@ -137,10 +137,10 @@ describe("get_tokens — category param", () => { expect(lowerData.total).toBe(upperData.total); }); - it("summarizes large categories by default (color has > 50 tokens, shows 20)", async () => { + it("summarizes large categories by default (color has > 55 tokens, shows 20)", async () => { const { text } = await callGetTokens(client, { category: "color" }); const response = JSON.parse(text) as CategoryResponse; - expect(response.total).toBeGreaterThan(50); + expect(response.total).toBeGreaterThan(55); expect(response.showing).toBe(20); expect(response.tokens.length).toBe(20); expect(typeof response.note).toBe("string"); diff --git a/packages/nimbus-mcp/src/tools/get-tokens.ts b/packages/nimbus-mcp/src/tools/get-tokens.ts index 667901669..29189c381 100644 --- a/packages/nimbus-mcp/src/tools/get-tokens.ts +++ b/packages/nimbus-mcp/src/tools/get-tokens.ts @@ -44,7 +44,7 @@ export function registerGetTokens(server: McpServer): void { .positive() .optional() .describe( - "Maximum number of tokens to return for a category. Defaults to all tokens for small categories, and 20 for large categories (> 50 tokens)." + "Maximum number of tokens to return for a category. Only applies when category is provided. Defaults to all tokens for small categories, and 20 for large categories (> 55 tokens)." ), }, },