minidev-ui-mcp 0.0.0-stage → 0.2.0
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/README.md +120 -2
- package/core.mjs +505 -0
- package/package.json +28 -4
- package/props-parser.mjs +282 -0
- package/server.mjs +50 -0
package/README.md
CHANGED
|
@@ -1,3 +1,121 @@
|
|
|
1
|
-
#
|
|
1
|
+
# minidev-ui-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An MCP server that gives AI coding agents direct access to [MiniDev UI](https://ui.minidev.pro): 500+ free React + Tailwind CSS v4 components on Base UI, shadcn compatible. Your agent can search the library, read a component's source, props and examples, follow the design rules, and install components with one command.
|
|
4
|
+
|
|
5
|
+
No dependencies, no API key. Two ways to run it, same tools:
|
|
6
|
+
|
|
7
|
+
- **Hosted** (nothing to install): `https://ui.minidev.pro/mcp`, Streamable HTTP.
|
|
8
|
+
- **Local** (stdio, Node 18.17+): `npx -y minidev-ui-mcp`, or `node mcp/server.mjs` from a clone of this repo.
|
|
9
|
+
|
|
10
|
+
## Hosted
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# Claude Code
|
|
14
|
+
claude mcp add --transport http minidev-ui https://ui.minidev.pro/mcp
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Cursor (`.cursor/mcp.json`): `{ "mcpServers": { "minidev-ui": { "url": "https://ui.minidev.pro/mcp" } } }`
|
|
18
|
+
VS Code (`.vscode/mcp.json`): `{ "servers": { "minidev-ui": { "type": "http", "url": "https://ui.minidev.pro/mcp" } } }`
|
|
19
|
+
Windsurf: `{ "mcpServers": { "minidev-ui": { "serverUrl": "https://ui.minidev.pro/mcp" } } }`
|
|
20
|
+
Codex (`~/.codex/config.toml`): `[mcp_servers.minidev-ui]` then `url = "https://ui.minidev.pro/mcp"`
|
|
21
|
+
|
|
22
|
+
## Local (stdio)
|
|
23
|
+
|
|
24
|
+
### Claude Code
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
claude mcp add minidev-ui -- npx -y minidev-ui-mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Cursor
|
|
31
|
+
|
|
32
|
+
`.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` for all projects):
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"minidev-ui": {
|
|
38
|
+
"command": "npx",
|
|
39
|
+
"args": ["-y", "minidev-ui-mcp"]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### VS Code (GitHub Copilot agent mode)
|
|
46
|
+
|
|
47
|
+
`.vscode/mcp.json`:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"servers": {
|
|
52
|
+
"minidev-ui": {
|
|
53
|
+
"type": "stdio",
|
|
54
|
+
"command": "npx",
|
|
55
|
+
"args": ["-y", "minidev-ui-mcp"]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Windsurf
|
|
62
|
+
|
|
63
|
+
`~/.codeium/windsurf/mcp_config.json`:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"mcpServers": {
|
|
68
|
+
"minidev-ui": {
|
|
69
|
+
"command": "npx",
|
|
70
|
+
"args": ["-y", "minidev-ui-mcp"]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Codex CLI
|
|
77
|
+
|
|
78
|
+
`~/.codex/config.toml`:
|
|
79
|
+
|
|
80
|
+
```toml
|
|
81
|
+
[mcp_servers.minidev-ui]
|
|
82
|
+
command = "npx"
|
|
83
|
+
args = ["-y", "minidev-ui-mcp"]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Claude Desktop and other clients
|
|
87
|
+
|
|
88
|
+
Any client that runs stdio servers works with command `npx` and args `["-y", "minidev-ui-mcp"]`.
|
|
89
|
+
|
|
90
|
+
## Tools
|
|
91
|
+
|
|
92
|
+
| Tool | What it does |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `search_components` | Fuzzy search by need ("date range picker", "pricing table with yearly toggle"). Typo tolerant. |
|
|
95
|
+
| `list_components` | Every component name by kind (`ui`, `block`, `motion`), or filtered by `category` (forms, charts, billing, ai-chat, ...) and `query`. |
|
|
96
|
+
| `get_component` | Description, install commands, npm and registry dependencies, props, import line, an example and the full source. |
|
|
97
|
+
| `get_example` | The documented usage examples for a component, ready to paste. |
|
|
98
|
+
| `get_design_rules` | Tokens, shadows, radius, motion springs, materials and the five laws. `topic`: all, tokens, materials, motion, laws. |
|
|
99
|
+
| `get_install_instructions` | Setup for `next`, `vite` or `remix` (React Router v7), with `method` `shadcn` (default) or `npm`. |
|
|
100
|
+
|
|
101
|
+
A typical agent run: `search_components` → `get_component` → `npx shadcn@latest add @minidev/<name>` → write the page following `get_design_rules`.
|
|
102
|
+
|
|
103
|
+
## Data and privacy
|
|
104
|
+
|
|
105
|
+
The server reads public files only: the registry at `https://ui.minidev.pro/r/*.json` and examples and design docs from the public GitHub repo. It sends nothing about your project. Responses are cached in memory for the session.
|
|
106
|
+
|
|
107
|
+
| Variable | Default | Meaning |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `MINIDEV_UI_URL` | `https://ui.minidev.pro` | Registry host. |
|
|
110
|
+
| `MINIDEV_UI_SOURCE` | remote, then local | `local` reads only the repo files (when run inside the MiniDev UI repo); `remote` never reads local files. |
|
|
111
|
+
|
|
112
|
+
## Develop
|
|
113
|
+
|
|
114
|
+
From the MiniDev UI repo root:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
node mcp/test.mjs # self-test against public/r
|
|
118
|
+
claude mcp add minidev-ui-dev -- node "$PWD/mcp/server.mjs" # use your working copy
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`props-parser.mjs` is a copy of `tools/props-parser.mjs`; keep them in sync.
|
package/core.mjs
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MiniDev UI MCP core: tools and the JSON-RPC 2.0 dispatcher, transport-free.
|
|
4
|
+
* Used by server.mjs (stdio, npx minidev-ui-mcp) and src/app/mcp/route.ts
|
|
5
|
+
* (Streamable HTTP at https://ui.minidev.pro/mcp).
|
|
6
|
+
*
|
|
7
|
+
* Data: https://ui.minidev.pro/r/*.json (registry), the public GitHub repo
|
|
8
|
+
* (examples, DESIGN.md), with a fallback to local files when this folder
|
|
9
|
+
* sits inside the MiniDev UI repo.
|
|
10
|
+
*
|
|
11
|
+
* Env: MINIDEV_UI_SOURCE=local|remote (default: remote, then local)
|
|
12
|
+
* MINIDEV_UI_URL (default https://ui.minidev.pro)
|
|
13
|
+
*/
|
|
14
|
+
import { extractProps } from "./props-parser.mjs"
|
|
15
|
+
|
|
16
|
+
// Kept equal to package.json "version" (test.mjs asserts it); a JSON import would trip the Next bundler.
|
|
17
|
+
export const VERSION = "0.2.0"
|
|
18
|
+
const env = globalThis.process?.env ?? {}
|
|
19
|
+
const SITE = (env.MINIDEV_UI_URL || "https://ui.minidev.pro").replace(/\/+$/, "")
|
|
20
|
+
const GITHUB = "https://raw.githubusercontent.com/amankaushik481/minidev-ui/main"
|
|
21
|
+
const SOURCE = env.MINIDEV_UI_SOURCE
|
|
22
|
+
|
|
23
|
+
let readLocal = null
|
|
24
|
+
/** stdio server inside the repo passes a (repoRelativePath) => Promise<string> reader; the HTTP endpoint reads remote only. */
|
|
25
|
+
export function setLocalReader(reader) {
|
|
26
|
+
readLocal = reader
|
|
27
|
+
}
|
|
28
|
+
const NAME_RE = /^[a-z0-9-]+$/
|
|
29
|
+
const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"]
|
|
30
|
+
const KIND = { ui: "ui", blocks: "block", premium: "motion" }
|
|
31
|
+
|
|
32
|
+
// ---------- data access ----------
|
|
33
|
+
|
|
34
|
+
const cache = new Map()
|
|
35
|
+
|
|
36
|
+
/** Read a repo-relative file: remote first, local fallback (or as MINIDEV_UI_SOURCE says). Resolves to null when nowhere. */
|
|
37
|
+
function read(local, remote) {
|
|
38
|
+
const key = local
|
|
39
|
+
if (!cache.has(key)) {
|
|
40
|
+
const order = SOURCE === "local" && readLocal ? ["local"] : SOURCE === "remote" || !readLocal ? ["remote"] : ["remote", "local"]
|
|
41
|
+
cache.set(
|
|
42
|
+
key,
|
|
43
|
+
(async () => {
|
|
44
|
+
for (const where of order) {
|
|
45
|
+
try {
|
|
46
|
+
if (where === "local") return await readLocal(local)
|
|
47
|
+
const res = await fetch(remote, { signal: AbortSignal.timeout(10_000), headers: { "user-agent": `minidev-ui-mcp/${VERSION}` } })
|
|
48
|
+
if (res.ok) return await res.text()
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
cache.delete(key) // let a later call retry after a network blip
|
|
52
|
+
return null
|
|
53
|
+
})(),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
return cache.get(key)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const readItemJson = async (name) => {
|
|
60
|
+
const t = await read(`public/r/${name}.json`, `${SITE}/r/${name}.json`)
|
|
61
|
+
return t && JSON.parse(t)
|
|
62
|
+
}
|
|
63
|
+
const readRepo = (rel) => read(rel, `${GITHUB}/${rel}`)
|
|
64
|
+
|
|
65
|
+
/** Hand-written category and search phrases per component, from src/content/component-seo. */
|
|
66
|
+
async function seoMeta() {
|
|
67
|
+
const index = await readRepo("src/content/component-seo/index.ts")
|
|
68
|
+
const out = {}
|
|
69
|
+
if (!index) return out
|
|
70
|
+
const parts = [...index.matchAll(/from "\.\/(part-\d+)"/g)].map((m) => m[1])
|
|
71
|
+
for (const src of await Promise.all(parts.map((p) => readRepo(`src/content/component-seo/${p}.ts`)))) {
|
|
72
|
+
for (const m of (src ?? "").matchAll(/"([a-z0-9-]+)":\s*\{\s*description:\s*"(?:[^"\\]|\\.)*",\s*category:\s*"([a-z-]+)",\s*keywords:\s*\[([^\]]*)\]/g)) {
|
|
73
|
+
out[m[1]] = { category: m[2], keywords: [...m[3].matchAll(/"([^"]*)"/g)].map((k) => k[1]) }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let itemsPromise
|
|
80
|
+
/** All registry items with name, title, kind, category, keywords. */
|
|
81
|
+
function items() {
|
|
82
|
+
itemsPromise ??= (async () => {
|
|
83
|
+
const [text, seo] = await Promise.all([read("public/r/registry.json", `${SITE}/r/registry.json`), seoMeta().catch(() => ({}))])
|
|
84
|
+
if (!text) throw new Error(`Could not load the MiniDev UI registry from ${SITE}/r/registry.json. Check your network.`)
|
|
85
|
+
return JSON.parse(text).items.map((it) => ({
|
|
86
|
+
name: it.name,
|
|
87
|
+
title: it.title,
|
|
88
|
+
description: it.description ?? "",
|
|
89
|
+
kind: KIND[it.files?.[0]?.path?.match(/registry\/(ui|blocks|premium)\//)?.[1]] ?? "ui",
|
|
90
|
+
category: it.categories?.[0] ?? seo[it.name]?.category,
|
|
91
|
+
keywords: seo[it.name]?.keywords ?? [],
|
|
92
|
+
dependencies: it.dependencies ?? [],
|
|
93
|
+
registryDependencies: (it.registryDependencies ?? []).map(depName),
|
|
94
|
+
}))
|
|
95
|
+
})().catch((e) => {
|
|
96
|
+
itemsPromise = undefined
|
|
97
|
+
throw e
|
|
98
|
+
})
|
|
99
|
+
return itemsPromise
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const depName = (d) => d.match(/\/r\/([a-z0-9-]+)\.json$/)?.[1] ?? d
|
|
103
|
+
const npmDir = (kind) => ({ ui: "ui", block: "blocks", motion: "premium" })[kind]
|
|
104
|
+
|
|
105
|
+
/** Exported component names, same rule as tools/build-registry.mjs. */
|
|
106
|
+
function exportsOf(src, name) {
|
|
107
|
+
const all = [
|
|
108
|
+
...[...src.matchAll(/export\s*\{([^}]*)\}/g)].flatMap((m) => m[1].split(",").map((x) => x.trim().split(/\s+as\s+/).pop())),
|
|
109
|
+
...[...src.matchAll(/export\s+(?:function|const)\s+(\w+)/g)].map((m) => m[1]),
|
|
110
|
+
].filter((x) => x && /^[A-Z]/.test(x))
|
|
111
|
+
const want = name.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join("")
|
|
112
|
+
const main = all.includes(want) ? want : (all.find((e) => e.startsWith(want)) ?? all[0] ?? want)
|
|
113
|
+
return { all: [...new Set(all)], main }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Examples from src/registry/examples/<name>.tsx: [{ title, description, code }]. */
|
|
117
|
+
async function examplesOf(name) {
|
|
118
|
+
const src = await readRepo(`src/registry/examples/${name}.tsx`)
|
|
119
|
+
if (!src) return []
|
|
120
|
+
const un = (s) => s.replace(/\\([`$\\"])/g, "$1")
|
|
121
|
+
return [...src.matchAll(/title:\s*"((?:[^"\\]|\\.)*)",\s*(?:description:\s*"((?:[^"\\]|\\.)*)",\s*)?code:\s*`((?:[^`\\]|\\.)*)`/g)].map((m) => ({
|
|
122
|
+
title: un(m[1]),
|
|
123
|
+
description: m[2] ? un(m[2]) : "",
|
|
124
|
+
code: un(m[3]).replace(/@\/registry\/(?:ui|blocks|premium)\//g, "@/components/ui/"),
|
|
125
|
+
}))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------- search ----------
|
|
129
|
+
|
|
130
|
+
function lev(a, b) {
|
|
131
|
+
const d = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
132
|
+
for (let i = 1; i <= a.length; i++) {
|
|
133
|
+
let prev = d[0]
|
|
134
|
+
d[0] = i
|
|
135
|
+
for (let j = 1; j <= b.length; j++) {
|
|
136
|
+
const tmp = d[j]
|
|
137
|
+
d[j] = Math.min(d[j] + 1, d[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
138
|
+
prev = tmp
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return d[b.length]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Returns [termsMatched, score]. Name hits weigh most, then title/keywords, then description; typos within 1-2 edits count. */
|
|
145
|
+
function score(it, query) {
|
|
146
|
+
const terms = query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
|
|
147
|
+
const nameParts = it.name.split("-")
|
|
148
|
+
const hay = `${it.title} ${it.keywords.join(" ")} ${it.category ?? ""} ${it.kind}`.toLowerCase()
|
|
149
|
+
const desc = it.description.toLowerCase()
|
|
150
|
+
const words = [...new Set(`${hay} ${desc} ${nameParts.join(" ")}`.split(/[^a-z0-9]+/).filter((w) => w.length > 2))]
|
|
151
|
+
let matched = 0
|
|
152
|
+
let s = it.name === terms.join("-") ? 30 : 0
|
|
153
|
+
for (const raw of terms) {
|
|
154
|
+
const t = raw.length > 3 && raw.endsWith("s") ? raw.slice(0, -1) : raw
|
|
155
|
+
let hit = 0
|
|
156
|
+
if (nameParts.includes(t) || nameParts.includes(raw)) hit = 8
|
|
157
|
+
else if (it.name.includes(t)) hit = 5
|
|
158
|
+
else if (hay.includes(t)) hit = 3
|
|
159
|
+
else if (desc.includes(t)) hit = 1
|
|
160
|
+
else if (t.length >= 4 && words.some((w) => Math.abs(w.length - t.length) <= 2 && lev(w, t) <= (t.length > 6 ? 2 : 1))) hit = 1
|
|
161
|
+
if (hit) matched++
|
|
162
|
+
s += hit
|
|
163
|
+
}
|
|
164
|
+
return [matched, s]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------- tools ----------
|
|
168
|
+
|
|
169
|
+
function arg(args, key, { required = false, pattern, max = 200, values } = {}) {
|
|
170
|
+
const v = args?.[key]
|
|
171
|
+
if (v === undefined || v === null || v === "") {
|
|
172
|
+
if (required) throw new ToolError(`"${key}" is required.`)
|
|
173
|
+
return undefined
|
|
174
|
+
}
|
|
175
|
+
if (typeof v !== "string" || v.length > max) throw new ToolError(`"${key}" must be a string of at most ${max} characters.`)
|
|
176
|
+
const s = v.trim().toLowerCase()
|
|
177
|
+
if (pattern && !pattern.test(s)) throw new ToolError(`"${key}" must match ${pattern} (lowercase letters, digits and hyphens), got "${v}".`)
|
|
178
|
+
if (values && !values.includes(s)) throw new ToolError(`"${key}" must be one of: ${values.join(", ")}.`)
|
|
179
|
+
return s
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
class ToolError extends Error {}
|
|
183
|
+
|
|
184
|
+
async function findItem(name) {
|
|
185
|
+
const all = await items()
|
|
186
|
+
const it = all.find((i) => i.name === name)
|
|
187
|
+
if (it) return it
|
|
188
|
+
const close = all
|
|
189
|
+
.map((i) => [i.name, i.name.includes(name) ? 0 : lev(i.name, name)])
|
|
190
|
+
.filter(([, d]) => d <= Math.max(2, Math.floor(name.length / 3)))
|
|
191
|
+
.sort((a, b) => a[1] - b[1])
|
|
192
|
+
.slice(0, 5)
|
|
193
|
+
.map(([n]) => n)
|
|
194
|
+
throw new ToolError(`No component named "${name}".${close.length ? ` Did you mean: ${close.join(", ")}?` : ""} Use search_components to find one.`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const line = (it) => `- ${it.name} (${it.kind}${it.category ? `, ${it.category}` : ""}): ${it.description}`
|
|
198
|
+
|
|
199
|
+
const TOOLS = [
|
|
200
|
+
{
|
|
201
|
+
name: "list_components",
|
|
202
|
+
title: "List MiniDev UI components",
|
|
203
|
+
description:
|
|
204
|
+
"List MiniDev UI components. With no filter, returns every name grouped by kind (ui, block, motion) plus the category names. Filter by `category` (a kind like ui, block, motion, or a category like forms, charts, billing, ai-chat) and/or a substring `query` to get one-line descriptions.",
|
|
205
|
+
inputSchema: {
|
|
206
|
+
type: "object",
|
|
207
|
+
properties: {
|
|
208
|
+
category: { type: "string", description: "Kind (ui, block, motion) or category (forms, buttons, data-tables, charts, navigation, overlays, feedback, ai-chat, auth, billing, settings, dashboard, marketing, animation, ecommerce, developer-tools, email, layout, media, workflow)." },
|
|
209
|
+
query: { type: "string", description: "Case-insensitive substring matched against name, title and description." },
|
|
210
|
+
},
|
|
211
|
+
additionalProperties: false,
|
|
212
|
+
},
|
|
213
|
+
async run(args) {
|
|
214
|
+
const category = arg(args, "category", { pattern: NAME_RE, max: 64 })
|
|
215
|
+
const query = arg(args, "query")
|
|
216
|
+
const all = await items()
|
|
217
|
+
if (!category && !query) {
|
|
218
|
+
const cats = [...new Set(all.map((i) => i.category).filter(Boolean))].sort()
|
|
219
|
+
const groups = ["ui", "block", "motion"].map((k) => {
|
|
220
|
+
const names = all.filter((i) => i.kind === k).map((i) => i.name)
|
|
221
|
+
return `## ${k} (${names.length})\n${names.join(", ")}`
|
|
222
|
+
})
|
|
223
|
+
return `${all.length} MiniDev UI components.\n\n${groups.join("\n\n")}\n\nCategories: ${cats.join(", ") || "(unavailable)"}\n\nNext: search_components for a need, or get_component for code.`
|
|
224
|
+
}
|
|
225
|
+
const hits = all.filter(
|
|
226
|
+
(i) => (!category || i.kind === category || i.category === category) && (!query || `${i.name} ${i.title} ${i.description}`.toLowerCase().includes(query)),
|
|
227
|
+
)
|
|
228
|
+
if (!hits.length) return `No components match${category ? ` category "${category}"` : ""}${query ? ` query "${query}"` : ""}. Try search_components for fuzzy matching.`
|
|
229
|
+
return `${hits.length} components:\n${hits.map(line).join("\n")}`
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "search_components",
|
|
234
|
+
title: "Search MiniDev UI components",
|
|
235
|
+
description:
|
|
236
|
+
'Fuzzy search MiniDev UI components by what you need ("date range picker", "pricing table with yearly toggle", "chat input"). Ranks by name, title, keywords and description and tolerates typos. Returns the best matches with install commands.',
|
|
237
|
+
inputSchema: {
|
|
238
|
+
type: "object",
|
|
239
|
+
properties: {
|
|
240
|
+
query: { type: "string", description: "What you are looking for, in plain words." },
|
|
241
|
+
limit: { type: "integer", minimum: 1, maximum: 50, default: 10 },
|
|
242
|
+
},
|
|
243
|
+
required: ["query"],
|
|
244
|
+
additionalProperties: false,
|
|
245
|
+
},
|
|
246
|
+
async run(args) {
|
|
247
|
+
const query = arg(args, "query", { required: true })
|
|
248
|
+
const limit = Number.isInteger(args?.limit) ? Math.min(Math.max(args.limit, 1), 50) : 10
|
|
249
|
+
const ranked = (await items())
|
|
250
|
+
.map((i) => [i, score(i, query)])
|
|
251
|
+
.filter(([, [m]]) => m > 0)
|
|
252
|
+
.sort((a, b) => b[1][0] - a[1][0] || b[1][1] - a[1][1] || a[0].name.length - b[0].name.length)
|
|
253
|
+
.slice(0, limit)
|
|
254
|
+
if (!ranked.length) return `No components match "${query}". Try fewer or broader words, or list_components.`
|
|
255
|
+
return `Top ${ranked.length} for "${query}":\n${ranked.map(([i]) => `${line(i)}\n install: npx shadcn@latest add @minidev/${i.name}`).join("\n")}\n\nNext: get_component with the name for source, props and usage.`
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
name: "get_component",
|
|
260
|
+
title: "Get a MiniDev UI component",
|
|
261
|
+
description:
|
|
262
|
+
"Get everything needed to use one MiniDev UI component: description, install commands (shadcn CLI and npm), npm and registry dependencies, props, the import line, an example, and the full source file.",
|
|
263
|
+
inputSchema: {
|
|
264
|
+
type: "object",
|
|
265
|
+
properties: { name: { type: "string", pattern: "^[a-z0-9-]+$", description: 'Registry name, e.g. "button", "data-table", "pricing-plans".' } },
|
|
266
|
+
required: ["name"],
|
|
267
|
+
additionalProperties: false,
|
|
268
|
+
},
|
|
269
|
+
async run(args) {
|
|
270
|
+
const name = arg(args, "name", { required: true, pattern: NAME_RE, max: 64 })
|
|
271
|
+
const it = await findItem(name)
|
|
272
|
+
const json = await readItemJson(name)
|
|
273
|
+
if (!json) throw new ToolError(`Could not load ${SITE}/r/${name}.json. Check your network.`)
|
|
274
|
+
const src = json.files?.[0]?.content ?? ""
|
|
275
|
+
const { all, main } = exportsOf(src, name)
|
|
276
|
+
const props = json.meta?.props ?? safe(() => extractProps(src, main))
|
|
277
|
+
const [example] = await examplesOf(name)
|
|
278
|
+
const regDeps = (json.registryDependencies ?? []).map(depName)
|
|
279
|
+
const out = [
|
|
280
|
+
`# ${main} (${name})`,
|
|
281
|
+
it.description,
|
|
282
|
+
`Kind: ${it.kind}${it.category ? ` · Category: ${it.category}` : ""} · Docs: ${SITE}/docs/${name}`,
|
|
283
|
+
`## Install`,
|
|
284
|
+
"```bash",
|
|
285
|
+
`npx shadcn@latest add @minidev/${name}`,
|
|
286
|
+
`# or: npx shadcn@latest add ${SITE}/r/${name}.json`,
|
|
287
|
+
`# or the npm package (all components): npm i minidev-ui-kit`,
|
|
288
|
+
"```",
|
|
289
|
+
`Writes \`${json.files?.[0]?.target ?? `components/ui/${name}.tsx`}\`. Needs the MiniDev tokens stylesheet once per app (see get_install_instructions).`,
|
|
290
|
+
`npm dependencies: ${(json.dependencies ?? []).join(", ") || "none"}`,
|
|
291
|
+
`Registry dependencies (installed automatically by the shadcn CLI): ${regDeps.join(", ") || "none"}`,
|
|
292
|
+
`## Import`,
|
|
293
|
+
"```tsx",
|
|
294
|
+
`import { ${all.join(", ") || main} } from "@/components/ui/${name}"`,
|
|
295
|
+
`// npm package: import { ${main} } from "minidev-ui-kit/${npmDir(it.kind)}/${name}"`,
|
|
296
|
+
"```",
|
|
297
|
+
]
|
|
298
|
+
if (props?.props?.length || props?.extends?.length) {
|
|
299
|
+
out.push(`## Props of <${main}>`)
|
|
300
|
+
for (const p of props.props ?? []) out.push(`- \`${p.name}${p.optional ? "?" : ""}: ${p.type}\`${p.default !== undefined ? ` (default \`${p.default}\`)` : ""}${p.doc ? `: ${p.doc}` : ""}`)
|
|
301
|
+
if (props.extends?.length) out.push(`- Also accepts all ${props.extends.join(", ")}.`)
|
|
302
|
+
}
|
|
303
|
+
if (example) out.push(`## Example: ${example.title}`, example.description, "```tsx", example.code, "```", "More: get_example.")
|
|
304
|
+
out.push(`## Source (${json.files?.[0]?.target ?? name})`, "```tsx", src.trimEnd(), "```")
|
|
305
|
+
return out.filter((l) => l !== "").join("\n")
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: "get_example",
|
|
310
|
+
title: "Get MiniDev UI usage examples",
|
|
311
|
+
description: "Get the documented usage examples (JSX you can paste) for a MiniDev UI component, each with a title and one-line explanation.",
|
|
312
|
+
inputSchema: {
|
|
313
|
+
type: "object",
|
|
314
|
+
properties: { name: { type: "string", pattern: "^[a-z0-9-]+$", description: "Registry name of the component." } },
|
|
315
|
+
required: ["name"],
|
|
316
|
+
additionalProperties: false,
|
|
317
|
+
},
|
|
318
|
+
async run(args) {
|
|
319
|
+
const name = arg(args, "name", { required: true, pattern: NAME_RE, max: 64 })
|
|
320
|
+
const it = await findItem(name)
|
|
321
|
+
const examples = await examplesOf(name)
|
|
322
|
+
const head = `# Examples for ${it.title} (${name})\nImports use "@/components/ui/<name>" (shadcn layout).`
|
|
323
|
+
if (!examples.length) {
|
|
324
|
+
const json = await readItemJson(name)
|
|
325
|
+
const { all, main } = exportsOf(json?.files?.[0]?.content ?? "", name)
|
|
326
|
+
return `${head}\n\nNo written examples for this component yet. Import it with:\n\`\`\`tsx\nimport { ${all.join(", ") || main} } from "@/components/ui/${name}"\n\`\`\`\nUse get_component for its props and full source, or see ${SITE}/docs/${name}.`
|
|
327
|
+
}
|
|
328
|
+
return `${head}\n\n${examples.map((e) => `## ${e.title}\n${e.description ? `${e.description}\n` : ""}\`\`\`tsx\n${e.code}\n\`\`\``).join("\n\n")}`
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: "get_design_rules",
|
|
333
|
+
title: "Get MiniDev UI design rules",
|
|
334
|
+
description:
|
|
335
|
+
"Get the MiniDev UI design system rules to follow when writing or editing UI with it: semantic color tokens, shadows, radius and spacing, type, motion and spring presets, materials (hairline, glass, metal, paper) and the five laws. Read this before styling anything.",
|
|
336
|
+
inputSchema: {
|
|
337
|
+
type: "object",
|
|
338
|
+
properties: {
|
|
339
|
+
topic: { type: "string", enum: ["all", "tokens", "materials", "motion", "laws"], default: "all", description: "Narrow to one area." },
|
|
340
|
+
},
|
|
341
|
+
additionalProperties: false,
|
|
342
|
+
},
|
|
343
|
+
async run(args) {
|
|
344
|
+
const topic = arg(args, "topic", { values: ["all", "tokens", "materials", "motion", "laws"] }) ?? "all"
|
|
345
|
+
const pick = {
|
|
346
|
+
tokens: /colour tokens|depth|geometry|type/i,
|
|
347
|
+
materials: /light and material|material law/i,
|
|
348
|
+
motion: /motion|spring/i,
|
|
349
|
+
laws: /five laws|material law|never|designer test/i,
|
|
350
|
+
}[topic]
|
|
351
|
+
const docs = (await Promise.all([readRepo("DESIGN.md"), readRepo("docs/VISION.md")])).filter(Boolean)
|
|
352
|
+
const sections = docs.flatMap((d) => d.split(/\n(?=## )/).filter((s) => s.startsWith("## ")))
|
|
353
|
+
const body = (pick ? sections.filter((s) => pick.test(s.split("\n")[0])) : sections).join("\n\n")
|
|
354
|
+
return `${CORE_RULES}\n\n${body || "(Full design docs unavailable offline; the rules above are the essentials.)"}`
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
name: "get_install_instructions",
|
|
359
|
+
title: "Get MiniDev UI install steps",
|
|
360
|
+
description: "Step-by-step setup of MiniDev UI in a Next.js, Vite or Remix (React Router v7) app: Tailwind v4, shadcn init, the tokens stylesheet, fonts, dark mode, and adding components. Method `shadcn` (copy source into your repo, default) or `npm` (the minidev-ui-kit package).",
|
|
361
|
+
inputSchema: {
|
|
362
|
+
type: "object",
|
|
363
|
+
properties: {
|
|
364
|
+
framework: { type: "string", enum: ["next", "vite", "remix"] },
|
|
365
|
+
method: { type: "string", enum: ["shadcn", "npm"], default: "shadcn" },
|
|
366
|
+
},
|
|
367
|
+
required: ["framework"],
|
|
368
|
+
additionalProperties: false,
|
|
369
|
+
},
|
|
370
|
+
async run(args) {
|
|
371
|
+
const framework = arg(args, "framework", { required: true, values: ["next", "vite", "remix"] })
|
|
372
|
+
const method = arg(args, "method", { values: ["shadcn", "npm"] }) ?? "shadcn"
|
|
373
|
+
return installGuide(framework, method)
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
]
|
|
377
|
+
|
|
378
|
+
const safe = (fn) => {
|
|
379
|
+
try {
|
|
380
|
+
return fn()
|
|
381
|
+
} catch {
|
|
382
|
+
return null
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// shortcut: the essentials of DESIGN.md / llms.txt "Rules for generating code", duplicated so offline agents still get them; update with DESIGN.md.
|
|
387
|
+
const CORE_RULES = `# MiniDev UI rules for generated code
|
|
388
|
+
- Semantic tokens only: bg-bg, bg-surface, bg-raised, bg-sunken, border-border, border-border-strong, text-fg, text-fg-muted, text-fg-subtle, bg-ink/text-on-ink, bg-accent/text-on-accent, bg-accent-soft, text-accent-fg, success, warning, danger, info. Never hex, never palette steps like blue-500, never bg-white/bg-black, no opacity variants on surfaces (bg-surface/80).
|
|
389
|
+
- Primary action: <Button> (ink). Brand action: <Button variant="accent">. Secondary: variant="outline". Quiet: variant="ghost".
|
|
390
|
+
- Depth only from: shadow-xs, shadow-sm, shadow-raised (cards), shadow-key, shadow-ink, shadow-md, shadow-lg, shadow-overlay (menus, dialogs). No hand-written shadow-[...]. Never backdrop-blur on surfaces.
|
|
391
|
+
- Radius: controls rounded-lg, cards rounded-xl, overlays rounded-2xl. Nested radius = outer minus padding.
|
|
392
|
+
- Sizes on the 2px grid (even numbers). Controls 28/32/36/44/48px. Numbers use tabular-nums.
|
|
393
|
+
- Motion: springs from the presets (snappy, morph, roll, release, data), exits faster than entrances, respect prefers-reduced-motion. Hover changes one property; press sinks 0.5px, never scales.
|
|
394
|
+
- Dark mode: the \`dark\` class on <html>; components never branch on theme. Materials: data-material="hairline|glass|metal|paper" on <html> or any element.
|
|
395
|
+
- Every component is fully keyboard and screen-reader accessible; keep labels, roles and focus rings.`
|
|
396
|
+
|
|
397
|
+
function installGuide(fw, method) {
|
|
398
|
+
const f = {
|
|
399
|
+
next: { name: "Next.js (App Router)", create: "npx create-next-app@latest my-app --typescript --tailwind --eslint --app", css: "app/globals.css", dir: "app", alias: "@/components/ui", out: "components/ui", guide: "nextjs" },
|
|
400
|
+
vite: { name: "Vite + React", create: "npm create vite@latest my-app -- --template react-ts", css: "src/index.css", dir: "src", alias: "@/components/ui", out: "src/components/ui", guide: "vite" },
|
|
401
|
+
remix: { name: "Remix (React Router v7 framework mode; Remix merged into React Router)", create: "npx create-react-router@latest my-app", css: "app/app.css", dir: "app", alias: "~/components/ui", out: "app/components/ui", guide: "react-router" },
|
|
402
|
+
}[fw]
|
|
403
|
+
const steps = [`# Install MiniDev UI in ${f.name} (${method})`, `Full guide: ${SITE}/docs/installation/${f.guide}`, `## 1. Create the app (skip for an existing one)`, "```bash", f.create, "```"]
|
|
404
|
+
if (fw === "vite")
|
|
405
|
+
steps.push(
|
|
406
|
+
`## 2. Tailwind CSS v4 and the @ alias`,
|
|
407
|
+
"```bash\nnpm install tailwindcss @tailwindcss/vite\nnpm install -D @types/node\n```",
|
|
408
|
+
'Replace `src/index.css` with `@import "tailwindcss";`. Add `"baseUrl": ".", "paths": { "@/*": ["./src/*"] }` to compilerOptions in both `tsconfig.json` and `tsconfig.app.json`. Then `vite.config.ts`:',
|
|
409
|
+
'```ts\nimport path from "path"\nimport tailwindcss from "@tailwindcss/vite"\nimport react from "@vitejs/plugin-react"\nimport { defineConfig } from "vite"\n\nexport default defineConfig({\n plugins: [react(), tailwindcss()],\n resolve: { alias: { "@": path.resolve(__dirname, "./src") } },\n})\n```',
|
|
410
|
+
)
|
|
411
|
+
else if (fw === "next") steps.push(`## 2. Tailwind CSS v4`, "The --tailwind flag sets up Tailwind v4 with `@tailwindcss/postcss`. Existing app: `npm install tailwindcss @tailwindcss/postcss postcss` and add `\"@tailwindcss/postcss\": {}` to `postcss.config.mjs` plugins. Keep the `@/*` alias.")
|
|
412
|
+
else steps.push(`## 2. Tailwind CSS v4`, "The template ships Tailwind v4 via `@tailwindcss/vite` and the `~/*` alias via `vite-tsconfig-paths`. Older projects: `npm install tailwindcss @tailwindcss/vite` and add `tailwindcss()` before `reactRouter()` in `vite.config.ts`.")
|
|
413
|
+
|
|
414
|
+
if (method === "npm") {
|
|
415
|
+
steps.push(
|
|
416
|
+
`## 3. Install the package`,
|
|
417
|
+
"```bash\nnpm i minidev-ui-kit @base-ui/react class-variance-authority clsx tailwind-merge lucide-react motion\n```",
|
|
418
|
+
`## 4. Styles: \`${f.css}\``,
|
|
419
|
+
'```css\n@import "tailwindcss";\n@import "minidev-ui-kit/styles.css";\n@source "../node_modules/minidev-ui-kit";\n```',
|
|
420
|
+
`Adjust the @source path so it points from \`${f.css}\` to node_modules.`,
|
|
421
|
+
)
|
|
422
|
+
if (fw === "next") steps.push('Add `transpilePackages: ["minidev-ui-kit"]` to `next.config.ts`.')
|
|
423
|
+
steps.push(`## 5. Use it`, "```tsx", 'import { Button } from "minidev-ui-kit/ui/button"', 'import { PricingPlans } from "minidev-ui-kit/blocks/pricing-plans"', "```", "Paths: minidev-ui-kit/ui/<name>, /blocks/<name>, /premium/<name> (motion components). get_component shows the exact path.")
|
|
424
|
+
} else {
|
|
425
|
+
steps.push(
|
|
426
|
+
`## 3. shadcn init`,
|
|
427
|
+
"```bash\nnpx shadcn@latest init\n```",
|
|
428
|
+
`## 4. The MiniDev tokens stylesheet`,
|
|
429
|
+
"```bash",
|
|
430
|
+
`curl -o ${f.dir}/minidev.css ${SITE}/r/styles.css`,
|
|
431
|
+
"```",
|
|
432
|
+
`Reduce \`${f.css}\` to imports, MiniDev after Tailwind:`,
|
|
433
|
+
'```css\n@import "tailwindcss";\n/* keep other @import lines init added, such as tw-animate-css */\n@import "./minidev.css";\n```',
|
|
434
|
+
"Delete the `:root`, `.dark` and `@theme inline` blocks that shadcn init generated: they override MiniDev's tokens and turn the accent gray. MiniDev already maps every shadcn variable name, so stock shadcn components keep working. Remove the duplicate `@custom-variant dark` line too.",
|
|
435
|
+
`## 5. Add components`,
|
|
436
|
+
"```bash\nnpx shadcn@latest add @minidev/button\nnpx shadcn@latest add @minidev/pricing-plans\n```",
|
|
437
|
+
`Files land in \`${f.out}\`; registry dependencies install automatically.`,
|
|
438
|
+
`## 6. Use it`,
|
|
439
|
+
"```tsx",
|
|
440
|
+
`import { Button } from "${f.alias}/button"`,
|
|
441
|
+
"",
|
|
442
|
+
'<Button variant="accent">Start free</Button>',
|
|
443
|
+
"```",
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
steps.push(
|
|
447
|
+
`## Fonts, dark mode, light`,
|
|
448
|
+
fw === "next"
|
|
449
|
+
? "Fonts read `--font-geist-sans` / `--font-geist-mono`, which the create-next-app layout sets through next/font (Geist)."
|
|
450
|
+
: 'Load Geist (Google Fonts `family=Geist:wght@100..900&family=Geist+Mono:wght@100..900`) and set `:root { --font-geist-sans: "Geist", ui-sans-serif, system-ui, sans-serif; --font-geist-mono: "Geist Mono", ui-monospace, monospace; }`.',
|
|
451
|
+
"Dark mode follows a `dark` class on `<html>` (set it before paint to avoid a flash). Optional: `data-material=\"glass|metal|paper\"` on `<html>`; `npx shadcn@latest add @minidev/light-provider` and render `<LightProvider />` once so shadows follow the pointer.",
|
|
452
|
+
"Before writing UI, call get_design_rules.",
|
|
453
|
+
)
|
|
454
|
+
return steps.join("\n")
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---------- JSON-RPC dispatch ----------
|
|
458
|
+
|
|
459
|
+
const INSTRUCTIONS = `MiniDev UI: 500+ free React + Tailwind v4 components on Base UI, shadcn compatible. Workflow: search_components (or list_components) to find a component, get_component for its source, props and install command, get_example for usage, get_design_rules before styling, get_install_instructions to set up a new app. Install any component with \`npx shadcn@latest add @minidev/<name>\`. Use semantic tokens only (bg-surface, text-fg-muted, shadow-raised), never hex or palette colors.`
|
|
460
|
+
|
|
461
|
+
const reply = (id, result) => ({ jsonrpc: "2.0", id, result })
|
|
462
|
+
export const fail = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } })
|
|
463
|
+
|
|
464
|
+
/** Handle one JSON-RPC message. Resolves to the response object, or null for notifications and responses. */
|
|
465
|
+
export async function handle(msg) {
|
|
466
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg) || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") {
|
|
467
|
+
if (msg && typeof msg === "object" && ("result" in msg || "error" in msg)) return null // a response to us; we send no requests
|
|
468
|
+
return fail(msg?.id ?? null, -32600, "Invalid Request")
|
|
469
|
+
}
|
|
470
|
+
const { id, method, params } = msg
|
|
471
|
+
const isNotification = id === undefined
|
|
472
|
+
if (isNotification) return null // notifications/initialized, notifications/cancelled: nothing to do
|
|
473
|
+
switch (method) {
|
|
474
|
+
case "initialize": {
|
|
475
|
+
const asked = params?.protocolVersion
|
|
476
|
+
return reply(id, {
|
|
477
|
+
protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
|
|
478
|
+
capabilities: { tools: { listChanged: false } },
|
|
479
|
+
serverInfo: { name: "minidev-ui", title: "MiniDev UI", version: VERSION },
|
|
480
|
+
instructions: INSTRUCTIONS,
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
case "ping":
|
|
484
|
+
return reply(id, {})
|
|
485
|
+
case "tools/list":
|
|
486
|
+
return reply(id, {
|
|
487
|
+
tools: TOOLS.map(({ name, title, description, inputSchema }) => ({ name, title, description, inputSchema, annotations: { title, readOnlyHint: true, openWorldHint: true } })),
|
|
488
|
+
})
|
|
489
|
+
case "tools/call": {
|
|
490
|
+
const tool = TOOLS.find((t) => t.name === params?.name)
|
|
491
|
+
if (!tool) return fail(id, -32602, `Unknown tool: ${params?.name}`)
|
|
492
|
+
if (params.arguments !== undefined && (typeof params.arguments !== "object" || params.arguments === null || Array.isArray(params.arguments)))
|
|
493
|
+
return fail(id, -32602, "arguments must be an object")
|
|
494
|
+
try {
|
|
495
|
+
return reply(id, { content: [{ type: "text", text: await tool.run(params.arguments ?? {}) }], isError: false })
|
|
496
|
+
} catch (e) {
|
|
497
|
+
if (!(e instanceof ToolError)) console.error(`[minidev-ui-mcp] ${tool.name}:`, e)
|
|
498
|
+
return reply(id, { content: [{ type: "text", text: e?.message ?? String(e) }], isError: true })
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
default:
|
|
502
|
+
return fail(id, -32601, `Method not found: ${method}`)
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minidev-ui-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server for MiniDev UI: lets Claude Code, Cursor, VS Code, Windsurf and other AI agents search, read and install 500+ React + Tailwind v4 components. Zero dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"minidev-ui-mcp": "server.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"server.mjs",
|
|
12
|
+
"core.mjs",
|
|
13
|
+
"props-parser.mjs",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node server.mjs",
|
|
18
|
+
"test": "node test.mjs"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18.17"
|
|
22
|
+
},
|
|
23
|
+
"keywords": ["mcp", "model-context-protocol", "react", "tailwind", "shadcn", "ui", "components", "ai-agents", "claude-code", "cursor"],
|
|
24
|
+
"homepage": "https://ui.minidev.pro",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/amankaushik481/minidev-ui.git",
|
|
28
|
+
"directory": "mcp"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/props-parser.mjs
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Vendored copy of tools/props-parser.mjs so the published package stays self-contained. Keep in sync.
|
|
2
|
+
/**
|
|
3
|
+
* Best-effort props extraction for the docs API tables. Reads the main
|
|
4
|
+
* component's parameter type (inline object, a named type/interface, or an
|
|
5
|
+
* intersection with React.ComponentProps) and the defaults from destructuring.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function matchClose(src, i, open, close) {
|
|
9
|
+
let depth = 0
|
|
10
|
+
for (let j = i; j < src.length; j++) {
|
|
11
|
+
const c = src[j]
|
|
12
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
13
|
+
const q = c
|
|
14
|
+
for (j++; j < src.length && src[j] !== q; j++) if (src[j] === "\\") j++
|
|
15
|
+
continue
|
|
16
|
+
}
|
|
17
|
+
if (c === open) depth++
|
|
18
|
+
else if (c === close) {
|
|
19
|
+
depth--
|
|
20
|
+
if (depth === 0) return j
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return -1
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Split on a separator at bracket depth 0. */
|
|
27
|
+
function splitTop(s, seps) {
|
|
28
|
+
const out = []
|
|
29
|
+
let depth = 0
|
|
30
|
+
let cur = ""
|
|
31
|
+
for (let i = 0; i < s.length; i++) {
|
|
32
|
+
const c = s[i]
|
|
33
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
34
|
+
const q = c
|
|
35
|
+
let j = i + 1
|
|
36
|
+
for (; j < s.length && s[j] !== q; j++) if (s[j] === "\\") j++
|
|
37
|
+
cur += s.slice(i, j + 1)
|
|
38
|
+
i = j
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (c === "/" && s[i + 1] === "*") {
|
|
42
|
+
const e = s.indexOf("*/", i + 2)
|
|
43
|
+
cur += s.slice(i, e + 2)
|
|
44
|
+
i = e + 1
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (c === "/" && s[i + 1] === "/") {
|
|
48
|
+
const e = s.indexOf("\n", i)
|
|
49
|
+
i = e === -1 ? s.length : e - 1
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if ("{([<".includes(c)) depth++
|
|
53
|
+
if ("})]>".includes(c) && !(c === ">" && s[i - 1] === "=")) depth--
|
|
54
|
+
if (depth === 0 && seps.includes(c)) {
|
|
55
|
+
out.push(cur)
|
|
56
|
+
cur = ""
|
|
57
|
+
} else cur += c
|
|
58
|
+
}
|
|
59
|
+
if (cur.trim()) out.push(cur)
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseMembers(body) {
|
|
64
|
+
const props = []
|
|
65
|
+
let pending
|
|
66
|
+
for (let raw of splitTop(body, [";", ",", "\n"])) {
|
|
67
|
+
let doc = pending
|
|
68
|
+
const d = raw.match(/\/\*\*([\s\S]*?)\*\//)
|
|
69
|
+
if (d) {
|
|
70
|
+
doc = d[1].replace(/^\s*\*\s?/gm, "").replace(/\s+/g, " ").trim()
|
|
71
|
+
raw = raw.replace(d[0], "")
|
|
72
|
+
}
|
|
73
|
+
raw = raw.replace(/\/\*[\s\S]*?\*\//g, "").trim()
|
|
74
|
+
// A `/** doc */` on its own line arrives as its own chunk (we split on newlines): keep it for the next member.
|
|
75
|
+
if (!raw) {
|
|
76
|
+
pending = doc
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
pending = undefined
|
|
80
|
+
const m = raw.match(/^(readonly\s+)?["']?([A-Za-z_$][\w$-]*)["']?(\?)?\s*:\s*([\s\S]+)$/)
|
|
81
|
+
if (!m) continue
|
|
82
|
+
let def
|
|
83
|
+
if (doc) {
|
|
84
|
+
def = doc.match(/@default\s+(.+?)(?=\s@(?:deprecated|see|example|remarks)\b|$)/)?.[1]
|
|
85
|
+
const deprecated = /@deprecated\b/.test(doc)
|
|
86
|
+
doc = doc.replace(/\s*@(?:default|deprecated|see|example|remarks)\b.*$/, "").trim()
|
|
87
|
+
if (deprecated) doc = `Deprecated. ${doc}`.trim()
|
|
88
|
+
}
|
|
89
|
+
props.push({ name: m[2], optional: Boolean(m[3]), type: m[4].replace(/\s+/g, " ").trim(), ...(doc ? { doc } : {}), ...(def ? { default: def } : {}) })
|
|
90
|
+
}
|
|
91
|
+
return props
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function findTypeBody(src, name, seen = new Set()) {
|
|
95
|
+
if (seen.has(name)) return { members: [], extends: [] }
|
|
96
|
+
seen.add(name)
|
|
97
|
+
const t = src.match(new RegExp(`(?:type|interface)\\s+${name}(?:<[^>]*>)?\\s*(=|extends|\\{)`))
|
|
98
|
+
if (!t) return null
|
|
99
|
+
let i = t.index + t[0].length - 1
|
|
100
|
+
if (t[1] === "=") {
|
|
101
|
+
const start = t.index + t[0].length
|
|
102
|
+
// read until end of statement: a newline followed by a non-continuation line at depth 0
|
|
103
|
+
let depth = 0
|
|
104
|
+
let j = start
|
|
105
|
+
for (; j < src.length; j++) {
|
|
106
|
+
const c = src[j]
|
|
107
|
+
if ("{([<".includes(c)) depth++
|
|
108
|
+
if ("})]>".includes(c) && !(c === ">" && src[j - 1] === "=")) depth--
|
|
109
|
+
if (depth === 0 && src[j] === "\n" && !/^\s*[&|]/.test(src.slice(j + 1, j + 40))) {
|
|
110
|
+
if (src.slice(start, j).trim().length) break
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return typeExpr(src, src.slice(start, j), seen)
|
|
114
|
+
}
|
|
115
|
+
const brace = src.indexOf("{", i)
|
|
116
|
+
const head = src.slice(t.index, brace)
|
|
117
|
+
const ext = (head.match(/extends\s+([\s\S]+)$/)?.[1] ?? "").trim()
|
|
118
|
+
const close = matchClose(src, brace, "{", "}")
|
|
119
|
+
return { members: parseMembers(src.slice(brace + 1, close)), extends: ext ? [ext] : [] }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function typeExpr(src, expr, seen) {
|
|
123
|
+
const members = []
|
|
124
|
+
const ext = []
|
|
125
|
+
for (let part of splitTop(expr, ["&"])) {
|
|
126
|
+
part = part.trim()
|
|
127
|
+
if (!part) continue
|
|
128
|
+
if (part.startsWith("{")) {
|
|
129
|
+
const close = matchClose(part, 0, "{", "}")
|
|
130
|
+
members.push(...parseMembers(part.slice(1, close)))
|
|
131
|
+
} else if (/^VariantProps<typeof\s+\w+>$/.test(part)) {
|
|
132
|
+
members.push(...cvaVariants(src, part.match(/typeof\s+(\w+)/)[1]))
|
|
133
|
+
} else if (/^(Omit|Pick|Partial)</.test(part)) {
|
|
134
|
+
ext.push(part.replace(/\s+/g, " "))
|
|
135
|
+
} else if (/^[A-Z]\w*(<[\s\S]*>)?$/.test(part)) {
|
|
136
|
+
const r = findTypeBody(src, part.replace(/<[\s\S]*>$/, ""), seen)
|
|
137
|
+
if (r) {
|
|
138
|
+
members.push(...r.members)
|
|
139
|
+
ext.push(...r.extends)
|
|
140
|
+
} else ext.push(part)
|
|
141
|
+
} else ext.push(part.replace(/\s+/g, " "))
|
|
142
|
+
}
|
|
143
|
+
return { members, extends: ext }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Props implied by a class-variance-authority config: each variant key becomes a union prop. */
|
|
147
|
+
function cvaVariants(src, name) {
|
|
148
|
+
const m = src.match(new RegExp(`const\\s+${name}\\s*=\\s*cva\\(`))
|
|
149
|
+
if (!m) return []
|
|
150
|
+
const open = src.indexOf("(", m.index)
|
|
151
|
+
const body = src.slice(open, matchClose(src, open, "(", ")"))
|
|
152
|
+
const v = body.indexOf("variants:")
|
|
153
|
+
if (v === -1) return []
|
|
154
|
+
const vo = body.indexOf("{", v)
|
|
155
|
+
const variants = body.slice(vo + 1, matchClose(body, vo, "{", "}"))
|
|
156
|
+
const defaults = {}
|
|
157
|
+
const dv = body.indexOf("defaultVariants:")
|
|
158
|
+
if (dv !== -1) {
|
|
159
|
+
const o = body.indexOf("{", dv)
|
|
160
|
+
for (const e of splitTop(body.slice(o + 1, matchClose(body, o, "{", "}")), [","])) {
|
|
161
|
+
const mm = e.trim().match(/^(\w+)\s*:\s*["']?([\w-]+)["']?/)
|
|
162
|
+
if (mm) defaults[mm[1]] = `"${mm[2]}"`
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const out = []
|
|
166
|
+
let i = 0
|
|
167
|
+
while (i < variants.length) {
|
|
168
|
+
const km = variants.slice(i).match(/(\w+)\s*:\s*\{/)
|
|
169
|
+
if (!km) break
|
|
170
|
+
const start = i + km.index + km[0].length - 1
|
|
171
|
+
const end = matchClose(variants, start, "{", "}")
|
|
172
|
+
const keys = splitTop(variants.slice(start + 1, end), [","]).map((e) => e.trim().match(/^["']?([\w-]+)["']?\s*:/)?.[1]).filter(Boolean)
|
|
173
|
+
out.push({ name: km[1], optional: true, type: keys.map((k) => `"${k}"`).join(" | "), ...(defaults[km[1]] ? { default: defaults[km[1]] } : {}) })
|
|
174
|
+
i = end + 1
|
|
175
|
+
}
|
|
176
|
+
return out
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function extractProps(src, title) {
|
|
180
|
+
const fn = src.match(new RegExp(`function\\s+${title}\\s*(?:<[^(]*>)?\\s*\\(`))
|
|
181
|
+
let paramText = null
|
|
182
|
+
if (fn) {
|
|
183
|
+
const open = fn.index + fn[0].length - 1
|
|
184
|
+
const close = matchClose(src, open, "(", ")")
|
|
185
|
+
paramText = src.slice(open + 1, close)
|
|
186
|
+
} else {
|
|
187
|
+
const c = src.match(new RegExp(`const\\s+${title}\\s*=\\s*(?:React\\.forwardRef<[^>]*>\\()?\\(`))
|
|
188
|
+
if (c) {
|
|
189
|
+
const open = c.index + c[0].length - 1
|
|
190
|
+
const close = matchClose(src, open, "(", ")")
|
|
191
|
+
paramText = src.slice(open + 1, close)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!paramText || !paramText.trim()) return null
|
|
195
|
+
// destructuring defaults
|
|
196
|
+
const defaults = {}
|
|
197
|
+
let typePart = ""
|
|
198
|
+
const t = paramText.trim()
|
|
199
|
+
if (t.startsWith("{")) {
|
|
200
|
+
const close = matchClose(t, 0, "{", "}")
|
|
201
|
+
for (const entry of splitTop(t.slice(1, close), [","])) {
|
|
202
|
+
const m = entry.trim().match(/^([\w$]+)(?:\s*:\s*[\w$]+)?\s*=\s*([\s\S]+)$/)
|
|
203
|
+
if (m) defaults[m[1]] = m[2].replace(/\s+/g, " ").trim()
|
|
204
|
+
}
|
|
205
|
+
const rest = t.slice(close + 1).trim()
|
|
206
|
+
typePart = rest.startsWith(":") ? rest.slice(1).trim() : ""
|
|
207
|
+
} else {
|
|
208
|
+
const m = t.match(/^[\w$]+\s*:\s*([\s\S]+)$/)
|
|
209
|
+
typePart = m ? m[1] : ""
|
|
210
|
+
}
|
|
211
|
+
if (!typePart) return null
|
|
212
|
+
const { members, extends: ext } = typeExpr(src, typePart, new Set())
|
|
213
|
+
const seen = new Set()
|
|
214
|
+
const props = members
|
|
215
|
+
.filter((p) => (seen.has(p.name) ? false : (seen.add(p.name), true)))
|
|
216
|
+
.map((p) => ({ ...p, type: p.type.length > 90 ? p.type.slice(0, 87) + "..." : p.type, ...(defaults[p.name] !== undefined ? { default: defaults[p.name].length > 40 ? defaults[p.name].slice(0, 37) + "..." : defaults[p.name] } : {}) }))
|
|
217
|
+
const extendsText = ext
|
|
218
|
+
.map((e) => {
|
|
219
|
+
const el = e.match(/ComponentProps(?:WithoutRef)?<\s*["'](\w+)["']\s*>/)?.[1]
|
|
220
|
+
if (el) return `<${el}>`
|
|
221
|
+
const prim = e.match(/(\w+(?:\.\w+)*)\.Props/)?.[1]
|
|
222
|
+
if (prim) return `${prim} props`
|
|
223
|
+
const base = e.match(/React\.ComponentProps<typeof\s+([\w.]+)>/)?.[1]
|
|
224
|
+
if (base) return `${base} props`
|
|
225
|
+
return null
|
|
226
|
+
})
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
if (!props.length && !extendsText.length) return null
|
|
229
|
+
return { props, extends: [...new Set(extendsText)] }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The component's own JSDoc: the column-0 `/** … *\/` block that names the component ("Button — …"),
|
|
234
|
+
* else the one right above its declaration, else the first column-0 block in the file.
|
|
235
|
+
* Returns { summary, body, examples } where body is the "when to use" prose and examples are @example blocks.
|
|
236
|
+
*/
|
|
237
|
+
export function parseFileDoc(src, title) {
|
|
238
|
+
const blocks = [...src.matchAll(/^\/\*\*([\s\S]*?)\*\//gm)]
|
|
239
|
+
if (!blocks.length) return null
|
|
240
|
+
const decl = new RegExp(`^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?(?:function|const)\\s+${title}\\b`)
|
|
241
|
+
const pick =
|
|
242
|
+
blocks.find((b) => new RegExp(`^[\\s*]*${title}\\s+[—–-]`).test(b[1])) ??
|
|
243
|
+
blocks.find((b) => decl.test(src.slice(b.index + b[0].length).replace(/^\s*\n/, ""))) ??
|
|
244
|
+
blocks[0]
|
|
245
|
+
const lines = pick[1].split("\n").map((l) => l.replace(/^\s*\*(?: |$)/, "").replace(/\s+$/, ""))
|
|
246
|
+
const summary = []
|
|
247
|
+
const body = []
|
|
248
|
+
const examples = []
|
|
249
|
+
let mode = "summary"
|
|
250
|
+
for (const l of lines) {
|
|
251
|
+
const tag = l.match(/^\s*@(\w+)\s*(.*)$/)
|
|
252
|
+
if (tag) {
|
|
253
|
+
mode = tag[1] === "example" ? "example" : "tag"
|
|
254
|
+
if (mode === "example") examples.push(tag[2] ? [tag[2]] : [])
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
if (!l.trim()) {
|
|
258
|
+
if (mode === "summary" && summary.length) mode = "body"
|
|
259
|
+
else if (mode === "example" || mode === "tag") mode = "body"
|
|
260
|
+
if (mode === "body") body.push("")
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
if (mode === "summary") summary.push(l.trim())
|
|
264
|
+
else if (mode === "example") examples.at(-1).push(l)
|
|
265
|
+
else if (mode === "body") body.push(l.trim())
|
|
266
|
+
}
|
|
267
|
+
const text = summary.join(" ").replace(/^[A-Z][\w.]*(?:\s+[A-Z][\w.]*)*\s+[—–-]{1,2}\s+/, "")
|
|
268
|
+
const paras = body
|
|
269
|
+
.join("\n")
|
|
270
|
+
.split(/\n\s*\n/)
|
|
271
|
+
.map((p) => p.replace(/\n(?!\s*[-*] )/g, " ").trim())
|
|
272
|
+
.filter(Boolean)
|
|
273
|
+
const dedent = (ls) => {
|
|
274
|
+
const ind = Math.min(...ls.filter((l) => l.trim()).map((l) => l.match(/^\s*/)[0].length))
|
|
275
|
+
return ls.map((l) => l.slice(ind)).join("\n").trim()
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
summary: text ? text[0].toUpperCase() + text.slice(1) : undefined,
|
|
279
|
+
body: paras.join("\n\n") || undefined,
|
|
280
|
+
examples: examples.filter((e) => e.some((l) => l.trim())).map(dedent),
|
|
281
|
+
}
|
|
282
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MiniDev UI MCP server. Zero dependencies, Node >= 18, stdio transport,
|
|
4
|
+
* newline-delimited JSON-RPC 2.0 per the Model Context Protocol.
|
|
5
|
+
* The tools live in core.mjs; the same core serves https://ui.minidev.pro/mcp over HTTP.
|
|
6
|
+
*
|
|
7
|
+
* Data: https://ui.minidev.pro/r/*.json (registry), the public GitHub repo
|
|
8
|
+
* (examples, DESIGN.md), with a fallback to local files when this folder
|
|
9
|
+
* sits inside the MiniDev UI repo.
|
|
10
|
+
*
|
|
11
|
+
* Env: MINIDEV_UI_SOURCE=local|remote (default: remote, then local)
|
|
12
|
+
* MINIDEV_UI_URL (default https://ui.minidev.pro)
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from "node:fs"
|
|
15
|
+
import { readFile } from "node:fs/promises"
|
|
16
|
+
import { fileURLToPath } from "node:url"
|
|
17
|
+
import path from "node:path"
|
|
18
|
+
import { fail, handle, setLocalReader } from "./core.mjs"
|
|
19
|
+
|
|
20
|
+
const ROOT = fileURLToPath(new URL("..", import.meta.url))
|
|
21
|
+
if (existsSync(path.join(ROOT, "public/r/registry.json"))) setLocalReader((rel) => readFile(path.join(ROOT, rel), "utf8"))
|
|
22
|
+
|
|
23
|
+
const send = (msg) => msg && process.stdout.write(JSON.stringify(msg) + "\n")
|
|
24
|
+
|
|
25
|
+
let buffer = ""
|
|
26
|
+
const pending = new Set()
|
|
27
|
+
process.stdin.setEncoding("utf8")
|
|
28
|
+
process.stdin.on("data", (chunk) => {
|
|
29
|
+
buffer += chunk
|
|
30
|
+
let nl
|
|
31
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
32
|
+
const raw = buffer.slice(0, nl).trim()
|
|
33
|
+
buffer = buffer.slice(nl + 1)
|
|
34
|
+
if (!raw) continue
|
|
35
|
+
let msg
|
|
36
|
+
try {
|
|
37
|
+
msg = JSON.parse(raw)
|
|
38
|
+
} catch {
|
|
39
|
+
send(fail(null, -32700, "Parse error"))
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
const job = handle(msg)
|
|
43
|
+
.catch((e) => fail(msg?.id ?? null, -32603, `Internal error: ${e?.message ?? e}`))
|
|
44
|
+
.then(send)
|
|
45
|
+
pending.add(job)
|
|
46
|
+
job.finally(() => pending.delete(job))
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
// Client closed stdin: finish in-flight calls, then exit (open fetch sockets would otherwise keep us alive).
|
|
50
|
+
process.stdin.on("end", () => Promise.allSettled([...pending]).then(() => process.exit(0)))
|