@hyperblur/mcp 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # @hyperblur/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [MCP](https://modelcontextprotocol.io) server for [Hyperblur](https://hyperblur.dev), the catalog of copy-paste React visual effects. It lets MCP clients such as Claude Code, Cursor and Claude Desktop search the catalog, read an effect's props and usage, fetch its source, and install it into a project through the shadcn registry.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 20 or later
8
+ - For `install_effect`: a React project with a `package.json`, set up for shadcn (`components.json`)
9
+
10
+ ## Setup
11
+
12
+ ### Claude Code
13
+
14
+ ```sh
15
+ claude mcp add hyperblur -- npx -y @hyperblur/mcp
16
+ ```
17
+
18
+ ### Cursor
19
+
20
+ Add to `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` for all projects:
21
+
22
+ ```json
23
+ {
24
+ "mcpServers": {
25
+ "hyperblur": {
26
+ "command": "npx",
27
+ "args": ["-y", "@hyperblur/mcp"]
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ ### Claude Desktop
34
+
35
+ Add to `claude_desktop_config.json` (Settings > Developer > Edit Config), then restart the app:
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "hyperblur": {
41
+ "command": "npx",
42
+ "args": ["-y", "@hyperblur/mcp"]
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ ## Configuration
49
+
50
+ | Variable | Default | Description |
51
+ | --- | --- | --- |
52
+ | `HYPERBLUR_SITE_URL` | `https://hyperblur.vercel.app` | Site that serves the catalog (`/r/index.json`) and registry items (`/r/<slug>.json`). Point it at `http://localhost:3000` when working on the site locally. |
53
+
54
+ Pass it through the `env` field of your client config, or with `claude mcp add hyperblur -e HYPERBLUR_SITE_URL=http://localhost:3000 -- npx -y @hyperblur/mcp`.
55
+
56
+ The catalog index is cached in memory for five minutes.
57
+
58
+ ## Tools
59
+
60
+ | Tool | Purpose |
61
+ | --- | --- |
62
+ | `search_effects` | Search by keyword and optional category (`text`, `motion`, `overlay`, `hover`). Returns slug, title, category, description and install command for each match. |
63
+ | `get_effect` | Full reference for one effect: props table, usage example, import line, dependencies, install command and docs link. |
64
+ | `get_effect_source` | The component source and its target path, for writing the file by hand when shadcn is not available. |
65
+ | `install_effect` | Runs `npx shadcn@latest add <registry-url> --yes` in the given project directory (`--overwrite` optional) and reports the output and installed file. |
66
+
67
+ It also exposes the resource `hyperblur://catalog` (the full catalog index as JSON) and the prompt `add_effect`, which walks through looking up and installing an effect by slug.
68
+
69
+ ## Development
70
+
71
+ ```sh
72
+ npm install
73
+ npm run build
74
+ npm test
75
+ HYPERBLUR_SITE_URL=http://localhost:3000 npm start
76
+ ```
77
+
78
+ ## License
79
+
80
+ MIT
@@ -0,0 +1,327 @@
1
+ import { z } from "zod";
2
+ export const DEFAULT_SITE_URL = "https://hyperblur.vercel.app";
3
+ export const SITE_URL_ENV = "HYPERBLUR_SITE_URL";
4
+ export const INDEX_TTL_MS = 5 * 60 * 1000;
5
+ export const FETCH_TIMEOUT_MS = 15_000;
6
+ export const CATEGORIES = ["text", "motion", "overlay", "hover"];
7
+ const propSchema = z.object({
8
+ name: z.string(),
9
+ type: z.string(),
10
+ default: z.string().optional(),
11
+ required: z.boolean().optional(),
12
+ description: z.string().default(""),
13
+ });
14
+ const effectSchema = z.object({
15
+ slug: z.string().min(1),
16
+ component: z.string().min(1),
17
+ title: z.string(),
18
+ description: z.string().default(""),
19
+ category: z.string(),
20
+ tags: z.array(z.string()).default([]),
21
+ props: z.array(propSchema).default([]),
22
+ usage: z.string().default(""),
23
+ dependencies: z.array(z.string()).default([]),
24
+ install: z.string().optional(),
25
+ docs: z.string().optional(),
26
+ file: z.string().optional(),
27
+ });
28
+ const indexSchema = z.object({
29
+ name: z.string().default("hyperblur"),
30
+ homepage: z.string().optional(),
31
+ generatedAt: z.string().optional(),
32
+ items: z.array(effectSchema),
33
+ });
34
+ const registryFileSchema = z.object({
35
+ path: z.string(),
36
+ type: z.string().optional(),
37
+ target: z.string().optional(),
38
+ content: z.string(),
39
+ });
40
+ const registryItemSchema = z.object({
41
+ name: z.string(),
42
+ dependencies: z.array(z.string()).default([]),
43
+ devDependencies: z.array(z.string()).default([]),
44
+ registryDependencies: z.array(z.string()).default([]),
45
+ files: z.array(registryFileSchema).min(1),
46
+ });
47
+ export class CatalogError extends Error {
48
+ constructor(message) {
49
+ super(message);
50
+ this.name = "CatalogError";
51
+ }
52
+ }
53
+ export class UnknownEffectError extends CatalogError {
54
+ slug;
55
+ validSlugs;
56
+ constructor(slug, validSlugs) {
57
+ const suggestions = suggestSlugs(slug, validSlugs);
58
+ const hint = suggestions.length > 0 ? ` Did you mean ${suggestions.map((s) => `"${s}"`).join(" or ")}?` : "";
59
+ super(`Unknown effect "${slug}".${hint} Valid slugs: ${validSlugs.join(", ")}.`);
60
+ this.name = "UnknownEffectError";
61
+ this.slug = slug;
62
+ this.validSlugs = validSlugs;
63
+ }
64
+ }
65
+ export function resolveSiteUrl(env = process.env) {
66
+ const raw = env[SITE_URL_ENV]?.trim() || DEFAULT_SITE_URL;
67
+ let parsed;
68
+ try {
69
+ parsed = new URL(raw);
70
+ }
71
+ catch {
72
+ throw new CatalogError(`${SITE_URL_ENV} must be an absolute http(s) URL such as http://localhost:3000, got "${raw}".`);
73
+ }
74
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
75
+ throw new CatalogError(`${SITE_URL_ENV} must use http or https, got "${raw}".`);
76
+ }
77
+ return raw.replace(/\/+$/, "");
78
+ }
79
+ export function importLine(effect) {
80
+ return `import { ${effect.component} } from "@/components/hyperblur/${effect.slug}";`;
81
+ }
82
+ export class Catalog {
83
+ siteUrl;
84
+ indexUrl;
85
+ fetchImpl;
86
+ ttlMs;
87
+ timeoutMs;
88
+ now;
89
+ cached;
90
+ pending;
91
+ sources = new Map();
92
+ constructor(options = {}) {
93
+ this.siteUrl = (options.siteUrl ?? DEFAULT_SITE_URL).replace(/\/+$/, "");
94
+ this.indexUrl = `${this.siteUrl}/r/index.json`;
95
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
96
+ this.ttlMs = options.ttlMs ?? INDEX_TTL_MS;
97
+ this.timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
98
+ this.now = options.now ?? Date.now;
99
+ }
100
+ itemUrl(slug) {
101
+ return `${this.siteUrl}/r/${slug}.json`;
102
+ }
103
+ installCommand(slug) {
104
+ return `npx shadcn@latest add ${this.itemUrl(slug)}`;
105
+ }
106
+ async getIndex() {
107
+ if (this.cached && this.now() - this.cached.fetchedAt < this.ttlMs) {
108
+ return this.cached.index;
109
+ }
110
+ if (!this.pending) {
111
+ this.pending = this.loadIndex()
112
+ .then((index) => {
113
+ this.cached = { index, fetchedAt: this.now() };
114
+ return index;
115
+ })
116
+ .finally(() => {
117
+ this.pending = undefined;
118
+ });
119
+ }
120
+ try {
121
+ return await this.pending;
122
+ }
123
+ catch (error) {
124
+ if (this.cached) {
125
+ return this.cached.index;
126
+ }
127
+ throw error;
128
+ }
129
+ }
130
+ async listSlugs() {
131
+ const index = await this.getIndex();
132
+ return index.items.map((item) => item.slug);
133
+ }
134
+ async search(options = {}) {
135
+ const index = await this.getIndex();
136
+ const limit = Math.max(1, Math.floor(options.limit ?? 10));
137
+ const pool = options.category ? index.items.filter((item) => item.category === options.category) : index.items;
138
+ const tokens = tokenize(options.query ?? "");
139
+ if (tokens.length === 0) {
140
+ return { total: pool.length, results: pool.slice(0, limit) };
141
+ }
142
+ const phrase = tokens.join(" ");
143
+ const ranked = pool
144
+ .map((effect, position) => ({ effect, position, rank: rankEffect(effect, tokens, phrase) }))
145
+ .filter((entry) => entry.rank !== undefined)
146
+ .sort((a, b) => b.rank.tier - a.rank.tier || b.rank.score - a.rank.score || a.position - b.position);
147
+ return { total: ranked.length, results: ranked.slice(0, limit).map((entry) => entry.effect) };
148
+ }
149
+ async getEffect(slug) {
150
+ const index = await this.getIndex();
151
+ const wanted = normalizeSlug(slug);
152
+ const effect = index.items.find((item) => item.slug === wanted);
153
+ if (!effect) {
154
+ throw new UnknownEffectError(slug, index.items.map((item) => item.slug));
155
+ }
156
+ return effect;
157
+ }
158
+ async getSource(slug) {
159
+ const effect = await this.getEffect(slug);
160
+ const cached = this.sources.get(effect.slug);
161
+ if (cached && this.now() - cached.fetchedAt < this.ttlMs) {
162
+ return cached.source;
163
+ }
164
+ const raw = await this.fetchJson(effect.file, `the registry item for "${effect.slug}"`);
165
+ const parsed = registryItemSchema.safeParse(raw);
166
+ if (!parsed.success) {
167
+ throw new CatalogError(`The registry item at ${effect.file} is not a valid shadcn registry item: ${z.prettifyError(parsed.error)}`);
168
+ }
169
+ const source = {
170
+ effect,
171
+ files: parsed.data.files.map((file) => ({
172
+ path: file.path,
173
+ target: file.target ?? `components/hyperblur/${baseName(file.path)}`,
174
+ content: file.content,
175
+ })),
176
+ dependencies: parsed.data.dependencies,
177
+ devDependencies: parsed.data.devDependencies,
178
+ registryDependencies: parsed.data.registryDependencies,
179
+ };
180
+ this.sources.set(effect.slug, { source, fetchedAt: this.now() });
181
+ return source;
182
+ }
183
+ async loadIndex() {
184
+ const raw = await this.fetchJson(this.indexUrl, "the catalog index");
185
+ const parsed = indexSchema.safeParse(raw);
186
+ if (!parsed.success) {
187
+ throw new CatalogError(`The catalog index at ${this.indexUrl} has an unexpected shape: ${z.prettifyError(parsed.error)}`);
188
+ }
189
+ const homepage = (parsed.data.homepage ?? DEFAULT_SITE_URL).replace(/\/+$/, "");
190
+ return {
191
+ name: parsed.data.name,
192
+ homepage: this.siteUrl,
193
+ ...(parsed.data.generatedAt ? { generatedAt: parsed.data.generatedAt } : {}),
194
+ items: parsed.data.items.map((item) => ({
195
+ slug: item.slug,
196
+ component: item.component,
197
+ title: item.title,
198
+ description: item.description,
199
+ category: item.category,
200
+ tags: item.tags,
201
+ props: item.props,
202
+ usage: item.usage,
203
+ dependencies: item.dependencies,
204
+ install: this.installCommand(item.slug),
205
+ docs: item.docs ? this.rebase(item.docs, homepage) : `${this.siteUrl}/effects/${item.slug}`,
206
+ file: this.itemUrl(item.slug),
207
+ })),
208
+ };
209
+ }
210
+ rebase(url, homepage) {
211
+ return url.startsWith(`${homepage}/`) ? `${this.siteUrl}${url.slice(homepage.length)}` : url;
212
+ }
213
+ async fetchJson(url, what) {
214
+ let response;
215
+ try {
216
+ response = await this.fetchImpl(url, {
217
+ headers: { accept: "application/json" },
218
+ signal: AbortSignal.timeout(this.timeoutMs),
219
+ });
220
+ }
221
+ catch (error) {
222
+ throw new CatalogError(`Could not reach the Hyperblur site at ${this.siteUrl} while loading ${what} (${describeFetchError(error, this.timeoutMs)}). ` +
223
+ `Check that the site is online and reachable from this machine, or set ${SITE_URL_ENV} to a reachable Hyperblur instance.`);
224
+ }
225
+ if (!response.ok) {
226
+ const status = `${response.status} ${response.statusText}`.trim();
227
+ throw new CatalogError(`Failed to load ${what}: ${url} responded with HTTP ${status}.`);
228
+ }
229
+ try {
230
+ return await response.json();
231
+ }
232
+ catch {
233
+ throw new CatalogError(`Failed to load ${what}: ${url} did not return valid JSON.`);
234
+ }
235
+ }
236
+ }
237
+ function rankEffect(effect, tokens, phrase) {
238
+ const slug = effect.slug.toLowerCase();
239
+ const title = effect.title.toLowerCase();
240
+ const description = effect.description.toLowerCase();
241
+ const tags = effect.tags.map((tag) => tag.toLowerCase());
242
+ let tier = 0;
243
+ let score = 0;
244
+ for (const token of tokens) {
245
+ const inName = slug.includes(token) || title.includes(token);
246
+ const exactTag = tags.includes(token);
247
+ const partialTag = !exactTag && tags.some((tag) => tag.includes(token));
248
+ const inDescription = description.includes(token);
249
+ if (!inName && !exactTag && !partialTag && !inDescription) {
250
+ return undefined;
251
+ }
252
+ if (inName) {
253
+ tier = 1;
254
+ score += 10;
255
+ }
256
+ if (exactTag)
257
+ score += 5;
258
+ if (partialTag)
259
+ score += 3;
260
+ if (inDescription)
261
+ score += 1;
262
+ }
263
+ if (slug === phrase.replace(/ /g, "-") || title === phrase) {
264
+ score += 100;
265
+ }
266
+ else if (slug.includes(phrase.replace(/ /g, "-")) || title.includes(phrase)) {
267
+ score += 20;
268
+ }
269
+ return { tier, score };
270
+ }
271
+ function tokenize(query) {
272
+ return query
273
+ .toLowerCase()
274
+ .split(/[\s,]+/)
275
+ .map((token) => token.trim())
276
+ .filter((token) => token.length > 0);
277
+ }
278
+ function normalizeSlug(slug) {
279
+ return slug.trim().toLowerCase().replace(/\.json$/, "").replace(/\s+/g, "-");
280
+ }
281
+ function baseName(filePath) {
282
+ return filePath.split("/").pop() ?? filePath;
283
+ }
284
+ function suggestSlugs(input, validSlugs) {
285
+ const wanted = normalizeSlug(input);
286
+ if (wanted.length === 0)
287
+ return [];
288
+ const parts = wanted.split("-").filter((part) => part.length > 2);
289
+ const close = (a, b) => editDistance(a, b) <= Math.max(1, Math.floor(Math.min(a.length, b.length) / 4));
290
+ return validSlugs
291
+ .filter((slug) => slug.includes(wanted) ||
292
+ wanted.includes(slug) ||
293
+ parts.some((part) => slug.includes(part)) ||
294
+ close(wanted, slug) ||
295
+ slug.split("-").some((piece) => piece.length > 2 && close(wanted, piece)))
296
+ .slice(0, 3);
297
+ }
298
+ function editDistance(a, b) {
299
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
300
+ for (let i = 1; i <= a.length; i += 1) {
301
+ const current = [i];
302
+ for (let j = 1; j <= b.length; j += 1) {
303
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
304
+ current[j] = Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, (previous[j - 1] ?? 0) + cost);
305
+ }
306
+ previous = current;
307
+ }
308
+ return previous[b.length] ?? 0;
309
+ }
310
+ function describeFetchError(error, timeoutMs) {
311
+ if (error instanceof Error) {
312
+ if (error.name === "TimeoutError" || error.name === "AbortError") {
313
+ return `timed out after ${Math.round(timeoutMs / 1000)}s`;
314
+ }
315
+ const cause = error.cause;
316
+ if (cause && typeof cause === "object") {
317
+ const code = cause.code;
318
+ const message = cause.message;
319
+ if (typeof code === "string")
320
+ return code;
321
+ if (typeof message === "string" && message.length > 0)
322
+ return message;
323
+ }
324
+ return error.message;
325
+ }
326
+ return String(error);
327
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { Catalog, resolveSiteUrl } from "./catalog.js";
4
+ import { createServer } from "./server.js";
5
+ async function main() {
6
+ const catalog = new Catalog({ siteUrl: resolveSiteUrl() });
7
+ const server = createServer({ catalog });
8
+ const transport = new StdioServerTransport();
9
+ await server.connect(transport);
10
+ const shutdown = () => {
11
+ server.close().finally(() => process.exit(0));
12
+ };
13
+ process.on("SIGINT", shutdown);
14
+ process.on("SIGTERM", shutdown);
15
+ }
16
+ main().catch((error) => {
17
+ console.error(`hyperblur-mcp failed to start: ${error instanceof Error ? error.message : String(error)}`);
18
+ process.exit(1);
19
+ });
@@ -0,0 +1,80 @@
1
+ import { spawn } from "node:child_process";
2
+ import { stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ export const INSTALL_TIMEOUT_MS = 3 * 60 * 1000;
5
+ const OUTPUT_LIMIT = 30_000;
6
+ const KILL_GRACE_MS = 5_000;
7
+ export function shadcnArgs(itemUrl, overwrite) {
8
+ return ["shadcn@latest", "add", itemUrl, "--yes", ...(overwrite ? ["--overwrite"] : [])];
9
+ }
10
+ export function npxInvocation(args) {
11
+ if (process.platform === "win32") {
12
+ const npxCli = path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npx-cli.js");
13
+ return { command: process.execPath, args: [npxCli, ...args] };
14
+ }
15
+ return { command: "npx", args };
16
+ }
17
+ export async function validateProjectDir(projectDir) {
18
+ if (!path.isAbsolute(projectDir)) {
19
+ return `projectDir must be an absolute path, got "${projectDir}". Pass the full path to the root of the React project (the folder that contains package.json).`;
20
+ }
21
+ const dirStat = await stat(projectDir).catch(() => undefined);
22
+ if (!dirStat) {
23
+ return `projectDir "${projectDir}" does not exist. Pass the full path to the root of an existing React project.`;
24
+ }
25
+ if (!dirStat.isDirectory()) {
26
+ return `projectDir "${projectDir}" is a file, not a directory. Pass the project root folder that contains package.json.`;
27
+ }
28
+ const manifest = await stat(path.join(projectDir, "package.json")).catch(() => undefined);
29
+ if (!manifest?.isFile()) {
30
+ return `No package.json found in "${projectDir}". Point projectDir at the root of the React project, or create the project first.`;
31
+ }
32
+ return undefined;
33
+ }
34
+ export async function findInstalledFile(projectDir, target) {
35
+ const candidates = [path.join(projectDir, target), path.join(projectDir, "src", target)];
36
+ for (const candidate of candidates) {
37
+ const found = await stat(candidate).catch(() => undefined);
38
+ if (found?.isFile())
39
+ return candidate;
40
+ }
41
+ return undefined;
42
+ }
43
+ export const runCommand = (command, args, options) => new Promise((resolve) => {
44
+ let output = "";
45
+ let timedOut = false;
46
+ let settled = false;
47
+ let killTimer;
48
+ const append = (chunk) => {
49
+ output += chunk.toString("utf8");
50
+ if (output.length > OUTPUT_LIMIT * 2) {
51
+ output = output.slice(-OUTPUT_LIMIT);
52
+ }
53
+ };
54
+ const child = spawn(command, args, {
55
+ cwd: options.cwd,
56
+ shell: false,
57
+ stdio: ["ignore", "pipe", "pipe"],
58
+ env: { ...process.env, npm_config_yes: "true" },
59
+ windowsHide: true,
60
+ });
61
+ const finish = (result) => {
62
+ if (settled)
63
+ return;
64
+ settled = true;
65
+ clearTimeout(timer);
66
+ if (killTimer)
67
+ clearTimeout(killTimer);
68
+ const trimmed = output.length > OUTPUT_LIMIT ? `[output truncated]\n${output.slice(-OUTPUT_LIMIT)}` : output;
69
+ resolve({ ...result, timedOut, output: trimmed });
70
+ };
71
+ const timer = setTimeout(() => {
72
+ timedOut = true;
73
+ child.kill("SIGTERM");
74
+ killTimer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
75
+ }, options.timeoutMs);
76
+ child.stdout.on("data", append);
77
+ child.stderr.on("data", append);
78
+ child.on("error", (error) => finish({ exitCode: null, signal: null, spawnError: error.message }));
79
+ child.on("close", (exitCode, signal) => finish({ exitCode, signal }));
80
+ });
package/dist/server.js ADDED
@@ -0,0 +1,258 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
3
+ import { z } from "zod";
4
+ import { CATEGORIES, Catalog, importLine } from "./catalog.js";
5
+ import { INSTALL_TIMEOUT_MS, findInstalledFile, npxInvocation, runCommand, shadcnArgs, validateProjectDir, } from "./install.js";
6
+ export const SERVER_NAME = "hyperblur";
7
+ export const SERVER_VERSION = "0.1.0";
8
+ const CATEGORY_GUIDE = "text = animated typography (glitch, neon, decode, wave, motion blur); motion = reveals and movement (pixel and shutter reveals, speed lines); overlay = full-surface layers drawn over content (scanlines, film grain, VHS, grid floor); hover = pointer interactions that wrap an element (tilt, magnetic, chromatic split).";
9
+ const SEARCH_DESCRIPTION = `Search the Hyperblur catalog of copy-paste React visual effects (glitch and neon text, motion blur, scanlines, film grain, VHS, 3D tilt, magnetic hover, pixel and shutter reveals, and more).
10
+
11
+ Use this first whenever the user asks for a visual effect, animation, text treatment, retro/cyberpunk look, or hover interaction in a React or Next.js project and you do not already know the exact effect slug. Leave "query" empty to browse the whole catalog, optionally narrowed by "category".
12
+
13
+ Matching is case-insensitive against slug, title, description and tags; every word of the query must match somewhere, and effects whose title or slug match rank first.
14
+
15
+ Returns a compact markdown list: slug, title, category, one-line description and the shadcn install command for each match, plus how many matched in total. Next step: call get_effect with a slug to get props, usage and the import line.`;
16
+ const GET_DESCRIPTION = `Get the full reference for one Hyperblur effect by slug: title, description, category, tags, a props table (name, type, default or required, description), a JSX usage example, the exact import line, npm dependencies, the shadcn install command and the docs URL.
17
+
18
+ Use this after search_effects (or when the user names an effect) and before writing code that renders the component, so the props you pass are real. It does not return the component source; use get_effect_source for that, or install_effect to add the file to a project.
19
+
20
+ An unknown slug returns an error that lists every valid slug.`;
21
+ const SOURCE_DESCRIPTION = `Get the complete TypeScript/React source of a Hyperblur effect, exactly as the shadcn registry ships it, along with the target path where the file belongs (for example components/hyperblur/glitch-text.tsx) and any npm dependencies it needs.
22
+
23
+ Use this when you need to write the component file yourself: the project does not use shadcn, has no components.json, running npx is not possible, or install_effect failed. Write the content to the target path (under src/ if the project keeps components there) and install any listed dependencies. Prefer install_effect when shadcn is available.`;
24
+ const INSTALL_DESCRIPTION = `Install a Hyperblur effect into a local React project by running "npx shadcn@latest add <registry-url> --yes" in projectDir. This writes the component file into the project (normally components/hyperblur/<slug>.tsx, or under src/ depending on components.json) and installs its npm dependencies.
25
+
26
+ Use this when the user wants the effect added to a project on this machine and the project uses shadcn (has a components.json, or can be initialized by shadcn). projectDir must be the absolute path of the project root containing package.json; nothing runs if it does not. Existing files are kept unless overwrite is true. The command can take up to 3 minutes.
27
+
28
+ Returns the exit status, the combined shadcn output, and the path of the installed file. If it fails, read the output, or fall back to get_effect_source and write the file by hand.`;
29
+ export function createServer(options) {
30
+ const { catalog } = options;
31
+ const runner = options.runner ?? runCommand;
32
+ const installTimeoutMs = options.installTimeoutMs ?? INSTALL_TIMEOUT_MS;
33
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION, title: "Hyperblur" }, {
34
+ instructions: "Hyperblur is a catalog of copy-paste React visual effects distributed through the shadcn registry. Find effects with search_effects, read props and usage with get_effect, then add them with install_effect (runs shadcn) or get_effect_source (write the file yourself). Components are imported from @/components/hyperblur/<slug>.",
35
+ });
36
+ server.registerTool("search_effects", {
37
+ title: "Search Hyperblur effects",
38
+ description: SEARCH_DESCRIPTION,
39
+ inputSchema: {
40
+ query: z
41
+ .string()
42
+ .max(200)
43
+ .optional()
44
+ .describe('Words to look for, e.g. "glitch", "retro crt", "hover tilt", "text reveal". Omit to list everything.'),
45
+ category: z.enum(CATEGORIES).optional().describe(`Restrict results to one category. ${CATEGORY_GUIDE}`),
46
+ limit: z.number().int().min(1).max(50).default(10).describe("Maximum number of effects to return (1-50, default 10)."),
47
+ },
48
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
49
+ }, async ({ query, category, limit }) => guard(async () => {
50
+ const { total, results } = await catalog.search({
51
+ ...(query !== undefined ? { query } : {}),
52
+ ...(category !== undefined ? { category } : {}),
53
+ limit,
54
+ });
55
+ return text(formatSearch(results, total, query, category));
56
+ }));
57
+ server.registerTool("get_effect", {
58
+ title: "Get Hyperblur effect details",
59
+ description: GET_DESCRIPTION,
60
+ inputSchema: {
61
+ slug: z.string().min(1).describe('Effect slug from search_effects, e.g. "glitch-text" or "tilt-card".'),
62
+ },
63
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
64
+ }, async ({ slug }) => guard(async () => text(formatEffect(await catalog.getEffect(slug)))));
65
+ server.registerTool("get_effect_source", {
66
+ title: "Get Hyperblur effect source",
67
+ description: SOURCE_DESCRIPTION,
68
+ inputSchema: {
69
+ slug: z.string().min(1).describe('Effect slug from search_effects, e.g. "glitch-text".'),
70
+ },
71
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
72
+ }, async ({ slug }) => guard(async () => text(formatSource(await catalog.getSource(slug)))));
73
+ server.registerTool("install_effect", {
74
+ title: "Install Hyperblur effect",
75
+ description: INSTALL_DESCRIPTION,
76
+ inputSchema: {
77
+ slug: z.string().min(1).describe('Effect slug from search_effects, e.g. "glitch-text".'),
78
+ projectDir: z
79
+ .string()
80
+ .min(1)
81
+ .describe('Absolute path to the React project root that contains package.json, e.g. "/Users/me/code/my-app".'),
82
+ overwrite: z
83
+ .boolean()
84
+ .default(false)
85
+ .describe("Replace the component file if it already exists. Default false keeps existing files."),
86
+ },
87
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
88
+ }, async ({ slug, projectDir, overwrite }) => guard(async () => {
89
+ const problem = await validateProjectDir(projectDir);
90
+ if (problem)
91
+ return error(problem);
92
+ const effect = await catalog.getEffect(slug);
93
+ const args = shadcnArgs(effect.file, overwrite);
94
+ const invocation = npxInvocation(args);
95
+ const result = await runner(invocation.command, invocation.args, { cwd: projectDir, timeoutMs: installTimeoutMs });
96
+ const target = (await catalog
97
+ .getSource(effect.slug)
98
+ .then((source) => source.files[0]?.target)
99
+ .catch(() => undefined)) ?? `components/hyperblur/${effect.slug}.tsx`;
100
+ const installed = await findInstalledFile(projectDir, target);
101
+ const commandLine = `npx ${args.join(" ")}`;
102
+ const lines = [];
103
+ const succeeded = result.exitCode === 0 && !result.timedOut && !result.spawnError;
104
+ if (succeeded) {
105
+ lines.push(`Installed **${effect.title}** (\`${effect.slug}\`) into ${projectDir}.`);
106
+ }
107
+ else if (result.spawnError) {
108
+ lines.push(`Could not start npx: ${result.spawnError}. Make sure Node.js and npm are installed and on PATH, or use get_effect_source to write the file by hand.`);
109
+ }
110
+ else if (result.timedOut) {
111
+ lines.push(`The shadcn command did not finish within ${Math.round(installTimeoutMs / 1000)} seconds and was stopped.`);
112
+ }
113
+ else {
114
+ lines.push(`The shadcn command failed (exit code ${result.exitCode ?? `signal ${result.signal ?? "unknown"}`}). Check the output below; use get_effect_source to write the file by hand if shadcn is not set up in this project.`);
115
+ }
116
+ lines.push("", `- Command: \`${commandLine}\``, `- Working directory: \`${projectDir}\``);
117
+ if (installed) {
118
+ lines.push(`- Installed file: \`${installed}\``);
119
+ }
120
+ else if (succeeded) {
121
+ lines.push(`- Installed file: expected at \`${target}\` relative to your components alias (see components.json)`);
122
+ }
123
+ if (succeeded) {
124
+ lines.push(`- Import: \`${importLine(effect)}\``);
125
+ if (effect.usage)
126
+ lines.push(`- Usage: \`${effect.usage}\``);
127
+ }
128
+ lines.push("", "Output:", "```", result.output.trim() || "(no output)", "```");
129
+ return succeeded ? text(lines.join("\n")) : error(lines.join("\n"));
130
+ }));
131
+ server.registerResource("catalog", "hyperblur://catalog", {
132
+ title: "Hyperblur catalog",
133
+ description: "The full Hyperblur catalog index as JSON: every effect with its props, usage, install command and docs URL.",
134
+ mimeType: "application/json",
135
+ }, async (uri) => ({
136
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(await catalog.getIndex(), null, 2) }],
137
+ }));
138
+ server.registerPrompt("add_effect", {
139
+ title: "Add a Hyperblur effect",
140
+ description: "Add a Hyperblur effect to the current project: look it up, then install it or write its source.",
141
+ argsSchema: {
142
+ slug: completable(z.string().describe('Effect slug, e.g. "glitch-text".'), async (value) => {
143
+ const slugs = await catalog.listSlugs().catch(() => []);
144
+ const prefix = (value ?? "").toLowerCase();
145
+ return slugs.filter((slug) => slug.startsWith(prefix));
146
+ }),
147
+ },
148
+ }, ({ slug }) => ({
149
+ messages: [
150
+ {
151
+ role: "user",
152
+ content: {
153
+ type: "text",
154
+ text: [
155
+ `Add the Hyperblur effect "${slug}" to this project.`,
156
+ "",
157
+ `1. Call get_effect with slug "${slug}" to read its props, usage and import line.`,
158
+ "2. If the project uses shadcn (it has a components.json), call install_effect with the absolute path of the project root as projectDir.",
159
+ "3. Otherwise, or if the install fails, call get_effect_source and write the file to its target path yourself, then install any listed dependencies.",
160
+ "4. Render the component where it fits, using only the props listed by get_effect.",
161
+ ].join("\n"),
162
+ },
163
+ },
164
+ ],
165
+ }));
166
+ return server;
167
+ }
168
+ export function formatSearch(results, total, query, category) {
169
+ const scope = [query ? `"${query}"` : undefined, category ? `category ${category}` : undefined].filter(Boolean).join(", ");
170
+ if (results.length === 0) {
171
+ return `No Hyperblur effects match ${scope || "your search"}. Try a broader word (for example "text", "blur", "retro", "hover"), drop the category filter, or call search_effects with no query to list everything.`;
172
+ }
173
+ const heading = results.length === total
174
+ ? `Found ${total} Hyperblur effect${total === 1 ? "" : "s"}${scope ? ` for ${scope}` : ""}:`
175
+ : `Showing ${results.length} of ${total} Hyperblur effects${scope ? ` for ${scope}` : ""} (raise limit to see more):`;
176
+ const items = results.map((effect) => `- **${effect.title}** \`${effect.slug}\` (${effect.category}): ${effect.description}\n Install: \`${effect.install}\``);
177
+ return [heading, "", ...items, "", "Call get_effect with a slug for props, usage and the import line."].join("\n");
178
+ }
179
+ export function formatEffect(effect) {
180
+ const lines = [
181
+ `# ${effect.title} (\`${effect.slug}\`)`,
182
+ "",
183
+ effect.description,
184
+ "",
185
+ `- Category: ${effect.category}`,
186
+ `- Tags: ${effect.tags.length > 0 ? effect.tags.join(", ") : "none"}`,
187
+ `- Component: \`${effect.component}\``,
188
+ `- Import: \`${importLine(effect)}\``,
189
+ `- Install: \`${effect.install}\``,
190
+ `- npm dependencies: ${effect.dependencies.length > 0 ? effect.dependencies.join(", ") : "none"}`,
191
+ `- Docs: ${effect.docs}`,
192
+ "",
193
+ "## Props",
194
+ "",
195
+ ];
196
+ if (effect.props.length === 0) {
197
+ lines.push("This component takes no props of its own.");
198
+ }
199
+ else {
200
+ lines.push("| Prop | Type | Default | Description |", "| --- | --- | --- | --- |");
201
+ for (const prop of effect.props) {
202
+ lines.push(`| \`${prop.name}\` | \`${cell(prop.type)}\` | ${defaultCell(prop)} | ${cell(prop.description)} |`);
203
+ }
204
+ }
205
+ if (effect.usage) {
206
+ lines.push("", "## Usage", "", "```tsx", importLine(effect), "", effect.usage, "```");
207
+ }
208
+ lines.push("", "Next: install_effect adds the file with shadcn; get_effect_source returns the source to write by hand.");
209
+ return lines.join("\n");
210
+ }
211
+ export function formatSource(source) {
212
+ const { effect } = source;
213
+ const lines = [
214
+ `# Source for ${effect.title} (\`${effect.slug}\`)`,
215
+ "",
216
+ `- Import after writing the file: \`${importLine(effect)}\``,
217
+ `- npm dependencies: ${source.dependencies.length > 0 ? `${source.dependencies.join(", ")} (install them with the project's package manager)` : "none"}`,
218
+ ];
219
+ if (source.devDependencies.length > 0) {
220
+ lines.push(`- npm dev dependencies: ${source.devDependencies.join(", ")}`);
221
+ }
222
+ if (source.registryDependencies.length > 0) {
223
+ lines.push(`- shadcn registry dependencies: ${source.registryDependencies.join(", ")}`);
224
+ }
225
+ lines.push("- Place the file at the target path below, relative to the project root (use src/ as a prefix if the project keeps components under src/).");
226
+ for (const file of source.files) {
227
+ lines.push("", `## ${file.target}`, "", `\`\`\`${fence(file.target)}`, file.content.trimEnd(), "```");
228
+ }
229
+ return lines.join("\n");
230
+ }
231
+ function defaultCell(prop) {
232
+ if (prop.required)
233
+ return "required";
234
+ if (prop.default !== undefined)
235
+ return `\`${cell(prop.default)}\``;
236
+ return "optional";
237
+ }
238
+ function cell(value) {
239
+ return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
240
+ }
241
+ function fence(target) {
242
+ const extension = target.split(".").pop() ?? "";
243
+ return ["tsx", "ts", "jsx", "js", "css", "json"].includes(extension) ? extension : "";
244
+ }
245
+ function text(value) {
246
+ return { content: [{ type: "text", text: value }] };
247
+ }
248
+ function error(value) {
249
+ return { content: [{ type: "text", text: value }], isError: true };
250
+ }
251
+ async function guard(run) {
252
+ try {
253
+ return await run();
254
+ }
255
+ catch (caught) {
256
+ return error(caught instanceof Error ? caught.message : String(caught));
257
+ }
258
+ }
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@hyperblur/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Hyperblur: search, inspect and install copy-paste React visual effects from any MCP client.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "homepage": "https://hyperblur.dev",
8
+ "keywords": [
9
+ "mcp",
10
+ "model-context-protocol",
11
+ "hyperblur",
12
+ "react",
13
+ "shadcn",
14
+ "effects"
15
+ ],
16
+ "bin": {
17
+ "hyperblur-mcp": "dist/cli.js"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "scripts": {
26
+ "clean": "node -e \"process.getBuiltinModule('fs').rmSync('dist', { recursive: true, force: true })\"",
27
+ "build": "npm run clean && tsc -p tsconfig.json && node -e \"process.getBuiltinModule('fs').chmodSync('dist/cli.js', 0o755)\"",
28
+ "dev": "node --watch src/cli.ts",
29
+ "typecheck": "tsc -p tsconfig.test.json",
30
+ "test": "npm run typecheck && node --test \"test/**/*.test.ts\"",
31
+ "start": "node dist/cli.js",
32
+ "prepublishOnly": "npm run build && npm test"
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/sdk": "^1.32.2",
36
+ "zod": "^4.6.7"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^26.6.6",
40
+ "typescript": "^7.0.2"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/Hyperblur/Hyperblur.git",
45
+ "directory": "packages/mcp"
46
+ },
47
+ "bugs": {
48
+ "url": "https://github.com/Hyperblur/Hyperblur/issues"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }