@webdesignhot/design-md-mcp 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/server.mjs +117 -16
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webdesignhot/design-md-mcp",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "MCP server exposing the webdesignhot.com DESIGN.md catalog (
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "MCP server exposing the webdesignhot.com DESIGN.md catalog (400+ real-brand design systems) to Claude Code, Claude Desktop, Cursor, Codex, Cline, and any other MCP-aware AI agent. Tools: list, get, search, diff, export.",
|
|
5
5
|
"keywords": ["mcp", "model-context-protocol", "design.md", "design-system", "ai-agents", "claude", "claude-code", "codex", "cursor"],
|
|
6
6
|
"author": "webdesignhot",
|
|
7
7
|
"license": "MIT",
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"engines": {
|
|
24
24
|
"node": ">=18"
|
|
25
25
|
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"prepublishOnly": "node -e \"const p=require('./package.json');const e=require('child_process').execSync;let r='0.0.0';try{r=e('npm view '+p.name+' version 2>/dev/null').toString().trim()||'0.0.0'}catch(_){};if(p.version===r){console.error('\\n\\x1b[31m✗ '+p.name+'@'+p.version+' already on npm. Bump version before publishing.\\x1b[0m\\n');process.exit(1)}console.log('→ '+p.name+': '+r+' → '+p.version)\""
|
|
28
|
+
},
|
|
26
29
|
"dependencies": {
|
|
27
30
|
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
28
31
|
}
|
package/src/server.mjs
CHANGED
|
@@ -64,7 +64,7 @@ const TOOLS = [
|
|
|
64
64
|
{
|
|
65
65
|
name: 'search_designs',
|
|
66
66
|
description:
|
|
67
|
-
'Fuzzy-search the catalog by name, tagline, tags, or categories.
|
|
67
|
+
'Fuzzy-search the catalog by name, tagline, tags, or categories. A subsequence match on any of those fields includes an entry; matches are then ranked by relevance — exact name match first, then name prefix, then name substring, then tagline, then tag/category, with weaker subsequence-only hits last — and the top results (default 20, max 50) are returned. Ranking happens before the cap, so the most relevant matches survive it. Use when the user says "something like Linear" or "find me dark editorial designs".',
|
|
68
68
|
inputSchema: {
|
|
69
69
|
type: 'object',
|
|
70
70
|
required: ['query'],
|
|
@@ -77,7 +77,7 @@ const TOOLS = [
|
|
|
77
77
|
{
|
|
78
78
|
name: 'diff_designs',
|
|
79
79
|
description:
|
|
80
|
-
'Token-level diff between two designs. Returns added / removed / modified colors
|
|
80
|
+
'Token-level diff between two designs. Returns `colors` and `radii`, each split into added / removed / modified token maps (the colors map is scoped to the top-level color tokens; themed entries use their default theme). Useful for "what would change if we migrated from Linear to Stripe?" questions.',
|
|
81
81
|
inputSchema: {
|
|
82
82
|
type: 'object',
|
|
83
83
|
required: ['from', 'to'],
|
|
@@ -115,8 +115,10 @@ const TOOLS = [
|
|
|
115
115
|
},
|
|
116
116
|
]
|
|
117
117
|
|
|
118
|
-
function tool(json) {
|
|
119
|
-
|
|
118
|
+
function tool(json, opts) {
|
|
119
|
+
const result = { content: [{ type: 'text', text: typeof json === 'string' ? json : JSON.stringify(json, null, 2) }] }
|
|
120
|
+
if (opts && opts.isError) result.isError = true
|
|
121
|
+
return result
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
function fuzzy(query, hay) {
|
|
@@ -131,16 +133,104 @@ function fuzzy(query, hay) {
|
|
|
131
133
|
return true
|
|
132
134
|
}
|
|
133
135
|
|
|
134
|
-
|
|
136
|
+
// Relevance score for a search hit. Higher = more relevant. Tiers (best→worst):
|
|
137
|
+
// exact name > name prefix > name word-boundary > name substring > tagline
|
|
138
|
+
// substring > tag/category match > subsequence-only. Name/tagline outweigh
|
|
139
|
+
// tags so a true title hit always beats a metadata coincidence. Returns 0 when
|
|
140
|
+
// nothing matches (caller has already gated on fuzzy(), so this is only the
|
|
141
|
+
// ranking signal, never the inclusion filter).
|
|
142
|
+
function scoreEntry(query, e) {
|
|
143
|
+
const q = String(query).toLowerCase().trim()
|
|
144
|
+
if (!q) return 0
|
|
145
|
+
const name = String(e.name ?? '').toLowerCase()
|
|
146
|
+
const tagline = String(e.tagline ?? '').toLowerCase()
|
|
147
|
+
const tags = (e.tags ?? []).map((t) => String(t).toLowerCase())
|
|
148
|
+
const cats = (e.categories ?? []).map((t) => String(t).toLowerCase())
|
|
149
|
+
|
|
150
|
+
let score = 0
|
|
151
|
+
// Name tiers (mutually escalating; take the strongest that applies).
|
|
152
|
+
if (name === q) score += 100
|
|
153
|
+
else if (name.startsWith(q)) score += 70
|
|
154
|
+
else if (new RegExp(`\\b${escapeRe(q)}`).test(name)) score += 55
|
|
155
|
+
else if (name.includes(q)) score += 45
|
|
156
|
+
// Tagline (weighted above tags, below name).
|
|
157
|
+
if (tagline.includes(q)) score += 30
|
|
158
|
+
// Tag / category exact-or-substring match.
|
|
159
|
+
if (tags.some((t) => t === q)) score += 22
|
|
160
|
+
else if (tags.some((t) => t.includes(q))) score += 15
|
|
161
|
+
if (cats.some((c) => c === q)) score += 20
|
|
162
|
+
else if (cats.some((c) => c.includes(q))) score += 13
|
|
163
|
+
// Subsequence-only fallback so fuzzy-but-no-substring hits still rank > 0
|
|
164
|
+
// and shorter (tighter) names edge out longer ones at the same tier.
|
|
165
|
+
if (score === 0) score += 1
|
|
166
|
+
if (name.length) score += Math.max(0, 6 - name.length / 12)
|
|
167
|
+
return score
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function escapeRe(s) {
|
|
171
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const COLOR_RE = /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\))$/
|
|
175
|
+
const RADIUS_RE = /^(\d+(?:\.\d+)?)(px)?$/
|
|
176
|
+
|
|
177
|
+
function stripValue(raw) {
|
|
178
|
+
// Drop trailing `# comment`, surrounding quotes, and whitespace.
|
|
179
|
+
let v = raw.replace(/\s+#.*$/, '').trim()
|
|
180
|
+
const q = v.match(/^(['"])([\s\S]*)\1$/)
|
|
181
|
+
if (q) return q[2].trim()
|
|
182
|
+
return v
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Section-scoped frontmatter parser. Only captures pairs that are DIRECT
|
|
186
|
+
// children of the top-level `colors:` and `radius:` maps — component-level
|
|
187
|
+
// leaf keys (backgroundColor/textColor/…) can never leak in. Themed entries
|
|
188
|
+
// nest colors as `colors:\n <theme>:\n <token>: <hex>`; for those we
|
|
189
|
+
// flatten the FIRST (default) theme's tokens to bare token names.
|
|
190
|
+
function parseTokens(md) {
|
|
135
191
|
const m = md.match(/^---\n([\s\S]*?)\n---/)
|
|
136
|
-
if (!m) return {}
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
192
|
+
if (!m) return { colors: {}, radii: {} }
|
|
193
|
+
const colors = {}
|
|
194
|
+
const radii = {}
|
|
195
|
+
let section = null // current top-level section name
|
|
196
|
+
let firstTheme = null // first/default theme name under a nested `colors:`
|
|
197
|
+
let curTheme = null // theme block we're currently inside
|
|
198
|
+
|
|
199
|
+
for (const raw of m[1].split('\n')) {
|
|
200
|
+
if (!raw.trim() || /^\s*#/.test(raw)) continue // blank or full-line comment
|
|
201
|
+
|
|
202
|
+
const top = raw.match(/^([a-zA-Z][\w-]*):(.*)$/) // column-0 key → new top-level section
|
|
203
|
+
if (top) {
|
|
204
|
+
section = top[1]
|
|
205
|
+
firstTheme = null
|
|
206
|
+
curTheme = null
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const indent = raw.match(/^(\s+)([a-zA-Z][\w-]*):\s*(.*)$/)
|
|
211
|
+
if (!indent) continue
|
|
212
|
+
const depth = indent[1].length
|
|
213
|
+
const key = indent[2]
|
|
214
|
+
const val = stripValue(indent[3])
|
|
215
|
+
|
|
216
|
+
if (section === 'colors') {
|
|
217
|
+
if (depth === 2 && val === '') {
|
|
218
|
+
// Sub-map with no scalar value → a theme block (e.g. `light:`).
|
|
219
|
+
if (firstTheme == null) firstTheme = key // lock onto the first/default theme
|
|
220
|
+
curTheme = key
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
if (firstTheme != null) {
|
|
224
|
+
// Themed: only capture tokens of the first theme (depth === 4).
|
|
225
|
+
if (curTheme === firstTheme && depth === 4 && COLOR_RE.test(val)) colors[key] = val
|
|
226
|
+
} else if (depth === 2 && COLOR_RE.test(val)) {
|
|
227
|
+
colors[key] = val // flat top-level color token
|
|
228
|
+
}
|
|
229
|
+
} else if (section === 'radius') {
|
|
230
|
+
if (depth === 2 && RADIUS_RE.test(val)) radii[key] = val
|
|
231
|
+
}
|
|
142
232
|
}
|
|
143
|
-
return
|
|
233
|
+
return { colors, radii }
|
|
144
234
|
}
|
|
145
235
|
|
|
146
236
|
function diffMaps(a, b) {
|
|
@@ -183,20 +273,31 @@ const HANDLERS = {
|
|
|
183
273
|
async search_designs({ query, limit }) {
|
|
184
274
|
const c = await fetchCatalog()
|
|
185
275
|
const lim = Math.min(limit ?? 20, 50)
|
|
276
|
+
// Inclusion: subsequence match on the combined haystack (unchanged).
|
|
277
|
+
// Ranking: score each survivor, then STABLE-sort by score descending
|
|
278
|
+
// BEFORE slicing so an exact match can never be sliced off by the cap.
|
|
186
279
|
const matches = c.entries
|
|
187
280
|
.filter((e) => {
|
|
188
281
|
const hay = `${e.name} ${e.tagline} ${(e.tags ?? []).join(' ')} ${(e.categories ?? []).join(' ')}`
|
|
189
282
|
return fuzzy(query, hay)
|
|
190
283
|
})
|
|
284
|
+
.map((e, i) => ({ e, i, score: scoreEntry(query, e) }))
|
|
285
|
+
.sort((a, b) => b.score - a.score || a.i - b.i) // stable via original index tiebreak
|
|
191
286
|
.slice(0, lim)
|
|
287
|
+
.map((m) => m.e)
|
|
192
288
|
return tool({ query, count: matches.length, matches: matches.map((e) => ({ slug: e.slug, name: e.name, tagline: e.tagline, tags: e.tags })) })
|
|
193
289
|
},
|
|
194
290
|
|
|
195
291
|
async diff_designs({ from, to }) {
|
|
196
292
|
const [a, b] = await Promise.all([fetchRaw(from), fetchRaw(to)])
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
return tool({
|
|
293
|
+
const ta = parseTokens(a)
|
|
294
|
+
const tb = parseTokens(b)
|
|
295
|
+
return tool({
|
|
296
|
+
from,
|
|
297
|
+
to,
|
|
298
|
+
colors: diffMaps(ta.colors, tb.colors),
|
|
299
|
+
radii: diffMaps(ta.radii, tb.radii),
|
|
300
|
+
})
|
|
200
301
|
},
|
|
201
302
|
|
|
202
303
|
async export_design({ slug, format }) {
|
|
@@ -232,7 +333,7 @@ export async function startServer() {
|
|
|
232
333
|
try {
|
|
233
334
|
return await handler(args ?? {})
|
|
234
335
|
} catch (err) {
|
|
235
|
-
return tool({ error: (err && err.message) || String(err) })
|
|
336
|
+
return tool({ error: (err && err.message) || String(err) }, { isError: true })
|
|
236
337
|
}
|
|
237
338
|
})
|
|
238
339
|
|