@webspire/mcp 0.9.0 → 0.11.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.
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { loadFonts, searchSnippets } from './registry.js';
3
- import { CONTENT_NEED_KEYS, CONTENT_NEED_LABEL, composePage, DOMAIN_KEYS, DOMAIN_LABEL, normalizeContentNeed, recommendFonts, searchCanvasEffects, searchPatterns, TONE_KEYS, TONE_LABEL, UX_GOAL_KEYS, UX_GOAL_LABEL, } from './search.js';
3
+ import { CONTENT_NEED_KEYS, CONTENT_NEED_LABEL, composePage, DOMAIN_KEYS, DOMAIN_LABEL, normalizeContentNeed, recommendFonts, searchCanvasEffects, searchMotionRecipes, searchPatterns, TONE_KEYS, TONE_LABEL, UX_GOAL_KEYS, UX_GOAL_LABEL, } from './search.js';
4
4
  function formatSnippetBrief(s) {
5
5
  return [
6
6
  `**${s.title}** (${s.id})`,
@@ -785,6 +785,133 @@ export function registerToolsWithProvider(server, getRegistry) {
785
785
  content: [{ type: 'text', text }],
786
786
  };
787
787
  });
788
+ // --- Motion Recipe Tools ---
789
+ function formatMotionBrief(r) {
790
+ return [
791
+ `**${r.title}** (${r.id})`,
792
+ ` ${r.summary}`,
793
+ ` Family: ${r.family} | Technology: ${r.technology} | Triggers: ${r.triggers.join(', ')}`,
794
+ r.tags.length > 0 ? ` Tags: ${r.tags.join(', ')}` : '',
795
+ ]
796
+ .filter(Boolean)
797
+ .join('\n');
798
+ }
799
+ function formatMotionFull(r) {
800
+ const deps = r.dependencies
801
+ .map((d) => ` ${d.name} v${d.version} — ${d.cdn}`)
802
+ .join('\n');
803
+ return [
804
+ `# ${r.title}`,
805
+ '',
806
+ r.description ?? r.summary,
807
+ '',
808
+ '## Identity',
809
+ `ID: ${r.id}`,
810
+ `Family: ${r.family}`,
811
+ `Technology: ${r.technology}`,
812
+ `Triggers: ${r.triggers.join(', ')}`,
813
+ '',
814
+ r.tags.length > 0 ? `## Tags\n${r.tags.join(', ')}` : '',
815
+ deps ? `## Dependencies\n${deps}` : '',
816
+ '',
817
+ '## HTML',
818
+ '```html',
819
+ r.html,
820
+ '```',
821
+ ]
822
+ .filter((l) => l !== undefined)
823
+ .join('\n');
824
+ }
825
+ server.tool('list_motion_recipes', 'List all available Motion Recipes grouped by family (reveal, scroll, transition, micro, layout).', {}, async () => {
826
+ const registry = await getRegistry();
827
+ const recipes = registry.motionRecipes ?? [];
828
+ const counts = {};
829
+ for (const r of recipes) {
830
+ counts[r.family] = (counts[r.family] ?? 0) + 1;
831
+ }
832
+ const result = Object.entries(counts)
833
+ .sort(([a], [b]) => a.localeCompare(b))
834
+ .map(([fam, count]) => `${fam}: ${count} recipe${count > 1 ? 's' : ''}`)
835
+ .join('\n');
836
+ return {
837
+ content: [
838
+ {
839
+ type: 'text',
840
+ text: `${recipes.length} motion recipes across ${Object.keys(counts).length} families:\n\n${result}`,
841
+ },
842
+ ],
843
+ };
844
+ });
845
+ server.tool('search_motion_recipes', 'Search Webspire Motion Recipes by keyword, family, technology, or trigger. Returns matching recipes with descriptions.', {
846
+ query: z
847
+ .string()
848
+ .describe('Search query, e.g. "scroll reveal", "fade up", "stagger", "clip-path"'),
849
+ family: z
850
+ .string()
851
+ .optional()
852
+ .describe('Filter by family: reveal, scroll, transition, micro, layout'),
853
+ technology: z
854
+ .string()
855
+ .optional()
856
+ .describe('Filter by technology: gsap, motion-one, both'),
857
+ trigger: z
858
+ .string()
859
+ .optional()
860
+ .describe('Filter by trigger: scroll, hover, load, click'),
861
+ }, async ({ query, family, technology, trigger }) => {
862
+ const registry = await getRegistry();
863
+ const recipes = registry.motionRecipes ?? [];
864
+ if (recipes.length === 0) {
865
+ return {
866
+ content: [
867
+ {
868
+ type: 'text',
869
+ text: 'No motion recipes available in registry.',
870
+ },
871
+ ],
872
+ };
873
+ }
874
+ const results = searchMotionRecipes(recipes, { query, family, technology, trigger });
875
+ if (results.length === 0) {
876
+ return {
877
+ content: [
878
+ {
879
+ type: 'text',
880
+ text: 'No motion recipes found matching your query.',
881
+ },
882
+ ],
883
+ };
884
+ }
885
+ return {
886
+ content: [
887
+ {
888
+ type: 'text',
889
+ text: `Found ${results.length} motion recipe${results.length > 1 ? 's' : ''}:\n\n${results.map(formatMotionBrief).join('\n\n')}\n\nUse \`get_motion_recipe(id)\` for full HTML source.`,
890
+ },
891
+ ],
892
+ };
893
+ });
894
+ server.tool('get_motion_recipe', 'Get full HTML source and dependency info for a specific Motion Recipe. Returns a self-contained HTML file.', {
895
+ id: z.string().describe('Recipe ID, e.g. "reveal/fade-up", "reveal/clip-reveal"'),
896
+ }, async ({ id }) => {
897
+ const registry = await getRegistry();
898
+ const recipes = registry.motionRecipes ?? [];
899
+ const recipe = recipes.find((r) => r.id === id);
900
+ if (!recipe) {
901
+ const available = recipes.map((r) => r.id).join(', ');
902
+ return {
903
+ content: [
904
+ {
905
+ type: 'text',
906
+ text: `Motion recipe "${id}" not found. Available: ${available}`,
907
+ },
908
+ ],
909
+ };
910
+ }
911
+ return {
912
+ content: [{ type: 'text', text: formatMotionFull(recipe) }],
913
+ };
914
+ });
788
915
  // --- Canvas Effect Tools ---
