awwwards-mcp 1.3.0 → 1.4.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
@@ -9,12 +9,16 @@ sourced from the web's best award-winning websites.
9
9
  Your agent searches in natural language ("dark 3D portfolio sites", "soft pastel
10
10
  e-commerce"), sees **real screenshots inline**, and can pull the **design DNA**
11
11
  of any site: color palette, tech stack, design elements, award history.
12
+ Free-text queries run on a porter-stemmed, prefix-matching FTS5 index with BM25
13
+ ranking — "magazines" now finds Magazine-tagged sites (68 on the live index),
14
+ best matches first, where the old substring path returned zero. Multi-word
15
+ queries keep AND semantics: every token must hit the same site.
12
16
 
13
17
  ## Tools
14
18
 
15
19
  | Tool | What it does |
16
20
  |------|--------------|
17
- | `search_sites` | Search by color, tags, technology or award type. Returns site cards with inline screenshots. |
21
+ | `search_sites` | Search by color, tags, technology, award type or free-text query. Multi-word queries match against the local FTS5 index and rank BM25 (title hits lead); zero results come with loose-match and taxonomy-tag hints. Returns site cards with inline screenshots. |
18
22
  | `get_site_details` | Full design DNA for one site: palette, technologies, elements, awards, description. |
19
23
  | `get_site_elements` | Component-level visuals for one site: each element's poster image inline (3D models, video content, mobile layouts, microcopy…) + video URLs. |
20
24
  | `list_categories` | Every filter the agent can search by (200+ tags, 27 colors). |
package/dist/cache.js CHANGED
@@ -17,6 +17,28 @@ const SCHEMA = `
17
17
  key TEXT PRIMARY KEY, value TEXT, fetchedAt INTEGER
18
18
  );
19
19
  `;
20
+ // FTS5 full-text layer over the sites table (derived — rebuildable at any
21
+ // time). Probe-guarded: if this Node build ships without FTS5, every fts
22
+ // statement is skipped and searchSites returns null (server keeps the legacy
23
+ // substring path). Columns mirror sites; tags/awards stay JSON strings —
24
+ // unicode61 tokenizes around brackets/quotes, so tokens extract cleanly.
25
+ const FTS_SCHEMA = `
26
+ CREATE VIRTUAL TABLE IF NOT EXISTS sites_fts USING fts5(
27
+ slug UNINDEXED, title, tags, awards, tokenize='porter unicode61'
28
+ );
29
+ CREATE TRIGGER IF NOT EXISTS sites_fts_ai AFTER INSERT ON sites BEGIN
30
+ INSERT INTO sites_fts (slug, title, tags, awards)
31
+ VALUES (new.slug, new.title, new.tags, new.awards);
32
+ END;
33
+ CREATE TRIGGER IF NOT EXISTS sites_fts_au AFTER UPDATE OF title, tags, awards ON sites BEGIN
34
+ DELETE FROM sites_fts WHERE slug = new.slug;
35
+ INSERT INTO sites_fts (slug, title, tags, awards)
36
+ VALUES (new.slug, new.title, new.tags, new.awards);
37
+ END;
38
+ CREATE TRIGGER IF NOT EXISTS sites_fts_ad AFTER DELETE ON sites BEGIN
39
+ DELETE FROM sites_fts WHERE slug = old.slug;
40
+ END;
41
+ `;
20
42
  function rowToSite(r) {
21
43
  return {
22
44
  slug: r.slug,
@@ -34,6 +56,12 @@ export class Cache {
34
56
  dbPath;
35
57
  now;
36
58
  imagesDir;
59
+ // FTS5 capability of this Node build, probed on the first DB open (the
60
+ // constructor's eager withDb). null = not yet probed; false = FTS5 compiled
61
+ // out → searchSites returns null and callers keep the legacy substring
62
+ // path. Instance-cached so searchSites never re-probes; public so the
63
+ // server layer and tests can branch on it.
64
+ ftsAvailable = null;
37
65
  constructor(rootDir, now = Date.now) {
38
66
  this.now = now;
39
67
  mkdirSync(rootDir, { recursive: true });
@@ -52,12 +80,40 @@ export class Cache {
52
80
  const db = new DatabaseSync(this.dbPath);
53
81
  try {
54
82
  db.exec(SCHEMA);
83
+ if (this.ftsAvailable === null) {
84
+ try {
85
+ db.exec(FTS_SCHEMA);
86
+ this.ftsAvailable = true;
87
+ }
88
+ catch {
89
+ this.ftsAvailable = false; // FTS5 compiled out → legacy fallback
90
+ }
91
+ }
92
+ if (this.ftsAvailable) {
93
+ // The fts table is derived and must never gate correctness: if sites
94
+ // has rows but sites_fts is empty (pre-FTS database opened for the
95
+ // first time), rebuild the index. The guard lives inside the INSERT
96
+ // itself (slug NOT IN sites_fts), not only in the counts above: the
97
+ // counts are read non-atomically, so `npm run index` and a first open
98
+ // can both pass them and both run this statement — sites_fts.slug has
99
+ // no unique constraint, so an unguarded re-run would double-index.
100
+ // Afterwards triggers keep it synced.
101
+ const { s: sitesN } = db.prepare("SELECT COUNT(*) AS s FROM sites").get();
102
+ const { s: ftsN } = db.prepare("SELECT COUNT(*) AS s FROM sites_fts").get();
103
+ if (sitesN > 0 && ftsN === 0) {
104
+ db.exec("INSERT INTO sites_fts (slug, title, tags, awards) SELECT slug, title, tags, awards FROM sites WHERE slug NOT IN (SELECT slug FROM sites_fts)");
105
+ }
106
+ }
55
107
  return fn(db);
56
108
  }
57
109
  finally {
58
110
  db.close();
59
111
  }
60
112
  }
113
+ /** @visibleForTesting */
114
+ withDbForTest(fn) {
115
+ this.withDb(fn);
116
+ }
61
117
  upsertSites(sites) {
62
118
  this.withDb((db) => {
63
119
  const stmt = db.prepare(`INSERT INTO sites (slug, id, title, createdAt, tags, thumbnailPath, liveUrl, detailPath, awards, fetchedAt)
@@ -99,6 +155,34 @@ export class Cache {
99
155
  return rowToSite(row);
100
156
  });
101
157
  }
158
+ // Full-text search over cached sites. matchMode "AND" (default) requires
159
+ // every token; "OR" matches rows containing any token (the server uses OR
160
+ // for its zero-result "loose matches" hint). Each token is a porter-stemmed
161
+ // prefix term, so multi-word queries match rows where the words are
162
+ // scattered across title/tags/awards. Results are bm25-ascending (best
163
+ // match first). Returns null when FTS5 is unavailable on this build or the
164
+ // query has no usable tokens — callers fall back to legacy search.
165
+ searchSites(query, maxAgeMs, limit = 200, matchMode = "AND") {
166
+ // Sanitization strips quotes/parens/operators, leaving [a-z0-9-] only —
167
+ // the quoted `"tok"*` MATCH string below cannot inject FTS syntax.
168
+ const tokens = query.toLowerCase().split(/\s+/)
169
+ .map((t) => t.replace(/[^a-z0-9-]/g, ""))
170
+ .filter((t) => t.length > 0);
171
+ if (!tokens.length)
172
+ return null;
173
+ return this.withDb((db) => {
174
+ if (!this.ftsAvailable)
175
+ return null;
176
+ const match = tokens.map((t) => `"${t}"*`).join(matchMode === "OR" ? " OR " : " AND ");
177
+ const min = this.now() - maxAgeMs;
178
+ const rows = db.prepare(`SELECT s.* FROM sites_fts
179
+ JOIN sites s ON s.slug = sites_fts.slug
180
+ WHERE sites_fts MATCH ? AND s.fetchedAt > ?
181
+ ORDER BY bm25(sites_fts) ASC
182
+ LIMIT ?`).all(match, min, limit);
183
+ return rows.map(rowToSite);
184
+ });
185
+ }
102
186
  setMeta(key, value) {
103
187
  this.withDb((db) => {
104
188
  db.prepare(`INSERT INTO meta (key, value, fetchedAt) VALUES (?, ?, ?)
package/dist/server.js CHANGED
@@ -76,7 +76,12 @@ export function createHandlers(deps) {
76
76
  // filter was applied, so check every client-checkable filter. Color is
77
77
  // never client-checkable (site rows carry no colors); it is handled by
78
78
  // never serving color searches from cache (see search_sites).
79
- function matchesFilters(s, f, honorUrlSource) {
79
+ // skipQueryCheck=true: the rows already matched the free-text query through
80
+ // FTS (prefix+stem semantics); re-checking with substring semantics would
81
+ // wrongly drop stem matches ("magazines" → "Magazine"), so only the
82
+ // non-query filters run. Scraped rows keep the check (default false) — they
83
+ // never came from FTS.
84
+ function matchesFilters(s, f, honorUrlSource, skipQueryCheck = false) {
80
85
  const source = urlSource(f);
81
86
  if (f.tags?.length) {
82
87
  const tagsToCheck = honorUrlSource && source === "tag" ? f.tags.slice(1) : f.tags;
@@ -95,7 +100,7 @@ export function createHandlers(deps) {
95
100
  if (!s.awards.includes(AWARD_FILTER_LABELS[f.award]))
96
101
  return false;
97
102
  }
98
- if (f.query) {
103
+ if (f.query && !skipQueryCheck) {
99
104
  const queryTokens = tokenizeQuery(f.query);
100
105
  if (queryTokens.length) {
101
106
  const hay = (s.title + " " + s.tags.join(" ")).toLowerCase();
@@ -136,18 +141,44 @@ export function createHandlers(deps) {
136
141
  // Color can't be verified client-side (site rows carry no colors), so a
137
142
  // color search always scrapes its filter page; everything else is
138
143
  // client-checkable against the index.
139
- let sites = args.color
140
- ? []
141
- : cache.getSites(SITE_TTL_MS).filter((s) => matchesFilters(s, args, false));
144
+ let sites;
145
+ let ftsRows = false; // query served from FTS-ranked rows (bm25 order)
146
+ let ftsEmpty = false; // FTS ran and matched nothing → loose hints apply
147
+ if (args.color) {
148
+ sites = [];
149
+ }
150
+ else if (args.query) {
151
+ const ranked = cache.searchSites(args.query, SITE_TTL_MS);
152
+ if (ranked) {
153
+ ftsRows = true;
154
+ ftsEmpty = ranked.length === 0;
155
+ // FTS already applied the query (prefix+stem, bm25-ranked). Re-check
156
+ // only the non-query filters; re-checking the query here with
157
+ // substring semantics would wrongly drop prefix/stem matches.
158
+ // Array.filter preserves the bm25 order.
159
+ sites = ranked.filter((s) => matchesFilters(s, args, false, true));
160
+ }
161
+ else {
162
+ // FTS5 unavailable or no usable tokens: legacy substring path.
163
+ sites = cache.getSites(SITE_TTL_MS).filter((s) => matchesFilters(s, args, false));
164
+ }
165
+ }
166
+ else {
167
+ sites = cache.getSites(SITE_TTL_MS).filter((s) => matchesFilters(s, args, false));
168
+ }
142
169
  // A scrape normally REPLACES the matched rows (fresh rows are only
143
170
  // guaranteed the URL filter), so it must run when the cache cannot serve
144
171
  // the requested page window at all. When the window is only PARTIALLY
145
- // filled (e.g. a tokenized query matched 3 cached rows for count 6),
146
- // replacing would discard already-verified matches — those runs scrape
147
- // the filter page and MERGE instead: dedupe by slug, verified cache rows
148
- // win duplicate slugs, scraped-only rows appended, all newest-first like
149
- // getSites; the merge result is client-checked as before (cache rows
150
- // already passed matchesFilters(false), scraped rows matchesFilters(true)).
172
+ // filled (e.g. a query matched 3 cached rows for count 6), replacing
173
+ // would discard already-verified matches — those runs scrape the filter
174
+ // page and MERGE instead: dedupe by slug, verified cache rows win
175
+ // duplicate slugs, scraped-only rows appended; the merge result is
176
+ // client-checked as before (cache rows already passed matchesFilters,
177
+ // scraped rows matchesFilters(true)). Non-FTS runs sort everything
178
+ // newest-first like getSites; FTS runs keep the bm25-ranked cache rows
179
+ // first in rank order and append the scraped-only rows newest-first
180
+ // among themselves — re-sorting ranked rows by createdAt would destroy
181
+ // the ranking the query asked for.
151
182
  const pageWindowEmpty = sites.slice((page - 1) * count, page * count).length === 0;
152
183
  const pageWindowPartial = !pageWindowEmpty && sites.length < page * count;
153
184
  if (pageWindowEmpty) {
@@ -180,14 +211,26 @@ export function createHandlers(deps) {
180
211
  if (parsed.length > 0) {
181
212
  cache.upsertSites(parsed);
182
213
  const fresh = parsed.filter((s) => matchesFilters(s, args, true));
183
- const bySlug = new Map();
184
- // Scraped rows seed the map; verified cache rows then overwrite any
185
- // duplicate slug, so they always win.
186
- for (const s of fresh)
187
- bySlug.set(s.slug, s);
188
- for (const s of sites)
189
- bySlug.set(s.slug, s);
190
- sites = [...bySlug.values()].sort((a, b) => b.createdAt - a.createdAt);
214
+ if (ftsRows) {
215
+ // Ranked cache rows keep their bm25 order; scraped-only rows
216
+ // append newest-first (cache rows win duplicate slugs, as
217
+ // everywhere — a slug in the ranked set is never re-added).
218
+ const rankedSlugs = new Set(sites.map((s) => s.slug));
219
+ const scrapedOnly = fresh
220
+ .filter((s) => !rankedSlugs.has(s.slug))
221
+ .sort((a, b) => b.createdAt - a.createdAt);
222
+ sites = [...sites, ...scrapedOnly];
223
+ }
224
+ else {
225
+ const bySlug = new Map();
226
+ // Scraped rows seed the map; verified cache rows then overwrite any
227
+ // duplicate slug, so they always win.
228
+ for (const s of fresh)
229
+ bySlug.set(s.slug, s);
230
+ for (const s of sites)
231
+ bySlug.set(s.slug, s);
232
+ sites = [...bySlug.values()].sort((a, b) => b.createdAt - a.createdAt);
233
+ }
191
234
  }
192
235
  }
193
236
  catch {
@@ -197,6 +240,17 @@ export function createHandlers(deps) {
197
240
  const ordered = orderByScore(sites, args.sortBy);
198
241
  const slice = ordered.slice((page - 1) * count, page * count);
199
242
  if (slice.length === 0) {
243
+ // Loose-match hint, computed once: only for genuine FTS zero results
244
+ // (FTS ran and matched nothing) — rows dropped by the non-query
245
+ // filters are not "loose matches". Up to 3 OR-relaxed slugs.
246
+ let looseHint = "";
247
+ if (ftsEmpty && args.query) {
248
+ const orSlugs = (cache.searchSites(args.query, SITE_TTL_MS, 3, "OR") ?? [])
249
+ .map((s) => s.slug);
250
+ if (orSlugs.length > 0) {
251
+ looseHint = `\nLoose matches (any token): ${orSlugs.join(", ")}.`;
252
+ }
253
+ }
200
254
  if (sites.length === 0) {
201
255
  // True zero-result search: suggest the closest taxonomy tags. The
202
256
  // taxonomy comes from the cache only — never a live fetch just to
@@ -209,15 +263,17 @@ export function createHandlers(deps) {
209
263
  return {
210
264
  content: [
211
265
  text(`No sites matched the search. Closest filter tags: ${suggestions.join(", ")}. ` +
212
- "Run list_categories for the full taxonomy."),
266
+ `Run list_categories for the full taxonomy.${looseHint}`),
213
267
  ],
214
268
  };
215
269
  }
216
270
  }
217
271
  return {
218
272
  content: [
219
- text("No sites matched the search on this page. Try fewer filters or run list_categories. " +
220
- "(Deep pagination is unavailable by design: awwwards.com's robots.txt disallows it.)"),
273
+ text(`No sites matched the search on this page. Try fewer filters or run list_categories. ` +
274
+ "(Deep pagination is unavailable by design: awwwards.com's robots.txt disallows it.)" +
275
+ // looseHint is empty unless this is a true FTS zero result.
276
+ looseHint),
221
277
  ],
222
278
  };
223
279
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "awwwards-mcp",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Free MCP server giving AI agents design inspiration from Awwwards: search award-winning sites with inline screenshots and extract design DNA.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,6 +31,9 @@ Run this loop before building anything visual:
31
31
  3. **Search.** Call `search_sites` with 1–3 filters (e.g.
32
32
  `{ color: "#404040", tags: ["3d", "portfolio"] }`). Judge the results from
33
33
  the inline screenshots, not just titles. Shortlist 2–3 candidates.
34
+ Free-text queries are porter-stem + prefix-matched and BM25-ranked
35
+ (`"magazines"` finds Magazine-tagged sites, best matches first); multi-word
36
+ queries keep AND semantics — both tokens must hit the same site.
34
37
  4. **Live reference URL named? Capture it full-page first.** (and later capture
35
38
  your own build the same way — compare both against each other) If the user
36
39
  points at a specific live site (e.g. "recreate cerebrium.ai"), call