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
2 changes: 2 additions & 0 deletions packages/nimbus-mcp/src/server.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -24,6 +25,7 @@ export function createServer(): McpServer {

// Register all tools
registerGetComponent(server);
registerGetTokens(server);
registerListComponents(server);

return server;
Expand Down
217 changes: 217 additions & 0 deletions packages/nimbus-mcp/src/tools/get-tokens.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void>;

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<void>;

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("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(55);
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<void>;

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");
});
});
146 changes: 146 additions & 0 deletions packages/nimbus-mcp/src/tools/get-tokens.ts
Original file line number Diff line number Diff line change
@@ -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 = 55;
Copy link
Collaborator Author

@valoriecarli valoriecarli Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why 55? size has 54 tokens, while color has 1034.
54 was a weird number.


/** 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 (summarized 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 summarized 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. Only applies when category is provided. Defaults to all tokens for small categories, and 20 for large categories (> 55 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<string, unknown> = {
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,
};
}
}
);
}
Loading