awwwards-mcp 1.5.0 → 1.6.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
@@ -4,31 +4,89 @@ Free, open-source MCP server that gives AI agents design inspiration from
4
4
  [Awwwards](https://www.awwwards.com/) — the Mobbin-style visual reference loop,
5
5
  sourced from the web's best award-winning websites.
6
6
 
7
+ <a href="https://github.com/INSANE0777/Awwwards-mcp"><img src="assets/demo.gif" alt="awwwards-mcp in action: search results, design DNA, motion filmstrip, band map — real tool output" width="480"></a>
8
+
7
9
  Your agent searches in natural language ("dark 3D portfolio sites", "soft pastel
8
10
  e-commerce"), sees **real screenshots inline**, and can pull the **design DNA**
9
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.
10
16
 
11
17
  ## Tools
12
18
 
13
19
  | Tool | What it does |
14
20
  |------|--------------|
15
- | `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. |
16
22
  | `get_site_details` | Full design DNA for one site: palette, technologies, elements, awards, description. |
17
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. |
18
24
  | `list_categories` | Every filter the agent can search by (200+ tags, 27 colors). |
19
- | `capture_live_site` | Optional: fresh full-page screenshot of any live URL. Waits for `load` + a settle window with a bounded pre-scroll, so heavy sites work (`waitStrategy: "networkidle"` available). (needs [playwright](https://playwright.dev)). |
20
- | `analyze_page_structure` | Section band map of any page (live URL or local file:// build): tag, background, offset, height per band. Compare a reference site's structure against your build. Same heavy-site-friendly wait (`waitStrategy: "networkidle"` available). (needs [playwright](https://playwright.dev)). |
21
- | `record_site_motion` | Optional: short motion-through video of a live URL — preloader, scroll-triggered and hover/cursor animations. Returns an inline filmstrip JPEG plus the saved .webm path. (needs [playwright](https://playwright.dev) + ffmpeg-static). |
25
+ | `capture_live_site` | Optional: fresh full-page screenshot of any live URL. Waits for `load` + a settle window with a bounded pre-scroll, so heavy sites work (`waitStrategy: "networkidle"` available). Pass `viewport: "mobile"` for the 390×844 iPhone-class render (`"desktop"` 1440×900 default). (needs [playwright](https://playwright.dev)). |
26
+ | `analyze_page_structure` | Section band map of any page (live URL or local file:// build): tag, background, offset, height per band. Compare a reference site's structure against your build. Same heavy-site-friendly wait (`waitStrategy: "networkidle"` available); `viewport: "mobile"` analyzes the phone-class layout (`"desktop"` default). (needs [playwright](https://playwright.dev)). |
27
+ | `record_site_motion` | Optional: short motion-through video of a live URL — preloader, scroll-triggered and hover/cursor animations. Returns an inline filmstrip JPEG plus the saved .webm path. `viewport: "mobile"` records at phone size — the filmstrip renders at the selected viewport, no pillarboxing (`"desktop"` default). (needs [playwright](https://playwright.dev) + ffmpeg-static). |
22
28
 
23
29
  ## Setup
24
30
 
31
+ Any MCP-compatible coding agent can use awwwards-mcp — no API key, no account.
32
+ Requires Node ≥ 22.13 (`node -v` to check). Pick your agent:
33
+
34
+ **Updates**: the server checks the npm registry once a day and prints an
35
+ stderr notice when a newer `awwwards-mcp` exists (stdout stays clean for the
36
+ JSON-RPC channel — your agent sees the notice as a log line). Set
37
+ `AWWWARDS_AUTO_UPDATE=1` in the server's `env` to opt into background
38
+ self-update; restart your agent afterwards to load it. Nothing is fetched
39
+ more than once a day and serving never waits on the check.
40
+
25
41
  **Claude Code**
26
42
 
27
43
  ```bash
28
44
  claude mcp add awwwards -- npx -y awwwards-mcp
29
45
  ```
30
46
 
31
- **Claude Desktop / Cursor / Windsurf** (`mcpServers` in the config):
47
+ **Codex CLI** (ChatGPT desktop app and the IDE extension share this config)
48
+
49
+ ```bash
50
+ codex mcp add awwwards -- npx -y awwwards-mcp
51
+ ```
52
+
53
+ or in `~/.codex/config.toml` (project-scoped: `.codex/config.toml`):
54
+
55
+ ```toml
56
+ [mcp_servers.awwwards]
57
+ command = "npx"
58
+ args = ["-y", "awwwards-mcp"]
59
+ ```
60
+
61
+ **OpenCode** (`opencode.json` — note the command is an array)
62
+
63
+ ```json
64
+ {
65
+ "$schema": "https://opencode.ai/config.json",
66
+ "mcp": {
67
+ "awwwards": {
68
+ "type": "local",
69
+ "command": ["npx", "-y", "awwwards-mcp"]
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ **ZCode** (`~/.zcode/cli/config.json` — note servers nest under `"mcp": { "servers": ... }`)
76
+
77
+ ```json
78
+ {
79
+ "mcp": {
80
+ "servers": {
81
+ "awwwards": { "command": "npx", "args": ["-y", "awwwards-mcp"], "env": {} }
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ **Claude Desktop / Cursor / Windsurf / Gemini CLI / Cline / Continue** — anything
88
+ reading the common `mcpServers` JSON shape (e.g. `~/.claude/claude_desktop_config.json`
89
+ or `~/.gemini/settings.json`):
32
90
 
33
91
  ```json
34
92
  {
@@ -38,25 +96,58 @@ claude mcp add awwwards -- npx -y awwwards-mcp
38
96
  }
39
97
  ```
40
98
 
41
- Optional full-page captures:
99
+ **Anything else** — awwwards-mcp is a plain stdio MCP server: point your client
100
+ at `npx -y awwwards-mcp` and it works. To pin a version, use
101
+ `npx -y awwwards-mcp@1.0.0`.
102
+
103
+ **pi coding agent** has no built-in MCP by design — it uses skills and
104
+ extensions instead. Two options:
105
+
106
+ 1. Install the awwwards-inspiration skill (below). pi reads skills from
107
+ `~/.pi/agent/skills/` or `~/.agents/skills/` (the latter is shared across
108
+ agents following the Agent Skills standard). The skill teaches the workflow;
109
+ for it to reach the live data, add an MCP-supporting pi extension, or run
110
+ the queries in another agent and paste results.
111
+ 2. Skip MCP entirely: ask pi to build you a small CLI wrapper around
112
+ awwwards.com, or use a shared skills directory (`~/.agents/skills/`) so the
113
+ same skill file serves pi and every other agent.
114
+
115
+ Optional full-page captures (needed by `capture_live_site`,
116
+ `analyze_page_structure`, `record_site_motion`):
42
117
 
43
118
  ```bash
44
119
  npm install -g playwright && npx playwright install chromium
45
120
  ```
46
121
 
122
+ `record_site_motion` additionally uses ffmpeg; it resolves the `ffmpeg-static`
123
+ package automatically if present.
124
+
47
125
  ## Skills
48
126
 
49
- This package ships an agent skill that teaches the inspiration workflow —
50
- search, judge from screenshots, pull design DNA, state a design direction —
51
- using the awwwards MCP tools. Copy it into your agent's skills directory:
127
+ This package ships three agent skills. Any agent that follows the
128
+ [Agent Skills standard](https://agentskills.io) can load them; copy them into
129
+ your agent's skills directory:
52
130
 
53
131
  ```bash
54
132
  npm install awwwards-mcp
55
- mkdir -p ~/.claude/skills && cp -r node_modules/awwwards-mcp/skills/awwwards-inspiration ~/.claude/skills/
133
+ mkdir -p ~/.agents/skills && cp -r node_modules/awwwards-mcp/skills/awwwards-inspiration node_modules/awwwards-mcp/skills/awwwards-doctor node_modules/awwwards-mcp/skills/awwwards-motion-study ~/.agents/skills/
56
134
  ```
57
135
 
58
- For ZCode, copy to `~/.zcode/skills/` instead of `~/.claude/skills/`.
59
- Windows: run this from Git Bash, or copy `node_modules\awwwards-mcp\skills\awwwards-inspiration` manually.
136
+ | Skill | What it teaches |
137
+ |-------|-----------------|
138
+ | `awwwards-inspiration` | The inspiration loop: search, judge from screenshots, pull design DNA, state a design direction, capture/motion-first builds. |
139
+ | `awwwards-motion-study` | The full video chain: what to record from a live site (and what to skip), frame-by-frame review (video input or tile-per-element), the motion inventory, and build verification by re-recording. |
140
+ | `awwwards-doctor` | Repair: run `npm run doctor`, apply its fixes, re-anchor parsers after real awwwards.com drift, recover the in-flight task that surfaced the failure. |
141
+
142
+ | Agent | Skills directory |
143
+ |-------|------------------|
144
+ | Claude Code | `~/.claude/skills/` |
145
+ | pi | `~/.pi/agent/skills/` (also reads `~/.agents/skills/`) |
146
+ | ZCode | `~/.zcode/skills/` |
147
+ | Agent Skills-standard agents | `~/.agents/skills/` |
148
+
149
+ Windows: run this from Git Bash, or copy
150
+ `node_modules\awwwards-mcp\skills\awwwards-inspiration` manually.
60
151
 
61
152
  ## Indexing (recommended)
62
153
 
@@ -94,6 +185,171 @@ and content remain the property of Awwwards and the credited creators — don't
94
185
  bulk-scrape, redistribute, or republish them. If you use this commercially,
95
186
  review awwwards.com's terms yourself.
96
187
 
188
+ ## Built with awwwards-mcp: four real sites
189
+
190
+ Four complete sites were built through the full inspiration loop this MCP
191
+ enables, using nothing but the server's tools plus the shipped
192
+ `awwwards-inspiration` skill. Each one exercised a different corner of the
193
+ loop — and every correction the loop caught on the way became doctrine in the
194
+ skill.
195
+
196
+ > **Built in one shot, by a model that can't watch video.** All three sites
197
+ > were built in a single prompt run on **GLM 5.3-flash** — which does not
198
+ > support video input. The loop's motion study worked entirely from
199
+ > frame-tiled filmstrips (ffmpeg, 1–2 fps per element) instead of watching
200
+ > the recordings. With a video-native model, those same `get_site_elements`
201
+ > videos and `record_site_motion` .webm files could be watched directly —
202
+ > timing, easing and overlap read at full fidelity — and the motion-true
203
+ > results would be better still. The skill's frame-tile doctrine is what
204
+ > closes that gap today.
205
+
206
+ **1. [Ridge](showcase/ridge/index.html)**
207
+ ([source](showcase/ridge/)) — a Swiss-minimal single-page showcase for a fictional engineering-talent studio, direction **Aspen Search** (SOTD + Developer Award, jury 7.48): monochrome `#FAFAF8`/`#1A1A1A` + mint, giant grotesque section markers, halftone grain, asymmetric panel grid, dark discipline panels in an interior **horizontal pin passage**, count-up stats, client rows, theme toggle, cursor-follower. Built with the v1.4.0 toolkit: FTS5-ranked direction search, **both-viewports** reference captures and QA (desktop 7,849px + mobile 390×844), overflow audit (0px both), pin-center shots, film verification — and the skill-memory flywheel recorded the findings. QA evidence: `showcase/ridge/_qa/`.
208
+
209
+ | Panel grid (desktop) | Horizontal discipline passage | Mobile 390×844 |
210
+ |---|---|---|
211
+ | ![Ridge desktop — Swiss panel grid with mint and grain](assets/ridge-home.jpg) | ![Ridge disciplines — pinned horizontal passage mid-slide](assets/ridge-disciplines.jpg) | ![Ridge mobile — stacked grid, zero overflow](assets/ridge-mobile.jpg) |
212
+
213
+ **2. [Fallow Press](fallow-press/index.html)**
214
+ ([source](fallow-press/)) — a flat-2D editorial journal, direction
215
+ **Emergence Magazine** (SOTD): pink `#FF9398` on cream and black, torn-paper
216
+ masthead (pure CSS `clip-path`, zero WebGL), giant grotesque display over
217
+ grayscale photography, serif-italic brand, three pages with **separate
218
+ horizontal** projects/about pages (GSAP ScrollTrigger pin +
219
+ `containerAnimation`).
220
+
221
+ | Torn-paper masthead (home) | Horizontal gallery (Fields) | Horizontal chapters (Practices) |
222
+ |---|---|---|
223
+ | ![Fallow Press home — torn-paper masthead over grayscale photography](assets/fallow-home.jpg) | ![Fallow Press Fields — pinned horizontal gallery panel](assets/fallow-fields.jpg) | ![Fallow Press Practices — pink quote chapter](assets/fallow-practices.jpg) |
224
+
225
+ The loop as it ran:
226
+
227
+ 1. `search_sites` (magazine filters) → shortlist judged from inline
228
+ screenshots → `get_site_details` on Emergence Magazine.
229
+ 2. **Capture before building**: `capture_live_site` + `record_site_motion`
230
+ on the live site *first*; full-page PNG and motion .webm kept in
231
+ [fallow-press/ref-motion/](fallow-press/ref-motion/) as the evidence trail.
232
+ 3. Build, then verify: full-page capture plus **panel-center pin shots** of
233
+ both horizontal pages (13 stops each, in
234
+ [fallow-press/_qa/](fallow-press/_qa/) — `capture-qa.mjs` is reusable).
235
+ 4. The pin shots caught a real bug: horizontal-panel entrances used
236
+ `toggleActions: "play none none reverse"`, and 100vw panels hide content
237
+ at midpoints on the way back — copy disappeared mid-view. Fix
238
+ (one-shot play entrances) is now doctrine: **full-viewport panels get
239
+ one-shot entrances**; QA pin shots land at panel **centers**, not uniform
240
+ fractions, or you photograph empty transition zones.
241
+
242
+ **3. Cerebrium recreation** (`C:/Users/Afjal/cerebrium-recreation/`) — a
243
+ fidelity-first recreation of cerebrium.ai, pixel-checked against the live
244
+ reference: full-page captures of both sides, `analyze_page_structure` band
245
+ compare, and SVG icon/legend fixes until the build matched the reference to
246
+ within 1px of total page height (10,871px vs 10,870px). This is the
247
+ **structure-before-pixels** doctrine at its strictest — band maps compared,
248
+ never just totals.
249
+
250
+ ![Cerebrium recreation — full-page build capture](assets/cerebrium-build.jpg)
251
+
252
+ **4. The Meridian** (`C:/Users/Afjal/editorial-site/`) — an editorial journal
253
+ built from ORDR/Hearst references: the first build to run the whole loop
254
+ end-to-end. `analyze_page_structure` caught a masthead band bug by comparing
255
+ the build's band map against the reference's; the reference captures,
256
+ motion film, and the reusable pre-scroll capture script live in
257
+ `editorial-site/_qa/`.
258
+
259
+ ![The Meridian editorial journal — full-page build capture](assets/meridian-build.jpg)
260
+
261
+ ![The Meridian — motion filmstrip from record_site_motion](assets/meridian-filmstrip.jpg)
262
+
263
+ ## Skills used to build these
264
+
265
+ | Skill | Role in the builds |
266
+ |---|---|
267
+ | `awwwards-inspiration` | The 8-step loop itself (ships with this package): search → judge from screenshots → design DNA → capture/motion study → state direction → build → band-map verify. |
268
+ | `gsap-scrolltrigger` | The horizontal pin + `containerAnimation` pattern (ease `"none"`, one-shot entrances) driving both Fallow Press horizontal pages. |
269
+ | `gsap-core` / `gsap-timeline` | Tween composition and sequenced hero entrances (torn-paper drop, panel copy rises). |
270
+ | `frontend-design` | Typography, palette and layout judgment applied when translating reference DNA into original pages. |
271
+ | `lenis` (library, via skill guidance) | smooth scrolling synced to ScrollTrigger on the Fallow Press home page. |
272
+ | `tailwindcss` / plain CSS | All builds are plain hand-rolled CSS — flat 2D, no frameworks needed. |
273
+
274
+ **The skills self-improve:** every loop pass records what verification caught (`scripts/skill-memory.mjs record`), and a deterministic distiller folds rules seen 2+ times into your installed skill copy — while the shipped copies only change via human PR. A techniques registry (`skills/_memory/techniques.json`) catalogs researched how-tos per domain (video understanding, motion detection, UI structure, micro-interactions, images).
275
+
276
+ Reduced-motion, JS-less visits, and capture tools all get graceful fallbacks
277
+ (vertical stacks; progressive-enhancement reveals).
278
+
279
+ **What the verification loop caught** — proof the structure-before-pixels
280
+ doctrine is load-bearing:
281
+
282
+ - Element **posters lie**: the first showcase build was designed from poster
283
+ frames alone and rendered a spinning 3D ring as *floating static cards*.
284
+ Downloading the element videos (`get_site_elements`) and frame-tiling them
285
+ revealed the motion truth — now the skill mandates studying motion before
286
+ animating.
287
+ - Full-page captures of reveal-on-scroll builds showed blank sections: `.reveal`
288
+ animation state vs capture's no-scroll reality. Builds ship
289
+ content-visible-without-JS progressive enhancement.
290
+ - Horizontal-panel copy vanished **mid-view** on the Fallow Press pages:
291
+ `toggleActions` reverse reverts entrances while a 100vw panel is still
292
+ holding the viewport (see above).
293
+ - Band-map compare kept the references' rhythm instead of drifting on
294
+ section heights (Cerebrium, The Meridian).
295
+
296
+ Prompt counts: **3** for the original showcase build (the build ask, the
297
+ motion correction that exposed the poster-lie, the structure pass) and
298
+ **1** for Fallow Press ("create a new website using our MCP and skills… no
299
+ 3D websites") — its two follow-ups were caught by the QA loop, not by the
300
+ user. Each correction became doctrine in the shipped `awwwards-inspiration`
301
+ skill: frame-study element videos before animating; judge page architecture
302
+ from the studied passages; tile per element, not one giant filmstrip;
303
+ capture live sites and animation **before** building.
304
+
305
+ ## Can awwwards-mcp crawl the sitemap? (robots.txt notes)
306
+
307
+ The awwwards.com `robots.txt` advertises
308
+ `Sitemap: https://www.awwwards.com/sitemap.xml` and — verified live
309
+ 2026-09-18 — **that sitemap URL returns a soft-404 HTML page** (as do common
310
+ child names like `/sitemap-websites.xml`). So sitemap discovery isn't
311
+ currently a path to more data; the polite crawl surface is exactly what the
312
+ indexer uses:
313
+
314
+ - **Allowed and used**: `/websites/`, `/websites/<filter>/`, `/sites/<slug>`
315
+ (one filter per URL; deep pagination stays un-crawled).
316
+ - **Disallowed and never fetched**: `/tag/`, `/search-websites`,
317
+ `/websites/?` (query-string pagination), `/elements/*`, `/vote/`,
318
+ favourites/likes/follows, and the rest of the 33 rules.
319
+ - Our client (`src/awwwards.ts` `buildFilterUrl`) constructs **only**
320
+ `/websites/…` paths at 1 request/second — the loop stays inside the
321
+ published rules by construction, not by convention.
322
+
323
+ ## Contributing
324
+
325
+ PRs welcome! The project especially needs **parser-drift fixes** — when live
326
+ awwwards.com markup changes, a fresh HTML snapshot attached to an issue often
327
+ becomes the new test fixture and the fastest merged PR. See
328
+ [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide:
329
+
330
+ **Parser-drift is monitored automatically.** A probe script
331
+ ([scripts/parser-drift-probe.mjs](scripts/parser-drift-probe.mjs),
332
+ `npm run drift`) checks every markup anchor the parsers depend on — the
333
+ `split`/`indexOf`/regex literals in `src/parsers.ts` — against the live
334
+ listing and detail pages (2 fetches, 1 request/second, same politeness as
335
+ the client). A daily GitHub Action ([.github/workflows/parser-drift.yml](.github/workflows/parser-drift.yml))
336
+ runs it and, on drift, opens/updates a single tracking issue with the exact
337
+ anchors that changed (and auto-closes it when a later run is green). To run
338
+ it yourself: `npm run drift` (live, exit code 0/1/2) or `npm run drift -- --fixture`
339
+ (offline, checks the committed fixtures still feed every anchor). Raw HTML
340
+ is never diffed or stored — anchors only fire when the parsers actually
341
+ break, so there are no false alarms from cosmetic tweaks.
342
+
343
+ - Development setup & project layout (offline fixture-tested, no network in tests)
344
+ - How to create a PR: fork → `fix/`/`feat/`/`docs/` branch → typecheck + tests → PR template
345
+ - The politeness constraints new code must keep (1 req/s, robots.txt paths, light runtime deps)
346
+
347
+ Bugs and feature ideas start as
348
+ [issues](https://github.com/INSANE0777/Awwwards-mcp/issues/new/choose) with
349
+ templates. Security problems go privately — see
350
+ [SECURITY.md](SECURITY.md). By participating you agree to the
351
+ [Code of Conduct](CODE_OF_CONDUCT.md).
352
+
97
353
  ## Development
98
354
 
99
355
  ```bash
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/capture.js CHANGED
@@ -6,6 +6,7 @@ import { join } from "node:path";
6
6
  // CAPTURE_INSTALL_HINT from here) — both sides only use the other's bindings
7
7
  // at call time, which ESM resolves fine.
8
8
  import { preScroll } from "./structure.js";
9
+ import { resolveViewport } from "./viewport.js";
9
10
  export const CAPTURE_INSTALL_HINT = "Full-page capture needs Playwright, which is an optional dependency.\n" +
10
11
  "Install it with: npm install -D playwright && npx playwright install chromium\n" +
11
12
  "Then retry the capture or structure tool.";
@@ -29,7 +30,12 @@ loader = () => import("playwright"), opts) {
29
30
  }
30
31
  try {
31
32
  const waitStrategy = opts?.waitStrategy ?? "load";
32
- const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
33
+ // Same viewport-profile split as analyzePageStructure (structure.ts):
34
+ // width/height fill the `viewport` key, mobile-profile flags spread in as
35
+ // sibling context options. Desktop resolves to no extra fields, so the
36
+ // default call shape is unchanged.
37
+ const { width, height, ...contextOpts } = resolveViewport(opts?.viewport);
38
+ const page = await browser.newPage({ viewport: { width, height }, ...contextOpts });
33
39
  await page.goto(url, { waitUntil: waitStrategy, timeout: 45_000 });
34
40
  // "load" can fire before late XHRs settle, so give the page a fixed
35
41
  // settle window; networkidle already means the network went quiet.
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { Cache } from "./cache.js";
12
12
  import { createHandlers } from "./server.js";
13
13
  import { captureLiveSite } from "./capture.js";
14
14
  import { runIndexer, shouldAutoIndex } from "./indexer.js";
15
+ import { checkForUpdate } from "./version-check.js";
15
16
  // The handlers return ToolResponse, which is structurally identical to the
16
17
  // SDK's CallToolResult at runtime ({ content, isError? }). CallToolResult's
17
18
  // schema additionally carries a [k: string]: unknown index signature that a
@@ -22,6 +23,10 @@ const waitStrategySchema = z
22
23
  .enum(["load", "networkidle"])
23
24
  .default("load")
24
25
  .describe("'load' + settle works on heavy sites; 'networkidle' waits for total quiet");
26
+ const viewportSchema = z
27
+ .enum(["desktop", "mobile"])
28
+ .default("desktop")
29
+ .describe("desktop = 1440x900 (default); mobile = 390x844 iPhone-class with deviceScaleFactor 3, isMobile + hasTouch");
25
30
  const cacheRoot = process.env.AWWWARDS_CACHE_DIR ?? join(homedir(), ".awwwards-mcp");
26
31
  let cache;
27
32
  try {
@@ -76,11 +81,13 @@ server.tool("list_categories", "List the filter taxonomy available on Awwwards:
76
81
  server.tool("capture_live_site", "Take a fresh full-page screenshot of a live website URL using a headless browser. Requires the optional playwright dependency.", {
77
82
  url: z.string().url().describe("Absolute URL of the site to capture"),
78
83
  waitStrategy: waitStrategySchema,
84
+ viewport: viewportSchema,
79
85
  }, (args) => asMcpResult(handlers.capture_live_site(args)));
80
86
  server.tool("analyze_page_structure", "Extract a page's section band map (tag, label, background color, offset, height per band) via a headless browser. Works on live URLs and file:// paths — use it to compare a reference site's structure against your local build.", {
81
87
  url: z.string().url().describe("Absolute URL (https:// or file://) of the page to analyze"),
82
88
  maxBands: z.number().int().min(5).max(60).default(40).describe("Cap on returned bands"),
83
89
  waitStrategy: waitStrategySchema,
90
+ viewport: viewportSchema,
84
91
  }, (args) => asMcpResult(handlers.analyze_page_structure(args)));
85
92
  server.tool("record_site_motion", "Record a short motion-through video of a live website — preloader, scroll-triggered and hover/cursor animations — and return an inline filmstrip JPEG plus the .webm path. Requires the optional playwright and ffmpeg-static dependencies.", {
86
93
  url: z.string().url().describe("Absolute URL of the site to record"),
@@ -92,6 +99,7 @@ server.tool("record_site_motion", "Record a short motion-through video of a live
92
99
  .default(16)
93
100
  .describe("Filmstrip tile count (default 16 → a 4x4 grid)"),
94
101
  waitStrategy: waitStrategySchema,
102
+ viewport: viewportSchema,
95
103
  }, (args) => asMcpResult(handlers.record_site_motion(args)));
96
104
  // Auto-refresh: if the index is stale (or absent) and no crawl is running,
97
105
  // re-index in the background. Serving is never blocked; errors are stderr-only.
@@ -100,4 +108,7 @@ if (shouldAutoIndex(cache)) {
100
108
  console.error(`awwwards-mcp: background index failed: ${err instanceof Error ? err.message : String(err)}`);
101
109
  });
102
110
  }
111
+ // Update notice: once a day, compare against the npm registry; stderr-only,
112
+ // never blocks serving. AWWWARDS_AUTO_UPDATE=1 opts into background install.
113
+ checkForUpdate(pkgJson.version);
103
114
  await server.connect(new StdioServerTransport());
package/dist/motion.js CHANGED
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, st
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { CAPTURE_INSTALL_HINT } from "./capture.js";
7
+ import { resolveViewport } from "./viewport.js";
7
8
  export const MOTION_FFMPEG_HINT = "Motion recording needs ffmpeg-static, which is an optional dependency.\n" +
8
9
  "Install it with: npm install -D ffmpeg-static\n" +
9
10
  "Then retry record_site_motion.";
@@ -167,9 +168,17 @@ export async function recordSiteMotion(url, opts) {
167
168
  const videoTmp = mkdtempSync(join(opts.cacheImagesDir, ".video-tmp-"));
168
169
  const waitStrategy = opts.waitStrategy ?? "load";
169
170
  try {
171
+ // Viewport profile split (same as structure/capture): width/height fill
172
+ // the `viewport` key; the mobile-profile flags (deviceScaleFactor/
173
+ // isMobile/hasTouch) spread in as sibling context options AFTER the
174
+ // existing fields. recordVideo.size derives from the same profile so the
175
+ // video canvas matches the viewport (desktop keeps the 1440x900 canvas; a
176
+ // mobile recording gets a 390x844 canvas instead of a pillarboxed one).
177
+ const { width, height, ...contextOpts } = resolveViewport(opts.viewport);
170
178
  const context = await browser.newContext({
171
- viewport: { width: 1440, height: 900 },
172
- recordVideo: { dir: videoTmp, size: { width: 1440, height: 900 } },
179
+ viewport: { width, height },
180
+ recordVideo: { dir: videoTmp, size: { width, height } },
181
+ ...contextOpts,
173
182
  });
174
183
  try {
175
184
  const page = await context.newPage();
package/dist/parsers.js CHANGED
@@ -148,9 +148,12 @@ export function parseElements(html) {
148
148
  continue;
149
149
  }
150
150
  const mediaPath = blob?.collectableImage;
151
- // Only element media (videos/posters live under element/); other blobs
152
- // (site card, collections) must not leak in if the end bound is missing.
153
- if (typeof mediaPath === "string" && mediaPath.startsWith("element/")) {
151
+ // Only element media; other blobs (site card, collections) must not leak
152
+ // in if the end bound is missing. Media paths come in two schemes: modern
153
+ // sites store them under element/YYYY/MM/..., 2018-era sites under
154
+ // external/YYYY/MM/... (both verified live 2026-09-18 — the CDN serves
155
+ // both prefixes and their _static.jpeg posters).
156
+ if (typeof mediaPath === "string" && /^(element|external)\//.test(mediaPath)) {
154
157
  elements.push({
155
158
  title: decodeEntities(String(blob.collectableTitle ?? "")),
156
159
  mediaPath,