789
916
  server.tool('search_canvas_effects', 'Search Webspire Canvas effects by keyword, category, or capability. Returns matching effects with their descriptions.', {
790
917
  query: z
@@ -7,9 +7,15 @@ export interface RegistryOptions {
7
7
  }
8
8
  /**
9
9
  * Load the registry with fallback chain:
10
- * 1. Explicit --registry-file path
11
- * 2. Bundled data/registry.json (shipped with npm package)
12
- * 3. Remote fetch from --registry-url or default URL
10
+ * 1. Explicit --registry-file path (dev / CI override)
11
+ * 2. In-memory cache (5-min TTL, cleared when MCP process restarts)
12
+ * 3. Remote fetch from webspire.de if newer than disk cache, writes to disk
13
+ * 4. Disk cache (~/.cache/webspire/registry.json) — persists across restarts,
14
+ * always holds the last successfully fetched remote version
15
+ * 5. Bundled data/registry.json — frozen at npm publish time, last resort
16
+ *
17
+ * The date comparison (registry.generated) ensures the disk cache is only
18
+ * overwritten when the remote actually has newer content.
13
19
  */
14
20
  export declare function loadRegistry(options?: RegistryOptions): Promise<Registry>;
15
21
  /**
package/dist/registry.js CHANGED
@@ -1,19 +1,16 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { readFile } from 'node:fs/promises';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
3
4
  import { dirname, resolve } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { searchSnippets as searchEntries } from './search.js';
6
7
  const DEFAULT_REGISTRY_URL = 'https://webspire.de/registry.json';
7
8
  const CACHE_TTL_MS = 5 * 60 * 1000;
8
- let cache = null;
9
- /**
10
- * Resolve the path to the bundled registry.json shipped with this package.
11
- * Works both from src/ (development) and dist/ (published).
12
- */
9
+ const DISK_CACHE_PATH = resolve(homedir(), '.cache', 'webspire', 'registry.json');
10
+ let memCache = null;
13
11
  function getBundledRegistryPath() {
14
12
  try {
15
13
  const thisDir = dirname(fileURLToPath(import.meta.url));
16
- // From dist/ → ../data/registry.json, from src/ → ../data/registry.json
17
14
  const candidate = resolve(thisDir, '..', 'data', 'registry.json');
18
15
  return existsSync(candidate) ? candidate : undefined;
19
16
  }
@@ -21,11 +18,39 @@ function getBundledRegistryPath() {
21
18
  return undefined;
22
19
  }
23
20
  }
21
+ async function readDiskCache() {
22
+ try {
23
+ const raw = await readFile(DISK_CACHE_PATH, 'utf-8');
24
+ return JSON.parse(raw);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ async function writeDiskCache(registry) {
31
+ try {
32
+ await mkdir(dirname(DISK_CACHE_PATH), { recursive: true });
33
+ await writeFile(DISK_CACHE_PATH, JSON.stringify(registry), 'utf-8');
34
+ }
35
+ catch {
36
+ // Best-effort — disk write failures are non-fatal
37
+ }
38
+ }
39
+ /** Returns true if registry a was generated after registry b. */
40
+ function isNewer(a, b) {
41
+ return new Date(a.generated ?? 0).getTime() > new Date(b.generated ?? 0).getTime();
42
+ }
24
43
  /**
25
44
  * Load the registry with fallback chain:
26
- * 1. Explicit --registry-file path
27
- * 2. Bundled data/registry.json (shipped with npm package)
28
- * 3. Remote fetch from --registry-url or default URL
45
+ * 1. Explicit --registry-file path (dev / CI override)
46
+ * 2. In-memory cache (5-min TTL, cleared when MCP process restarts)
47
+ * 3. Remote fetch from webspire.de if newer than disk cache, writes to disk
48
+ * 4. Disk cache (~/.cache/webspire/registry.json) — persists across restarts,
49
+ * always holds the last successfully fetched remote version
50
+ * 5. Bundled data/registry.json — frozen at npm publish time, last resort
51
+ *
52
+ * The date comparison (registry.generated) ensures the disk cache is only
53
+ * overwritten when the remote actually has newer content.
29
54
  */
30
55
  export async function loadRegistry(options) {
31
56
  // 1. Explicit file path
@@ -33,24 +58,42 @@ export async function loadRegistry(options) {
33
58
  const raw = await readFile(options.filePath, 'utf-8');
34
59
  return JSON.parse(raw);
35
60
  }
36
- // 2. Bundled registry (no cache needed file is local and fast)
61
+ // 2. In-memory cache (avoids re-fetching within the same session)
62
+ if (memCache && Date.now() - memCache.loadedAt < CACHE_TTL_MS) {
63
+ return memCache.registry;
64
+ }
65
+ // 3. Remote fetch — compare with disk cache, write if newer
66
+ try {
67
+ const url = options?.url ?? DEFAULT_REGISTRY_URL;
68
+ const response = await fetch(url);
69
+ if (response.ok) {
70
+ const remote = (await response.json());
71
+ memCache = { registry: remote, loadedAt: Date.now() };
72
+ // Write to disk only when remote is actually newer than what's cached
73
+ const disk = await readDiskCache();
74
+ if (!disk || isNewer(remote, disk)) {
75
+ await writeDiskCache(remote);
76
+ }
77
+ return remote;
78
+ }
79
+ }
80
+ catch {
81
+ // Network unavailable — fall through to disk cache
82
+ }
83
+ // 4. Disk cache — last successfully fetched version, survives process restarts
84
+ const disk = await readDiskCache();
85
+ if (disk) {
86
+ memCache = { registry: disk, loadedAt: Date.now() };
87
+ return disk;
88
+ }
89
+ // 5. Bundled registry — frozen at npm publish time
37
90
  const bundledPath = getBundledRegistryPath();
38
- if (bundledPath && !options?.url) {
91
+ if (bundledPath) {
39
92
  const raw = await readFile(bundledPath, 'utf-8');
40
93
  return JSON.parse(raw);
41
94
  }
42
- // 3. Remote fetch with TTL cache
43
- if (cache && Date.now() - cache.loadedAt < CACHE_TTL_MS) {
44
- return cache.registry;
45
- }
46
- const url = options?.url ?? DEFAULT_REGISTRY_URL;
47
- const response = await fetch(url);
48
- if (!response.ok) {
49
- throw new Error(`Failed to fetch registry from ${url}: ${response.statusText}`);
50
- }
51
- const registry = (await response.json());
52
- cache = { registry, loadedAt: Date.now() };
53
- return registry;
95
+ throw new Error('Registry unavailable: network unreachable and no local cache found. ' +
96
+ 'Try reinstalling @webspire/mcp or check your internet connection.');
54
97
  }
55
98
  /**
56
99
  * Load the bundled fonts.json shipped with this package.
package/dist/search.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CanvasEffectEntry, FontRecommendation, FontsData, PatternEntry, SnippetEntry } from './types.js';
1
+ import type { CanvasEffectEntry, FontRecommendation, FontsData, MotionRecipeEntry, PatternEntry, SnippetEntry } from './types.js';
2
2
  export interface SnippetSearchOptions {
3
3
  query: string;
4
4
  category?: string;
@@ -61,3 +61,11 @@ export interface CanvasSearchOptions {
61
61
  limit?: number;
62
62
  }
63
63
  export declare function searchCanvasEffects(effects: CanvasEffectEntry[], options: CanvasSearchOptions): CanvasEffectEntry[];
64
+ export interface MotionSearchOptions {
65
+ query: string;
66
+ family?: string;
67
+ technology?: string;
68
+ trigger?: string;
69
+ limit?: number;
70
+ }
71
+ export declare function searchMotionRecipes(recipes: MotionRecipeEntry[], options: MotionSearchOptions): MotionRecipeEntry[];
package/dist/search.js CHANGED
@@ -999,3 +999,48 @@ export function searchCanvasEffects(effects, options) {
999
999
  .sort((a, b) => b.score - a.score);
1000
1000
  return scored.slice(0, limit).map(({ effect }) => effect);
1001
1001
  }
1002
+ export function searchMotionRecipes(recipes, options) {
1003
+ const { query, family, technology, trigger, limit = 10 } = options;
1004
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
1005
+ const stems = words.map(stem);
1006
+ const expanded = expandWithSynonyms(words);
1007
+ if (stems.length === 0 && !family && !technology && !trigger)
1008
+ return recipes.slice(0, limit);
1009
+ const filtered = recipes.filter((r) => {
1010
+ if (family && r.family !== family)
1011
+ return false;
1012
+ if (technology && r.technology !== technology)
1013
+ return false;
1014
+ if (trigger && !r.triggers.includes(trigger))
1015
+ return false;
1016
+ return true;
1017
+ });
1018
+ if (stems.length === 0)
1019
+ return filtered.slice(0, limit);
1020
+ const scored = filtered
1021
+ .map((recipe) => {
1022
+ let score = 0;
1023
+ const idLower = recipe.id.toLowerCase();
1024
+ for (const term of stems) {
1025
+ if (idLower.includes(term))
1026
+ score += 5;
1027
+ }
1028
+ const title = recipe.title.toLowerCase();
1029
+ score += stems.filter((w) => title.includes(w)).length * 3;
1030
+ const summary = recipe.summary.toLowerCase();
1031
+ score += stems.filter((w) => summary.includes(w)).length * 2;
1032
+ const tagStr = recipe.tags.join(' ').toLowerCase();
1033
+ score += stems.filter((w) => tagStr.includes(w)).length * 2;
1034
+ const tech = recipe.technology.toLowerCase();
1035
+ score += stems.filter((w) => tech.includes(w)).length * 2;
1036
+ const synonymOnly = expanded.filter((t) => !stems.includes(t));
1037
+ if (synonymOnly.length > 0) {
1038
+ const allText = [title, summary, tagStr].join(' ').toLowerCase();
1039
+ score += synonymOnly.filter((w) => allText.includes(w)).length;
1040
+ }
1041
+ return { recipe, score };
1042
+ })
1043
+ .filter(({ score }) => score > 0)
1044
+ .sort((a, b) => b.score - a.score);
1045
+ return scored.slice(0, limit).map(({ recipe }) => recipe);
1046
+ }
package/dist/types.d.ts CHANGED
@@ -226,6 +226,28 @@ export interface CanvasEffectEntry {
226
226
  solves: string[];
227
227
  js: string;
228
228
  }
229
+ export interface MotionRecipeEntry {
230
+ id: string;
231
+ title: string;
232
+ summary: string;
233
+ description?: string;
234
+ family: 'reveal' | 'scroll' | 'transition' | 'micro' | 'layout';
235
+ variant: string;
236
+ technology: 'gsap' | 'motion-one' | 'both';
237
+ triggers: Array<'scroll' | 'hover' | 'load' | 'click'>;
238
+ tags: string[];
239
+ dependencies: Array<{
240
+ name: string;
241
+ cdn: string;
242
+ version: string;
243
+ }>;
244
+ governance: {
245
+ status: 'draft' | 'review' | 'published' | 'deprecated';
246
+ quality: 'experimental' | 'stable' | 'flagship';
247
+ updatedAt: string;
248
+ };
249
+ html: string;
250
+ }
229
251
  export interface Registry {
230
252
  version: string;
231
253
  generated: string;
@@ -233,6 +255,7 @@ export interface Registry {
233
255
  patterns?: PatternEntry[];
234
256
  templates?: TemplateEntry[];
235
257
  canvasEffects?: CanvasEffectEntry[];
258
+ motionRecipes?: MotionRecipeEntry[];
236
259
  fonts?: FontsData;
237
260
  mountCanvas?: string;
238
261
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@webspire/mcp",
3
- "version": "0.9.0",
4
- "description": "MCP server for Webspire — AI-native discovery of CSS snippets, UI patterns, canvas effects, page templates, and font recommendations",
3
+ "version": "0.11.0",
4
+ "description": "MCP server for Webspire — AI-native discovery of CSS snippets, UI patterns, canvas effects, page templates, motion recipes, and font recommendations",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./dist/index.js",