@adobe/spectrum-design-data-mcp
Version:
Model Context Protocol server for Spectrum design data including tokens, schemas, and component anatomy
582 lines (525 loc) • 18.9 kB
JavaScript
/*
Copyright 2024 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/
import { getTokenData } from "../data/tokens.js";
/**
* Create token-related MCP tools
* @returns {Array} Array of token tools
*/
export function createTokenTools() {
return [
{
name: "query-tokens",
description:
"Search and retrieve Spectrum design tokens by name, type, or category",
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")',
},
limit: {
type: "number",
description: "Maximum number of tokens to return (default: 50)",
default: 50,
},
},
},
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 },
};
},
},
{
name: "get-token-categories",
description:
"Get all available token categories in the Spectrum design system",
inputSchema: {
type: "object",
properties: {},
},
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,
};
},
},
{
name: "get-token-details",
description:
"Get detailed information about a specific token by its path",
inputSchema: {
type: "object",
properties: {
tokenPath: {
type: "string",
description: 'The full path to the token (e.g., "color.blue.100")',
required: true,
},
category: {
type: "string",
description: 'Token category to search in (e.g., "color-palette")',
},
},
required: ["tokenPath"],
},
handler: async (args) => {
const { tokenPath, category } = args;
const tokenData = await getTokenData();
// Search for the token in all categories or specific category
const categoriesToSearch = category
? [category]
: Object.keys(tokenData);
for (const cat of categoriesToSearch) {
const categoryData = tokenData[cat + ".json"] || tokenData[cat];
if (!categoryData) continue;
const token = findTokenByPath(categoryData, tokenPath);
if (token) {
return {
path: tokenPath,
category: cat,
token,
};
}
}
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,
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",
inputSchema: {
type: "object",
properties: {
componentName: {
type: "string",
description:
'Name of the component (e.g., "button", "input", "card", "modal")',
},
},
required: ["componentName"],
},
handler: async ({ componentName }) => {
const data = await getTokenData();
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,
});
}
});
});
// Group by category for better organization
const groupedTokens = componentTokens.reduce((acc, token) => {
if (!acc[token.category]) acc[token.category] = [];
acc[token.category].push(token);
return acc;
}, {});
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,
};
},
},
];
}
/**
* Process tokens based on search criteria
* @param {Object} tokens - Token data structure
* @param {string} fileName - Name of the token file
* @param {string} query - Search query
* @param {string} type - Type filter
* @returns {Array} Processed tokens
*/
function processTokens(tokens, fileName, query, type) {
const results = [];
const category = fileName.replace(".json", "");
function traverse(obj, path = "") {
for (const [key, value] of Object.entries(obj)) {
const currentPath = path ? `${path}.${key}` : key;
if (value && typeof value === "object") {
if (value.$value !== undefined || value.value !== undefined) {
// This is a token
const tokenType = value.$type || value.type || "unknown";
// Apply type filter
if (type && tokenType !== type) {
continue;
}
// Apply query filter
if (query && !matchesQuery(currentPath, value, query)) {
continue;
}
results.push({
name: key,
path: currentPath,
category,
type: tokenType,
value: value.$value || value.value,
description: value.$description || value.description,
extensions: value.$extensions || value.extensions,
});
} else {
// Recurse into nested objects
traverse(value, currentPath);
}
}
}
}
traverse(tokens);
return results;
}
/**
* Find a token by its path
* @param {Object} tokens - Token data structure
* @param {string} path - Token path (e.g., "color.blue.100")
* @returns {Object|null} Token object or null if not found
*/
function findTokenByPath(tokens, path) {
const parts = path.split(".");
let current = tokens;
for (const part of parts) {
if (current[part] === undefined) {
return null;
}
current = current[part];
}
return current;
}
/**
* Check if a token matches the search query
* @param {string} path - Token path
* @param {Object} token - Token object
* @param {string} query - Search query
* @returns {boolean} Whether the token matches
*/
function matchesQuery(path, token, query) {
const searchText = query.toLowerCase();
// Search in path
if (path.toLowerCase().includes(searchText)) {
return true;
}
// Search in description
const description = token.$description || token.description || "";
if (description.toLowerCase().includes(searchText)) {
return true;
}
// Search in value (for string values)
const value = token.$value || token.value || "";
if (typeof value === "string" && value.toLowerCase().includes(searchText)) {
return true;
}
return false;
}