@webdesignhot/design-md-mcp 0.1.1 → 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.
Files changed (3) hide show
  1. package/README.md +76 -10
  2. package/package.json +7 -4
  3. package/src/server.mjs +117 -16
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # @webdesignhot/design-md-mcp
2
2
 
3
- > MCP server exposing the [webdesignhot.com](https://www.webdesignhot.com/design.md/) DESIGN.md catalog (254+ real-brand design systems) to Claude Desktop, Claude Code, Cursor, Cline, and any other MCP-aware AI agent.
3
+ > MCP server exposing the [webdesignhot.com](https://www.webdesignhot.com/design.md/) DESIGN.md catalog (285+ real-brand design systems) to Claude Code, Claude Desktop, Cursor, Codex, Cline, and any other MCP-aware AI agent.
4
4
 
5
5
  ## Why
6
6
 
7
- When an AI coding agent picks up a project, it usually has no idea what your visual style is. You can paste tokens into the system prompt, hand it a Figma export, or — best — point it at a single `DESIGN.md` file. This MCP server takes that one step further: agents can browse 254+ real-brand design systems (Linear, Stripe, Vercel, Anthropic, Notion, Apple, Tesla, BMW, …) and install one in your project without you ever leaving the chat.
7
+ When an AI coding agent picks up a project, it usually has no idea what your visual style is. You can paste tokens into the system prompt, hand it a Figma export, or — best — point it at a single `DESIGN.md` file. This MCP server takes that one step further: agents can browse 285+ real-brand design systems (Linear, Stripe, Vercel, Anthropic, Notion, Apple, Tesla, BMW, …) and install one in your project without you ever leaving the chat.
8
8
 
9
9
  ## Tools
10
10
 
@@ -17,9 +17,46 @@ When an AI coding agent picks up a project, it usually has no idea what your vis
17
17
 
18
18
  ## Install
19
19
 
20
- ### Claude Desktop / Claude Code
20
+ ### Claude Code (CLI / IDE extension)
21
21
 
22
- Add to your `~/Library/Application Support/Claude/claude_desktop_config.json` (or the equivalent on Windows / Linux):
22
+ Easiest let the `claude` CLI write the config for you. Pick a scope:
23
+
24
+ ```bash
25
+ # Local (default) — only this project, only you. Written to ~/.claude.json
26
+ # under the project key. Teammates won't see it.
27
+ claude mcp add design-md -- npx -y @webdesignhot/design-md-mcp
28
+
29
+ # Project — committed to the repo's .mcp.json so teammates get it on clone.
30
+ claude mcp add --scope project design-md -- npx -y @webdesignhot/design-md-mcp
31
+
32
+ # User — every project on your machine, written to ~/.claude.json globals.
33
+ claude mcp add --scope user design-md -- npx -y @webdesignhot/design-md-mcp
34
+ ```
35
+
36
+ Verify with `/mcp` inside Claude Code — `design-md` should be listed with 6 tools (`list_designs`, `get_design`, `search_designs`, `diff_designs`, `export_design`, `install_design`).
37
+
38
+ Manual alternative for `--scope project` — drop `.mcp.json` in the repo root:
39
+
40
+ ```json
41
+ {
42
+ "mcpServers": {
43
+ "design-md": {
44
+ "command": "npx",
45
+ "args": ["-y", "@webdesignhot/design-md-mcp"]
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ > **Note:** Claude Code does **not** read `claude_desktop_config.json` — that's Claude Desktop's file. Mixing them up is the #1 wrong path.
52
+
53
+ ### Claude Desktop
54
+
55
+ Different application, different config file. Edit:
56
+
57
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
58
+ - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
59
+ - Linux: `~/.config/Claude/claude_desktop_config.json`
23
60
 
24
61
  ```json
25
62
  {
@@ -32,17 +69,46 @@ Add to your `~/Library/Application Support/Claude/claude_desktop_config.json` (o
32
69
  }
33
70
  ```
34
71
 
72
+ Restart Claude Desktop after editing.
73
+
35
74
  ### Cursor
36
75
 
37
- In Settings Features MCP "Add new MCP server":
76
+ Drop a JSON file at one of two paths same shape as Claude:
77
+
78
+ - Global: `~/.cursor/mcp.json`
79
+ - Project: `.cursor/mcp.json` (in repo root)
80
+
81
+ ```json
82
+ {
83
+ "mcpServers": {
84
+ "design-md": {
85
+ "command": "npx",
86
+ "args": ["-y", "@webdesignhot/design-md-mcp"]
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ Toggle the server on under **Settings → Features → Model Context Protocol** (or the marketplace at [cursor.directory](https://cursor.directory) for one-click adds on community servers).
93
+
94
+ ### Codex (OpenAI CLI / IDE extension)
95
+
96
+ Codex uses **TOML**, not JSON. Edit:
97
+
98
+ - Global: `~/.codex/config.toml`
99
+ - Project: `.codex/config.toml` (for trusted projects only)
100
+
101
+ ```toml
102
+ [mcp_servers.design-md]
103
+ command = "npx"
104
+ args = ["-y", "@webdesignhot/design-md-mcp"]
105
+ ```
38
106
 
39
- - **Name**: `design-md`
40
- - **Type**: `command`
41
- - **Command**: `npx -y @webdesignhot/design-md-mcp`
107
+ The CLI and the IDE extension share this config — set it once, both clients see it.
42
108
 
43
- ### Cline / Roo / etc.
109
+ ### Cline / Roo / Continue / etc.
44
110
 
45
- Any MCP client supporting stdio transports works. After install the binary is `design-md-mcp` (no scope) — run it directly, or invoke via `npx -y @webdesignhot/design-md-mcp`.
111
+ Any MCP client supporting stdio transports works. After install the binary is exposed as `design-md-mcp` — run it directly, or invoke via `npx -y @webdesignhot/design-md-mcp`. Most clients accept the same `{ "command": "npx", "args": ["-y", "@webdesignhot/design-md-mcp"] }` JSON shape Claude uses.
46
112
 
47
113
  ## Use it
48
114
 
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@webdesignhot/design-md-mcp",
3
- "version": "0.1.1",
4
- "description": "MCP server exposing the webdesignhot.com DESIGN.md catalog (254+ real-brand design systems) to Claude Desktop, Claude Code, Cursor, Cline, and any other MCP-aware AI agent. Tools: list, get, search, diff, export.",
5
- "keywords": ["mcp", "model-context-protocol", "design.md", "design-system", "ai-agents", "claude", "cursor"],
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
+ "keywords": ["mcp", "model-context-protocol", "design.md", "design-system", "ai-agents", "claude", "claude-code", "codex", "cursor"],
6
6
  "author": "webdesignhot",
7
7
  "license": "MIT",
8
8
  "homepage": "https://www.webdesignhot.com/design.md/",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "https://github.com/WebDesignHot/design-md"
11
+ "url": "git+https://github.com/WebDesignHot/design-md.git"
12
12
  },
13
13
  "bin": {
14
14
  "design-md-mcp": "bin/design-md-mcp.mjs"
@@ -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. Returns up to 20 matches sorted by relevance. Use when the user says "something like Linear" or "find me dark editorial designs".',
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 and radii. Useful for "what would change if we migrated from Linear to Stripe?" questions.',
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
- return { content: [{ type: 'text', text: typeof json === 'string' ? json : JSON.stringify(json, null, 2) }] }
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
- function parseFrontmatter(md) {
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 out = {}
138
- // Tiny parser — only handles flat color: hex pairs (good enough for diff)
139
- for (const line of m[1].split('\n')) {
140
- const km = line.match(/^\s+([a-zA-Z][\w-]*):\s*['"]?(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\))['"]?/)
141
- if (km) out[km[1]] = km[2]
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 out
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 fa = parseFrontmatter(a)
198
- const fb = parseFrontmatter(b)
199
- return tool({ from, to, colors: diffMaps(fa, fb) })
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