diff --git a/.changeset/add-get-tokens-by-file.md b/.changeset/add-get-tokens-by-file.md new file mode 100644 index 00000000..b317f975 --- /dev/null +++ b/.changeset/add-get-tokens-by-file.md @@ -0,0 +1,5 @@ +--- +"@adobe/spectrum-tokens": minor +--- + +Add `getTokensByFile()` export (tokens grouped by filename; complement to `getAllTokens()`). diff --git a/.changeset/mcp-token-value-query.md b/.changeset/mcp-token-value-query.md new file mode 100644 index 00000000..a9b6ee9e --- /dev/null +++ b/.changeset/mcp-token-value-query.md @@ -0,0 +1,6 @@ +--- +"@adobe/spectrum-design-data-mcp": minor +--- + +Fix data loader to use getTokensByFile/getAllTokens from @adobe/spectrum-tokens. +Add query-tokens-by-value tool (search by direct or resolved alias value). diff --git a/packages/tokens/index.js b/packages/tokens/index.js index 6a1f2e99..51ce111a 100644 --- a/packages/tokens/index.js +++ b/packages/tokens/index.js @@ -12,7 +12,7 @@ governing permissions and limitations under the License. import { glob } from "glob"; -import { resolve } from "path"; +import { basename, resolve } from "path"; import { readFile } from "fs/promises"; import * as url from "url"; import { writeFile } from "fs/promises"; @@ -45,6 +45,17 @@ export const isDeprecated = (token) => export const getFileTokens = async (tokenFileName) => await readJson(resolve(__dirname, "src", tokenFileName)); +export const getTokensByFile = async () => { + const result = {}; + await Promise.all( + tokenFileNames.map(async (filePath) => { + const fileName = basename(filePath); + result[fileName] = await readJson(filePath); + }), + ); + return result; +}; + export const getAllTokens = async () => { return await Promise.all(tokenFileNames.map(getFileTokens)).then( (tokenFileDataAr) => { diff --git a/tools/spectrum-design-data-mcp/README.md b/tools/spectrum-design-data-mcp/README.md index 6a8cb7a6..96f5fb99 100644 --- a/tools/spectrum-design-data-mcp/README.md +++ b/tools/spectrum-design-data-mcp/README.md @@ -54,20 +54,17 @@ The server runs locally and communicates via stdio with MCP-compatible AI client #### Token Tools -* **`query-tokens`**: Search and retrieve design tokens by name, type, or category -* **`find-tokens-by-use-case`** ⭐: Find appropriate tokens for specific component use cases (e.g., "button background", "text color", "error state") -* **`get-component-tokens`** ⭐: Get all tokens related to a specific component type -* **`get-design-recommendations`** ⭐: Get token recommendations for design decisions and component states -* **`get-token-categories`**: List all available token categories +* **`query-tokens`**: Search Spectrum tokens by name, type, or category +* **`query-tokens-by-value`**: Find tokens by direct or resolved value (follows aliases) * **`get-token-details`**: Get detailed information about a specific token +* **`get-component-tokens`**: Get all tokens for a component name #### Schema Tools -* **`query-component-schemas`**: Search component API schemas -* **`get-component-schema`**: Get the complete schema for a specific component -* **`list-components`**: List all available components with schemas -* **`validate-component-props`**: Validate component properties against schemas -* **`get-type-schemas`**: Get type definitions used in schemas +* **`list-components`**: List available components (no schema payload) +* **`get-component-schema`**: Full schema for one component +* **`validate-component-props`**: Validate props against schema +* **`search-components-by-feature`**: Find components by property name ## Configuration @@ -82,7 +79,7 @@ Add to your MCP configuration (e.g., `.cursor/mcp.json` for Cursor IDE): "mcpServers": { "spectrum-design-data": { "command": "npx", - "args": ["@adobe/spectrum-design-data-mcp"] + "args": ["-y", "@adobe/spectrum-design-data-mcp"] } } } @@ -138,55 +135,38 @@ The server uses stdio transport and follows the MCP specification. Refer to your ## Examples -### Component Building Workflow ⭐ +### Component Building Workflow -#### Finding Tokens for Component Use Cases - -``` -Human: I'm building a primary action button. What design tokens should I use for the background color? - -AI: I'll help you find the appropriate tokens for a primary button background. - -[Uses find-tokens-by-use-case with useCase="button background" and componentType="button"] - -For a primary action button background, I recommend these Spectrum tokens: -- accent-color-100: Primary accent color for default state -- accent-color-200: For hover states -- accent-color-300: For pressed/active states -- floating-action-button-drop-shadow-color: For drop shadows -``` - -#### Design Intent Recommendations +#### Component-Specific Token Discovery ``` -Human: What tokens should I use for error messaging in my form validation? +Human: Show me all available button-related design tokens. -AI: I'll find tokens for error state messaging. +AI: I'll gather all button-related tokens from the Spectrum system. -[Uses get-design-recommendations with intent="negative" and context="text"] +[Uses get-component-tokens with componentName="button"] -For error messaging, use these Spectrum tokens: -- negative-color-100: Primary error text color -- negative-background-color-default: Error background areas -- negative-border-color: Error state borders +Found button-related tokens across categories: +- Color tokens: floating-action-button colors +- Layout tokens: button sizing, padding, spacing +- Examples: radio-button-control-size-small, action-button-edge-to-hold-icon... ``` -#### Component-Specific Token Discovery +#### Finding Tokens by Value ``` -Human: Show me all available button-related design tokens. +Human: Which tokens resolve to 1px? -AI: I'll gather all button-related tokens from the Spectrum system. +AI: I'll search for tokens whose value is 1px (direct or alias). -[Uses get-component-tokens with componentName="button"] +[Uses query-tokens-by-value with value="1px"] -Found 57 button-related tokens across categories: -- Color tokens (2): floating-action-button colors -- Layout tokens (55): button sizing, padding, spacing -- Examples: radio-button-control-size-small, action-button-edge-to-hold-icon... +Tokens with value 1px: +- border-width-100 (direct) +- picker-border-width (alias to border-width-100) ``` -### Traditional Token Queries +### Token Queries #### Querying Color Tokens @@ -291,7 +271,7 @@ src/ * Always verify package integrity using `npm audit signatures` * Keep the package updated to the latest version -* Use npx for the most secure and up-to-date execution +* Use `npx -y` for the most secure and up-to-date execution * Report security issues through the [GitHub security advisory](https://github.com/adobe/spectrum-design-data/security/advisories) ## License diff --git a/tools/spectrum-design-data-mcp/src/data/tokens.js b/tools/spectrum-design-data-mcp/src/data/tokens.js index bbec05df..fddca359 100644 --- a/tools/spectrum-design-data-mcp/src/data/tokens.js +++ b/tools/spectrum-design-data-mcp/src/data/tokens.js @@ -10,86 +10,15 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { readFileSync } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; +import { getTokensByFile, getAllTokens } from "@adobe/spectrum-tokens"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +export const getTokenData = getTokensByFile; -/** - * Get token data from the spectrum-tokens package - * @returns {Promise} Token data organized by category - */ -export async function getTokenData() { - try { - // Try to import from the workspace package first - const spectrumTokens = await import("@adobe/spectrum-tokens"); - - // If the package exports token data directly, use it - if (spectrumTokens.default || spectrumTokens.tokens) { - return spectrumTokens.default || spectrumTokens.tokens; - } - - // Otherwise, read the token files directly from the package - return await loadTokenFilesFromPackage(); - } catch (error) { - console.error( - "Failed to load token data from package, trying direct file access:", - error, - ); - return await loadTokenFilesDirectly(); - } -} - -/** - * Load token files from the spectrum-tokens package structure - * @returns {Promise} Token data - */ -async function loadTokenFilesFromPackage() { - // This would be the ideal approach - loading from the actual package - // For now, we'll fall back to direct file access - return await loadTokenFilesDirectly(); -} - -/** - * Load token files directly from the repository structure - * @returns {Promise} Token data - */ -async function loadTokenFilesDirectly() { - const tokenData = {}; - - // Path to the tokens source directory - const tokensPath = join(__dirname, "../../../../packages/tokens/src"); - - // List of token files to load - const tokenFiles = [ - "color-aliases.json", - "color-component.json", - "color-palette.json", - "icons.json", - "layout-component.json", - "layout.json", - "semantic-color-palette.json", - "typography.json", - ]; - - for (const fileName of tokenFiles) { - try { - const filePath = join(tokensPath, fileName); - const fileContent = readFileSync(filePath, "utf8"); - tokenData[fileName] = JSON.parse(fileContent); - } catch (error) { - console.error(`Failed to load token file ${fileName}:`, error); - // Continue loading other files even if one fails - } - } - - return tokenData; -} +/** Flat { tokenName: tokenData } map — used for alias resolution */ +export const getFlatTokenMap = getAllTokens; /** - * Get available token categories + * Get available token categories (filenames without .json) * @returns {Promise>} List of token categories */ export async function getTokenCategories() { diff --git a/tools/spectrum-design-data-mcp/src/index.js b/tools/spectrum-design-data-mcp/src/index.js index eb4f0cfc..3c7aba1c 100644 --- a/tools/spectrum-design-data-mcp/src/index.js +++ b/tools/spectrum-design-data-mcp/src/index.js @@ -66,10 +66,7 @@ export function createMCPServer() { content: [ { type: "text", - text: - typeof result === "string" - ? result - : JSON.stringify(result, null, 2), + text: typeof result === "string" ? result : JSON.stringify(result), }, ], }; diff --git a/tools/spectrum-design-data-mcp/src/tools/schemas.js b/tools/spectrum-design-data-mcp/src/tools/schemas.js index 1e4f2e2f..19d370c7 100644 --- a/tools/spectrum-design-data-mcp/src/tools/schemas.js +++ b/tools/spectrum-design-data-mcp/src/tools/schemas.js @@ -18,81 +18,15 @@ import { getSchemaData } from "../data/schemas.js"; */ export function createSchemaTools() { return [ - { - name: "query-component-schemas", - description: "Search and retrieve Spectrum component API schemas", - inputSchema: { - type: "object", - properties: { - component: { - type: "string", - description: - 'Component name to search for (e.g., "button", "action-button")', - }, - query: { - type: "string", - description: - "Search query to filter schemas (searches component names, descriptions)", - }, - limit: { - type: "number", - description: "Maximum number of schemas to return (default: 20)", - default: 20, - }, - }, - }, - handler: async (args) => { - const { component, query, limit = 20 } = args; - const schemaData = await getSchemaData(); - - let results = []; - - // Search through component schemas - for (const [fileName, schema] of Object.entries( - schemaData.components, - )) { - const componentName = fileName.replace(".json", ""); - - // Apply component filter - if (component && !componentName.includes(component.toLowerCase())) { - continue; - } - - // Apply query filter - if (query && !matchesSchemaQuery(componentName, schema, query)) { - continue; - } - - results.push({ - component: componentName, - fileName, - title: schema.title, - description: schema.description, - properties: Object.keys(schema.properties || {}), - required: schema.required || [], - schema, - }); - } - - // Apply limit - results = results.slice(0, limit); - - return { - total: results.length, - schemas: results, - query: { component, query, limit }, - }; - }, - }, { name: "get-component-schema", - description: "Get the complete schema for a specific component", + description: "Get full JSON schema for one component.", inputSchema: { type: "object", properties: { component: { type: "string", - description: 'Component name (e.g., "action-button")', + description: "Component name", required: true, }, }, @@ -123,29 +57,23 @@ export function createSchemaTools() { }, { name: "list-components", - description: "List all available Spectrum components with schemas", - inputSchema: { - type: "object", - properties: {}, - }, + description: "List all components (names and summary, no full schema).", + inputSchema: { type: "object", properties: {} }, handler: async () => { const schemaData = await getSchemaData(); - const components = Object.keys(schemaData.components).map( (fileName) => { const componentName = fileName.replace(".json", ""); const schema = schemaData.components[fileName]; - - return { + const entry = { name: componentName, - title: schema.title, - description: schema.description, propertyCount: Object.keys(schema.properties || {}).length, - hasRequired: (schema.required || []).length > 0, }; + if (schema.title) entry.title = schema.title; + if (schema.description) entry.description = schema.description; + return entry; }, ); - return { total: components.length, components: components.sort((a, b) => a.name.localeCompare(b.name)), @@ -154,18 +82,18 @@ export function createSchemaTools() { }, { name: "validate-component-props", - description: "Validate component properties against their schema", + description: "Validate props against a component schema.", inputSchema: { type: "object", properties: { component: { type: "string", - description: "Component name to validate against", + description: "Component name", required: true, }, props: { type: "object", - description: "Component properties to validate", + description: "Props to validate", required: true, }, }, @@ -194,149 +122,16 @@ export function createSchemaTools() { }; }, }, - { - name: "get-type-schemas", - description: "Get type definitions used in component schemas", - inputSchema: { - type: "object", - properties: { - type: { - type: "string", - description: 'Specific type to retrieve (e.g., "hex-color")', - }, - }, - }, - handler: async (args) => { - const { type } = args; - const schemaData = await getSchemaData(); - - if (type) { - const typeSchema = schemaData.types[`${type}.json`]; - if (!typeSchema) { - throw new Error(`Type schema not found: ${type}`); - } - - return { - type, - schema: typeSchema, - }; - } - - // Return all types - const types = Object.keys(schemaData.types).map((fileName) => { - const typeName = fileName.replace(".json", ""); - const typeSchema = schemaData.types[fileName]; - - return { - name: typeName, - title: typeSchema.title, - description: typeSchema.description, - type: typeSchema.type, - }; - }); - - return { - total: types.length, - types: types.sort((a, b) => a.name.localeCompare(b.name)), - }; - }, - }, - { - name: "get-component-options", - description: - "Get all available options/properties for a component in a user-friendly format - perfect for discovering what options a component supports", - inputSchema: { - type: "object", - properties: { - component: { - type: "string", - description: - 'Component name (e.g., "action-button", "text-field", "menu")', - required: true, - }, - detailed: { - type: "boolean", - description: - "Include detailed property information like enums, default values, etc.", - default: false, - }, - }, - required: ["component"], - }, - handler: async (args) => { - const { component, detailed = false } = args; - const schemaData = await getSchemaData(); - - const fileName = `${component}.json`; - const schema = schemaData.components[fileName]; - - if (!schema) { - throw new Error( - `Component not found: ${component}. Use list-components to see available components.`, - ); - } - - const componentInfo = { - name: component, - title: schema.title || component, - description: schema.description, - totalProperties: 0, - properties: [], - }; - - if (schema.properties) { - componentInfo.totalProperties = Object.keys(schema.properties).length; - - Object.entries(schema.properties).forEach(([propName, propDef]) => { - const propInfo = { - name: propName, - type: propDef.type || "object", - required: schema.required - ? schema.required.includes(propName) - : false, - description: propDef.description, - }; - - if (detailed) { - // Add detailed information - if (propDef.enum) { - propInfo.possibleValues = propDef.enum; - } - if (propDef.default !== undefined) { - propInfo.defaultValue = propDef.default; - } - if (propDef.properties) { - propInfo.nestedProperties = Object.keys(propDef.properties); - } - if (propDef.$ref) { - propInfo.reference = propDef.$ref; - } - } - - componentInfo.properties.push(propInfo); - }); - - // Sort properties: required first, then alphabetical - componentInfo.properties.sort((a, b) => { - if (a.required !== b.required) return a.required ? -1 : 1; - return a.name.localeCompare(b.name); - }); - } - - return componentInfo; - }, - }, { name: "search-components-by-feature", description: - 'Find components that have specific features or properties (e.g., "size", "disabled", "selected")', + "Find components that have a property matching a name (e.g. size, disabled).", inputSchema: { type: "object", properties: { feature: { type: "string", - description: - 'The feature/property to search for (e.g., "size", "disabled", "icon", "label")', + description: "Property name substring", required: true, }, }, @@ -349,30 +144,27 @@ export function createSchemaTools() { Object.entries(schemaData.components).forEach(([fileName, schema]) => { const componentName = fileName.replace(".json", ""); - if (schema.properties) { const hasFeature = Object.keys(schema.properties).some((prop) => prop.toLowerCase().includes(feature.toLowerCase()), ); - if (hasFeature) { const matchingProps = Object.keys(schema.properties).filter( (prop) => prop.toLowerCase().includes(feature.toLowerCase()), ); - - matchingComponents.push({ + const entry = { name: componentName, - title: schema.title || componentName, - description: schema.description, matchingProperties: matchingProps, totalProperties: Object.keys(schema.properties).length, - }); + }; + if (schema.title) entry.title = schema.title; + if (schema.description) entry.description = schema.description; + matchingComponents.push(entry); } } }); return { - feature, totalMatches: matchingComponents.length, components: matchingComponents.sort((a, b) => a.name.localeCompare(b.name), diff --git a/tools/spectrum-design-data-mcp/src/tools/tokens.js b/tools/spectrum-design-data-mcp/src/tools/tokens.js index 54f5faa0..293c6eb4 100644 --- a/tools/spectrum-design-data-mcp/src/tools/tokens.js +++ b/tools/spectrum-design-data-mcp/src/tools/tokens.js @@ -10,7 +10,7 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { getTokenData } from "../data/tokens.js"; +import { getTokenData, getFlatTokenMap } from "../data/tokens.js"; /** * Create token-related MCP tools @@ -21,27 +21,19 @@ export function createTokenTools() { { name: "query-tokens", description: - "Search and retrieve Spectrum design tokens by name, type, or category", + "Search Spectrum tokens by name, type, or category. Categories: color-aliases, color-component, color-palette, icons, layout, layout-component, semantic-color-palette, typography.", inputSchema: { type: "object", properties: { query: { type: "string", - description: - "Search query to filter tokens (searches names, types, categories)", - }, - category: { - type: "string", - description: - 'Filter by token category (e.g., "color", "layout", "typography")', - }, - type: { - type: "string", - description: 'Filter by token type (e.g., "alias", "component")', + description: "Filter by name, type, or description", }, + category: { type: "string", description: "Filter by category" }, + type: { type: "string", description: "Filter by token type" }, limit: { type: "number", - description: "Maximum number of tokens to return (default: 50)", + description: "Max results (default: 50)", default: 50, }, }, @@ -49,71 +41,120 @@ export function createTokenTools() { handler: async (args) => { const { query, category, type, limit = 50 } = args; const tokenData = await getTokenData(); - let results = []; - - // Search through all token files for (const [fileName, tokens] of Object.entries(tokenData)) { - // Skip if category filter doesn't match if ( category && !fileName.toLowerCase().includes(category.toLowerCase()) ) { continue; } - - // Process tokens based on their structure const processedTokens = processTokens(tokens, fileName, query, type); results.push(...processedTokens); } - - // Apply limit results = results.slice(0, limit); - - return { - total: results.length, - tokens: results, - query: { query, category, type, limit }, - }; + return { total: results.length, tokens: results }; }, }, { - name: "get-token-categories", - description: - "Get all available token categories in the Spectrum design system", + name: "query-tokens-by-value", + description: "Find tokens by direct or resolved value (follows aliases).", inputSchema: { type: "object", - properties: {}, + properties: { + value: { + type: "string", + description: "Value to match (e.g. 1px, #000)", + }, + exact: { + type: "boolean", + description: "Exact match (default: true)", + default: true, + }, + limit: { + type: "number", + description: "Max results (default: 50)", + default: 50, + }, + }, + required: ["value"], }, - handler: async () => { - const tokenData = await getTokenData(); - const categories = Object.keys(tokenData).map((fileName) => { - // Extract category from filename (e.g., "color-palette.json" -> "color-palette") - return fileName.replace(".json", ""); - }); - - return { - categories, - total: categories.length, + handler: async (args) => { + const { value: searchValue, exact = true, limit = 50 } = args; + const [tokenData, flatMap] = await Promise.all([ + getTokenData(), + getFlatTokenMap(), + ]); + const results = []; + const norm = (v) => + v === null || v === undefined ? "" : String(v).toLowerCase(); + const s = norm(searchValue); + + const resolvedValuesToCheck = (resolved) => { + if (resolved === null || resolved === undefined) return []; + if (typeof resolved === "object" && !Array.isArray(resolved)) { + return Object.values(resolved).flatMap(resolvedValuesToCheck); + } + return [resolved]; }; + + for (const [fileName, tokens] of Object.entries(tokenData)) { + const category = fileName.replace(".json", ""); + if (!tokens || typeof tokens !== "object") continue; + + for (const [name, token] of Object.entries(tokens)) { + if (!token || typeof token !== "object") continue; + const directValue = token.value; + const resolved = resolveTokenValue(token, flatMap, new Set(), name); + const toCheck = [directValue, ...resolvedValuesToCheck(resolved)]; + const matched = toCheck.some((v) => { + const n = norm(v); + return exact ? n === s : n.includes(s); + }); + if (!matched) continue; + + const resolvedDisplay = + typeof resolved === "object" && resolved !== null + ? Object.entries(resolved) + .map(([k, v]) => `${k}: ${v}`) + .join(", ") + : resolved; + const directMatches = + directValue !== undefined && + matchesValue(directValue, searchValue, exact); + const matchType = directMatches ? "direct" : "alias"; + + results.push({ + name, + category, + directValue: + directValue !== undefined + ? directValue + : token.sets + ? JSON.stringify(token.sets) + : undefined, + resolvedValue: resolvedDisplay ?? directValue, + matchType, + }); + } + } + + const sliced = results.slice(0, limit); + return { total: sliced.length, tokens: sliced }; }, }, { name: "get-token-details", - description: - "Get detailed information about a specific token by its path", + description: "Get full token data by path (flat token name).", inputSchema: { type: "object", properties: { tokenPath: { type: "string", - description: 'The full path to the token (e.g., "color.blue.100")', + description: "Token path/name", required: true, }, - category: { - type: "string", - description: 'Token category to search in (e.g., "color-palette")', - }, + category: { type: "string", description: "Category to search in" }, }, required: ["tokenPath"], }, @@ -143,136 +184,16 @@ export function createTokenTools() { throw new Error(`Token not found: ${tokenPath}`); }, }, - { - name: "find-tokens-by-use-case", - description: - 'Find appropriate design tokens for specific component use cases (e.g., "button background", "text color", "border", "spacing")', - inputSchema: { - type: "object", - properties: { - useCase: { - type: "string", - description: - 'The use case or purpose (e.g., "button background", "text color", "border", "spacing", "error state")', - }, - componentType: { - type: "string", - description: - 'Optional: Type of component being built (e.g., "button", "input", "card")', - }, - }, - required: ["useCase"], - }, - handler: async ({ useCase, componentType }) => { - const data = await getTokenData(); - const recommendations = []; - - // Smart token recommendations based on use case - const useCaseLower = useCase.toLowerCase(); - const compTypeLower = (componentType || "").toLowerCase(); - - // Define use case mappings - const useCasePatterns = { - background: [ - "color-component", - "semantic-color-palette", - "color-palette", - ], - text: ["color-component", "semantic-color-palette", "typography"], - border: ["color-component", "semantic-color-palette"], - spacing: ["layout", "layout-component"], - padding: ["layout", "layout-component"], - margin: ["layout", "layout-component"], - font: ["typography"], - icon: ["icons", "layout"], - error: ["semantic-color-palette", "color-component"], - success: ["semantic-color-palette", "color-component"], - warning: ["semantic-color-palette", "color-component"], - accent: ["semantic-color-palette", "color-component"], - button: ["color-component", "layout-component"], - input: ["color-component", "layout-component"], - card: ["color-component", "layout-component"], - }; - - // Find relevant categories - const relevantCategories = []; - for (const [pattern, categories] of Object.entries(useCasePatterns)) { - if ( - useCaseLower.includes(pattern) || - compTypeLower.includes(pattern) - ) { - relevantCategories.push(...categories); - } - } - - // If no specific patterns match, search all categories - const categoriesToSearch = - relevantCategories.length > 0 - ? [...new Set(relevantCategories)] - : Object.keys(data); - - // Search within relevant categories - for (const category of categoriesToSearch) { - const filename = category.includes(".json") - ? category - : `${category}.json`; - const tokens = data[filename]; - if (!tokens) continue; - - Object.entries(tokens).forEach(([name, token]) => { - const nameMatch = - name.toLowerCase().includes(useCaseLower) || - (componentType && name.toLowerCase().includes(compTypeLower)); - const descMatch = - token.description && - token.description.toLowerCase().includes(useCaseLower); - - if (nameMatch || descMatch) { - recommendations.push({ - name, - category: filename, - value: token.value, - description: token.description, - schema: token.$schema, - uuid: token.uuid, - private: token.private || false, - deprecated: token.deprecated || false, - deprecated_comment: token.deprecated_comment, - renamed: token.renamed, - relevanceReason: nameMatch ? "name match" : "description match", - }); - } - }); - } - - // Sort by relevance (non-private first, then by name match) - recommendations.sort((a, b) => { - if (a.private !== b.private) return a.private ? 1 : -1; - if (a.relevanceReason !== b.relevanceReason) { - return a.relevanceReason === "name match" ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - - return { - useCase, - componentType, - recommendations: recommendations.slice(0, 20), // Limit to top 20 - totalFound: recommendations.length, - searchedCategories: categoriesToSearch, - }; - }, - }, { name: "get-component-tokens", - description: "Get all tokens related to a specific component type", + description: + "Get tokens whose name contains a component (e.g. button, input).", inputSchema: { type: "object", properties: { componentName: { type: "string", - description: - 'Name of the component (e.g., "button", "input", "card", "modal")', + description: "Component name substring", }, }, required: ["componentName"], @@ -282,29 +203,19 @@ export function createTokenTools() { const componentTokens = []; const componentLower = componentName.toLowerCase(); - // Search through all token categories for component-specific tokens Object.entries(data).forEach(([category, tokens]) => { if (!tokens) return; - Object.entries(tokens).forEach(([name, token]) => { if (name.toLowerCase().includes(componentLower)) { - componentTokens.push({ - name, - category, - value: token.value, - description: token.description, - schema: token.$schema, - uuid: token.uuid, - private: token.private || false, - deprecated: token.deprecated || false, - deprecated_comment: token.deprecated_comment, - renamed: token.renamed, - }); + const entry = { name, category, value: token.value }; + if (token.description) entry.description = token.description; + if (token.deprecated) entry.deprecated = true; + if (token.private) entry.private = true; + componentTokens.push(entry); } }); }); - // Group by category for better organization const groupedTokens = componentTokens.reduce((acc, token) => { if (!acc[token.category]) acc[token.category] = []; acc[token.category].push(token); @@ -312,175 +223,11 @@ export function createTokenTools() { }, {}); return { - componentName, tokensByCategory: groupedTokens, totalTokens: componentTokens.length, }; }, }, - { - name: "get-design-recommendations", - description: - "Get design token recommendations for common design decisions and component states", - inputSchema: { - type: "object", - properties: { - intent: { - type: "string", - description: - 'Design intent (e.g., "primary", "secondary", "accent", "negative", "notice", "positive", "informative")', - }, - state: { - type: "string", - description: - 'Component state (e.g., "default", "hover", "focus", "active", "disabled", "selected")', - }, - context: { - type: "string", - description: - 'Usage context (e.g., "button", "input", "text", "background", "border", "icon")', - }, - }, - required: ["intent"], - }, - handler: async ({ intent, state, context }) => { - const data = await getTokenData(); - const recommendations = { - colors: [], - layout: [], - typography: [], - }; - - const intentLower = intent.toLowerCase(); - const stateLower = (state || "").toLowerCase(); - const contextLower = (context || "").toLowerCase(); - - // Search semantic colors first (these are typically the best recommendations) - const semanticColors = data["semantic-color-palette.json"] || {}; - Object.entries(semanticColors).forEach(([name, token]) => { - const nameLower = name.toLowerCase(); - - // Intent matching - const intentMatch = - nameLower.includes(intentLower) || - (intentLower === "error" && nameLower.includes("negative")) || - (intentLower === "success" && nameLower.includes("positive")) || - (intentLower === "warning" && nameLower.includes("notice")); - - // State matching - const stateMatch = !state || nameLower.includes(stateLower); - - // Context matching - const contextMatch = !context || nameLower.includes(contextLower); - - if (intentMatch && stateMatch && contextMatch) { - recommendations.colors.push({ - name, - value: token.value, - category: "semantic-color-palette", - type: "semantic", - confidence: "high", - }); - } - }); - - // Search component colors if semantic colors don't provide enough options - if (recommendations.colors.length < 3) { - const componentColors = data["color-component.json"] || {}; - Object.entries(componentColors).forEach(([name, token]) => { - const nameLower = name.toLowerCase(); - - // Intent and context matching for component colors - const intentMatch = nameLower.includes(intentLower); - const contextMatch = !context || nameLower.includes(contextLower); - const stateMatch = !state || nameLower.includes(stateLower); - - if ((intentMatch || contextMatch) && stateMatch) { - recommendations.colors.push({ - name, - value: token.value, - category: "color-component", - type: "component", - confidence: "medium", - }); - } - }); - } - - // Layout recommendations if context suggests spacing/sizing - if ( - context && - ["button", "input", "spacing", "padding", "margin"].some((c) => - contextLower.includes(c), - ) - ) { - const layoutComponent = data["layout-component.json"] || {}; - Object.entries(layoutComponent).forEach(([name, token]) => { - const nameLower = name.toLowerCase(); - - if ( - contextLower && - nameLower.includes(contextLower) && - nameLower.includes(stateLower || "size") - ) { - recommendations.layout.push({ - name, - value: token.value, - category: "layout-component", - type: "spacing", - confidence: "high", - }); - } - }); - } - - // Typography recommendations for text contexts - if ( - context && - ["text", "label", "heading", "body"].some((c) => - contextLower.includes(c), - ) - ) { - const typography = data["typography.json"] || {}; - Object.entries(typography).forEach(([name, token]) => { - const nameLower = name.toLowerCase(); - - if (nameLower.includes(contextLower)) { - recommendations.typography.push({ - name, - value: token.value, - category: "typography", - type: "text", - confidence: "high", - }); - } - }); - } - - // Sort by confidence and limit results - ["colors", "layout", "typography"].forEach((category) => { - recommendations[category] = recommendations[category] - .sort((a, b) => { - const confidenceOrder = { high: 0, medium: 1, low: 2 }; - return ( - confidenceOrder[a.confidence] - confidenceOrder[b.confidence] - ); - }) - .slice(0, 10); - }); - - return { - intent, - state, - context, - recommendations, - totalFound: - recommendations.colors.length + - recommendations.layout.length + - recommendations.typography.length, - }; - }, - }, ]; } @@ -515,20 +262,19 @@ function processTokens(tokens, fileName, query, type) { continue; } - results.push({ + const entry = { name: key, - path: currentPath, category, - type: tokenType, value: value.$value || value.value, - description: value.$description || value.description, - extensions: value.$extensions || value.extensions, - uuid: value.uuid, - private: value.private || false, - deprecated: value.deprecated || false, - deprecated_comment: value.deprecated_comment, - renamed: value.renamed, - }); + }; + if (value.$description || value.description) + entry.description = value.$description || value.description; + if (value.deprecated) entry.deprecated = true; + if (value.private) entry.private = true; + if (value.deprecated_comment) + entry.deprecated_comment = value.deprecated_comment; + if (value.renamed) entry.renamed = value.renamed; + results.push(entry); } else { // Recurse into nested objects traverse(value, currentPath); @@ -590,3 +336,66 @@ function matchesQuery(path, token, query) { return false; } + +/** + * Extract token name from alias reference value {token-name} + * @param {string} value - Raw value + * @returns {string|null} Token name or null if not a reference + */ +function extractReference(value) { + if (typeof value !== "string") return null; + const m = value.match(/^\{([^}]+)\}$/); + return m ? m[1] : null; +} + +/** + * Resolve a token's value (follow alias chains, handle sets). Detects cycles. + * @param {Object} token - Token object + * @param {Object} flatMap - Flat map of tokenName -> token + * @param {Set} visited - Token names already visited (cycle detection) + * @param {string} currentName - Current token name (for visited set) + * @returns {string|number|Object|null} Resolved value; for sets, object of setKey -> resolved value + */ +function resolveTokenValue(token, flatMap, visited, currentName) { + if (!token || typeof token !== "object") return null; + if (visited.has(currentName)) return null; + visited.add(currentName); + + if (token.sets && typeof token.sets === "object") { + const out = {}; + for (const [setKey, setToken] of Object.entries(token.sets)) { + const resolved = resolveTokenValue( + setToken, + flatMap, + new Set(visited), + `${currentName}.${setKey}`, + ); + out[setKey] = resolved; + } + return out; + } + + const raw = token.value; + if (raw === undefined) return null; + const ref = extractReference(raw); + if (ref) { + const refToken = flatMap[ref]; + if (!refToken) return null; + return resolveTokenValue(refToken, flatMap, visited, ref); + } + return raw; +} + +/** + * Check if a value matches the search string (exact or contains) + * @param {string|number} value - Token value + * @param {string} searchValue - Search string + * @param {boolean} exact - Whether to require exact match + * @returns {boolean} + */ +function matchesValue(value, searchValue, exact) { + const n = + value === null || value === undefined ? "" : String(value).toLowerCase(); + const s = String(searchValue).toLowerCase(); + return exact ? n === s : n.includes(s); +} diff --git a/tools/spectrum-design-data-mcp/test/tools/schemas.test.js b/tools/spectrum-design-data-mcp/test/tools/schemas.test.js index 47fc9f69..c628e11c 100644 --- a/tools/spectrum-design-data-mcp/test/tools/schemas.test.js +++ b/tools/spectrum-design-data-mcp/test/tools/schemas.test.js @@ -13,10 +13,10 @@ governing permissions and limitations under the License. import test from "ava"; import { createSchemaTools } from "../../src/tools/schemas.js"; -test("createSchemaTools returns array of tools", (t) => { +test("createSchemaTools returns array of 4 tools", (t) => { const tools = createSchemaTools(); t.true(Array.isArray(tools)); - t.true(tools.length > 0); + t.is(tools.length, 4); }); test("schema tools have required properties", (t) => { @@ -30,17 +30,6 @@ test("schema tools have required properties", (t) => { } }); -test("query-component-schemas tool exists", (t) => { - const tools = createSchemaTools(); - const queryTool = tools.find( - (tool) => tool.name === "query-component-schemas", - ); - - t.truthy(queryTool); - t.is(queryTool.name, "query-component-schemas"); - t.true(queryTool.description.includes("Search")); -}); - test("get-component-schema tool exists", (t) => { const tools = createSchemaTools(); const schemaTool = tools.find((tool) => tool.name === "get-component-schema"); @@ -70,10 +59,11 @@ test("validate-component-props tool exists", (t) => { t.true(validateTool.inputSchema.required.includes("props")); }); -test("get-type-schemas tool exists", (t) => { +test("list-components returns non-empty data", async (t) => { const tools = createSchemaTools(); - const typesTool = tools.find((tool) => tool.name === "get-type-schemas"); - - t.truthy(typesTool); - t.is(typesTool.name, "get-type-schemas"); + const listTool = tools.find((tool) => tool.name === "list-components"); + const result = await listTool.handler({}); + t.true(Array.isArray(result.components)); + t.true(result.components.length > 0); + t.is(result.total, result.components.length); }); diff --git a/tools/spectrum-design-data-mcp/test/tools/tokens.test.js b/tools/spectrum-design-data-mcp/test/tools/tokens.test.js index 6f8a7d2f..193e2bd2 100644 --- a/tools/spectrum-design-data-mcp/test/tools/tokens.test.js +++ b/tools/spectrum-design-data-mcp/test/tools/tokens.test.js @@ -13,10 +13,10 @@ governing permissions and limitations under the License. import test from "ava"; import { createTokenTools } from "../../src/tools/tokens.js"; -test("createTokenTools returns array of tools", (t) => { +test("createTokenTools returns array of 4 tools", (t) => { const tools = createTokenTools(); t.true(Array.isArray(tools)); - t.true(tools.length > 0); + t.is(tools.length, 4); }); test("token tools have required properties", (t) => { @@ -39,16 +39,6 @@ test("query-tokens tool exists", (t) => { t.true(queryTool.description.includes("Search")); }); -test("get-token-categories tool exists", (t) => { - const tools = createTokenTools(); - const categoriesTool = tools.find( - (tool) => tool.name === "get-token-categories", - ); - - t.truthy(categoriesTool); - t.is(categoriesTool.name, "get-token-categories"); -}); - test("get-token-details tool exists", (t) => { const tools = createTokenTools(); const detailsTool = tools.find((tool) => tool.name === "get-token-details"); @@ -57,3 +47,71 @@ test("get-token-details tool exists", (t) => { t.is(detailsTool.name, "get-token-details"); t.true(detailsTool.inputSchema.required.includes("tokenPath")); }); + +test("query-tokens-by-value tool exists with value and limit", (t) => { + const tools = createTokenTools(); + const valueTool = tools.find((tool) => tool.name === "query-tokens-by-value"); + + t.truthy(valueTool); + t.is(valueTool.name, "query-tokens-by-value"); + t.true("value" in valueTool.inputSchema.properties); + t.true("limit" in valueTool.inputSchema.properties); + t.true(valueTool.inputSchema.required.includes("value")); +}); + +test("query-tokens returns non-empty results for 'border'", async (t) => { + const tools = createTokenTools(); + const queryTool = tools.find((tool) => tool.name === "query-tokens"); + const result = await queryTool.handler({ query: "border", limit: 10 }); + t.true(Array.isArray(result.tokens)); + t.true(result.tokens.length > 0); + t.is(result.total, result.tokens.length); +}); + +test("query-tokens-by-value respects limit", async (t) => { + const tools = createTokenTools(); + const valueTool = tools.find((tool) => tool.name === "query-tokens-by-value"); + const result = await valueTool.handler({ + value: "px", + exact: false, + limit: 5, + }); + t.true(Array.isArray(result.tokens)); + t.true(result.tokens.length <= 5); + t.is(result.total, result.tokens.length); +}); + +test("query-tokens-by-value '1px' returns border-width-100 with matchType direct", async (t) => { + const tools = createTokenTools(); + const valueTool = tools.find((tool) => tool.name === "query-tokens-by-value"); + const result = await valueTool.handler({ value: "1px" }); + const borderToken = result.tokens.find( + (tok) => tok.name === "border-width-100", + ); + t.truthy(borderToken); + t.is(borderToken.matchType, "direct"); + t.is(borderToken.resolvedValue, "1px"); +}); + +test("query-tokens-by-value '1px' returns picker-border-width with matchType alias", async (t) => { + const tools = createTokenTools(); + const valueTool = tools.find((tool) => tool.name === "query-tokens-by-value"); + const result = await valueTool.handler({ value: "1px" }); + const pickerToken = result.tokens.find( + (tok) => tok.name === "picker-border-width", + ); + t.truthy(pickerToken); + t.is(pickerToken.matchType, "alias"); + t.is(pickerToken.resolvedValue, "1px"); +}); + +test("query-tokens-by-value non-existent value returns empty tokens", async (t) => { + const tools = createTokenTools(); + const valueTool = tools.find((tool) => tool.name === "query-tokens-by-value"); + const result = await valueTool.handler({ + value: "nonexistent-value-xyz-12345", + }); + t.true(Array.isArray(result.tokens)); + t.is(result.tokens.length, 0); + t.is(result.total, 0); +});