awwwards-mcp 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Afjal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # awwwards-mcp
2
+
3
+ Free, open-source MCP server that gives AI agents design inspiration from
4
+ [Awwwards](https://www.awwwards.com/) — the Mobbin-style visual reference loop,
5
+ sourced from the web's best award-winning websites.
6
+
7
+ Your agent searches in natural language ("dark 3D portfolio sites", "soft pastel
8
+ e-commerce"), sees **real screenshots inline**, and can pull the **design DNA**
9
+ of any site: color palette, tech stack, design elements, award history.
10
+
11
+ ## Tools
12
+
13
+ | Tool | What it does |
14
+ |------|--------------|
15
+ | `search_sites` | Search by color, tags, technology or award type. Returns site cards with inline screenshots. |
16
+ | `get_site_details` | Full design DNA for one site: palette, technologies, elements, awards, description. |
17
+ | `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
+ | `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). |
22
+
23
+ ## Setup
24
+
25
+ **Claude Code**
26
+
27
+ ```bash
28
+ claude mcp add awwwards -- npx -y awwwards-mcp
29
+ ```
30
+
31
+ **Claude Desktop / Cursor / Windsurf** (`mcpServers` in the config):
32
+
33
+ ```json
34
+ {
35
+ "mcpServers": {
36
+ "awwwards": { "command": "npx", "args": ["-y", "awwwards-mcp"] }
37
+ }
38
+ }
39
+ ```
40
+
41
+ Optional full-page captures:
42
+
43
+ ```bash
44
+ npm install -g playwright && npx playwright install chromium
45
+ ```
46
+
47
+ ## Skills
48
+
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:
52
+
53
+ ```bash
54
+ npm install awwwards-mcp
55
+ mkdir -p ~/.claude/skills && cp -r node_modules/awwwards-mcp/skills/awwwards-inspiration ~/.claude/skills/
56
+ ```
57
+
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.
60
+
61
+ ## Indexing (recommended)
62
+
63
+ `search_sites` works out of the box, but its depth is limited by polite live
64
+ scraping (~31 sites per filter page). Build a local index once and searches
65
+ draw from thousands of award-winning sites instantly:
66
+
67
+ ```bash
68
+ npx -y -p awwwards-mcp awwwards-index # once published
69
+ # or, from a local checkout of this repo:
70
+ npm run index
71
+ ```
72
+
73
+ - Crawls all ~200 tag pages at 1 request/second (~4 minutes) into the local
74
+ SQLite cache at `~/.awwwards-mcp/`.
75
+ - Resumable: interrupt it and re-run — completed pages are skipped.
76
+ - The MCP server re-indexes automatically in the background whenever the
77
+ index is older than 7 days (never blocking your session).
78
+
79
+ Site details (palettes, tech stacks) are still fetched on demand and cached
80
+ for 7 days.
81
+
82
+ ## How it works
83
+
84
+ - Live, polite scraping of awwwards.com public pages (max 1 request/second,
85
+ robots.txt-compliant paths only, cached 7 days in SQLite at `~/.awwwards-mcp/`).
86
+ - Screenshots are served from Awwwards' own CDN (880×660), cached on disk.
87
+ - No API key, no account, no cost.
88
+
89
+ ## Ethics & terms
90
+
91
+ This tool fetches publicly available pages for **personal design-inspiration
92
+ use**, at human-ish request rates, honoring robots.txt. Awwwards' screenshots
93
+ and content remain the property of Awwwards and the credited creators — don't
94
+ bulk-scrape, redistribute, or republish them. If you use this commercially,
95
+ review awwwards.com's terms yourself.
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ npm install
101
+ npm test # offline unit tests against committed HTML fixtures
102
+ npm run smoke # manual live smoke test against awwwards.com
103
+ npm run build # compile to dist/
104
+ ```
105
+
106
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,113 @@
1
+ export const BASE_URL = "https://www.awwwards.com";
2
+ export const ASSETS_URL = "https://assets.awwwards.com";
3
+ export const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 awwwards-mcp/1.0";
4
+ export const AWARD_FILTERS = {
5
+ sotd: "websites/sites_of_the_day",
6
+ developer: "websites/developer",
7
+ honorable: "websites/honorable",
8
+ };
9
+ // Combined filter URLs return 404 on awwwards.com (verified 2026-09-17), so
10
+ // exactly one filter is used in the URL — the most specific one. The caller
11
+ // applies the remaining filters client-side over the parsed results.
12
+ export function buildFilterUrl(filters) {
13
+ if (filters.color) {
14
+ return `${BASE_URL}/websites/%23${filters.color.replace("#", "").toUpperCase()}/`;
15
+ }
16
+ if (filters.award)
17
+ return `${BASE_URL}/${AWARD_FILTERS[filters.award]}/`;
18
+ const tag = filters.technology ?? filters.tags?.[0];
19
+ if (tag)
20
+ return `${BASE_URL}/websites/${encodeURIComponent(tag.toLowerCase())}/`;
21
+ return `${BASE_URL}/websites/`;
22
+ }
23
+ export function thumbnailUrl(thumbPath, size = 880) {
24
+ const dim = size === 880 ? "880_660" : "440_330";
25
+ return `${ASSETS_URL}/awards/media/cache/thumb_${dim}/${thumbPath}`;
26
+ }
27
+ export function elementUrl(mediaPath) {
28
+ return `${ASSETS_URL}/awards/${mediaPath}`;
29
+ }
30
+ // Video elements ship a poster at the same path with .mp4 → _static.jpeg
31
+ // (live-verified on the CDN); image elements are used as-is.
32
+ export function elementPosterPath(mediaPath) {
33
+ return mediaPath.endsWith(".mp4")
34
+ ? mediaPath.replace(/\.mp4$/, "_static.jpeg")
35
+ : mediaPath;
36
+ }
37
+ export class BlockedError extends Error {
38
+ status;
39
+ constructor(url, status) {
40
+ super(`Awwwards is blocking requests (HTTP ${status} on ${url}). ` +
41
+ "Try again later; the tool never retries through blocks.");
42
+ this.status = status;
43
+ }
44
+ }
45
+ export class RateLimiter {
46
+ intervalMs;
47
+ last = 0;
48
+ chain = Promise.resolve();
49
+ constructor(intervalMs) {
50
+ this.intervalMs = intervalMs;
51
+ }
52
+ acquire() {
53
+ const next = this.chain.then(async () => {
54
+ const wait = this.last + this.intervalMs - Date.now();
55
+ if (wait > 0)
56
+ await new Promise((r) => setTimeout(r, wait));
57
+ this.last = Date.now();
58
+ });
59
+ this.chain = next.catch(() => { });
60
+ return next;
61
+ }
62
+ }
63
+ export class AwwwardsClient {
64
+ rateLimiter;
65
+ fetchFn;
66
+ constructor(opts = {}) {
67
+ this.rateLimiter = opts.rateLimiter ?? new RateLimiter(1000);
68
+ this.fetchFn = opts.fetchFn ?? fetch;
69
+ }
70
+ // Rate-limited page fetch with one retry on transient failures.
71
+ // Blocks (403/429) are never retried.
72
+ async getHtml(path) {
73
+ const url = path.startsWith("http") ? path : BASE_URL + path;
74
+ let lastErr;
75
+ for (let attempt = 0; attempt < 2; attempt++) {
76
+ try {
77
+ await this.rateLimiter.acquire();
78
+ const res = await this.fetchFn(url, {
79
+ headers: { "User-Agent": USER_AGENT },
80
+ redirect: "follow",
81
+ });
82
+ if (res.status === 403 || res.status === 429)
83
+ throw new BlockedError(url, res.status);
84
+ if (!res.ok)
85
+ throw new Error(`HTTP ${res.status} for ${url}`);
86
+ return await res.text();
87
+ }
88
+ catch (err) {
89
+ if (err instanceof BlockedError)
90
+ throw err;
91
+ lastErr = err;
92
+ }
93
+ }
94
+ throw lastErr;
95
+ }
96
+ // Thumbnail fetch from the CDN — not rate-limited.
97
+ async getThumbnail(thumbPath, size = 880) {
98
+ return this.fetchCdnBinary(thumbnailUrl(thumbPath, size), `thumbnail ${thumbPath}`);
99
+ }
100
+ // Asset fetch from the CDN (element posters etc.) — not rate-limited.
101
+ async getAsset(assetPath) {
102
+ return this.fetchCdnBinary(elementUrl(assetPath), `asset ${assetPath}`);
103
+ }
104
+ // Binary CDN assets (thumbnails, element media) all fetch through this single path.
105
+ async fetchCdnBinary(url, label) {
106
+ const res = await this.fetchFn(url, {
107
+ headers: { "User-Agent": USER_AGENT },
108
+ });
109
+ if (!res.ok)
110
+ throw new Error(`HTTP ${res.status} fetching ${label}`);
111
+ return Buffer.from(await res.arrayBuffer());
112
+ }
113
+ }
package/dist/cache.js ADDED
@@ -0,0 +1,134 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync } from "node:fs";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ // Freshness window applied by getSite() when the caller does not pass one.
7
+ // Expired lookups are misses (single-arg getSite returns null for rows older
8
+ // than this window); getSites(Infinity) remains the stale fallback.
9
+ const DEFAULT_SITE_TTL_MS = 10_000;
10
+ const SCHEMA = `
11
+ CREATE TABLE IF NOT EXISTS sites (
12
+ slug TEXT PRIMARY KEY, id INTEGER, title TEXT, createdAt INTEGER,
13
+ tags TEXT, thumbnailPath TEXT, liveUrl TEXT, detailPath TEXT,
14
+ awards TEXT, fetchedAt INTEGER
15
+ );
16
+ CREATE TABLE IF NOT EXISTS meta (
17
+ key TEXT PRIMARY KEY, value TEXT, fetchedAt INTEGER
18
+ );
19
+ `;
20
+ function rowToSite(r) {
21
+ return {
22
+ slug: r.slug,
23
+ id: r.id,
24
+ title: r.title,
25
+ createdAt: r.createdAt,
26
+ tags: JSON.parse(r.tags),
27
+ thumbnailPath: r.thumbnailPath,
28
+ liveUrl: r.liveUrl,
29
+ detailPath: r.detailPath,
30
+ awards: JSON.parse(r.awards),
31
+ };
32
+ }
33
+ export class Cache {
34
+ dbPath;
35
+ now;
36
+ imagesDir;
37
+ constructor(rootDir, now = Date.now) {
38
+ this.now = now;
39
+ mkdirSync(rootDir, { recursive: true });
40
+ this.imagesDir = join(rootDir, "images");
41
+ mkdirSync(this.imagesDir, { recursive: true });
42
+ this.dbPath = join(rootDir, "cache.db");
43
+ // Create cache.db + schema eagerly so the storage layout exists right
44
+ // after construction. The handle is closed immediately (see withDb).
45
+ this.withDb(() => { });
46
+ }
47
+ // Open → run → close per operation. node:sqlite keeps cache.db open until
48
+ // close(); on Windows an open handle makes the file and its directory
49
+ // undeletable, which broke temp-dir cleanup in tests. Per-operation
50
+ // open/close keeps the same public API with no lingering handles.
51
+ withDb(fn) {
52
+ const db = new DatabaseSync(this.dbPath);
53
+ try {
54
+ db.exec(SCHEMA);
55
+ return fn(db);
56
+ }
57
+ finally {
58
+ db.close();
59
+ }
60
+ }
61
+ upsertSites(sites) {
62
+ this.withDb((db) => {
63
+ const stmt = db.prepare(`INSERT INTO sites (slug, id, title, createdAt, tags, thumbnailPath, liveUrl, detailPath, awards, fetchedAt)
64
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
65
+ ON CONFLICT(slug) DO UPDATE SET
66
+ id=excluded.id, title=excluded.title, createdAt=excluded.createdAt,
67
+ tags=excluded.tags, thumbnailPath=excluded.thumbnailPath,
68
+ liveUrl=excluded.liveUrl, detailPath=excluded.detailPath,
69
+ awards=excluded.awards, fetchedAt=excluded.fetchedAt`);
70
+ const t = this.now();
71
+ // One transaction for the whole batch: each autocommitted INSERT pays a
72
+ // disk sync (~4ms on Windows), which made a 31-card upsert ~150ms and a
73
+ // full index crawl take minutes. A single commit syncs once.
74
+ db.exec("BEGIN");
75
+ try {
76
+ for (const s of sites) {
77
+ stmt.run(s.slug, s.id, s.title, s.createdAt, JSON.stringify(s.tags), s.thumbnailPath, s.liveUrl, s.detailPath, JSON.stringify(s.awards), t);
78
+ }
79
+ db.exec("COMMIT");
80
+ }
81
+ catch (err) {
82
+ db.exec("ROLLBACK");
83
+ throw err;
84
+ }
85
+ });
86
+ }
87
+ getSites(maxAgeMs) {
88
+ return this.withDb((db) => {
89
+ const min = this.now() - maxAgeMs;
90
+ const rows = db.prepare("SELECT * FROM sites WHERE fetchedAt > ? ORDER BY createdAt DESC").all(min);
91
+ return rows.map(rowToSite);
92
+ });
93
+ }
94
+ getSite(slug, maxAgeMs = DEFAULT_SITE_TTL_MS) {
95
+ return this.withDb((db) => {
96
+ const row = db.prepare("SELECT * FROM sites WHERE slug = ?").get(slug);
97
+ if (!row || row.fetchedAt <= this.now() - maxAgeMs)
98
+ return null;
99
+ return rowToSite(row);
100
+ });
101
+ }
102
+ setMeta(key, value) {
103
+ this.withDb((db) => {
104
+ db.prepare(`INSERT INTO meta (key, value, fetchedAt) VALUES (?, ?, ?)
105
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value, fetchedAt=excluded.fetchedAt`).run(key, JSON.stringify(value), this.now());
106
+ });
107
+ }
108
+ getMeta(key, maxAgeMs) {
109
+ return this.withDb((db) => {
110
+ const row = db.prepare("SELECT value, fetchedAt FROM meta WHERE key = ?").get(key);
111
+ if (!row || row.fetchedAt <= this.now() - maxAgeMs)
112
+ return null;
113
+ return JSON.parse(row.value);
114
+ });
115
+ }
116
+ deleteMeta(key) {
117
+ this.withDb((db) => {
118
+ db.prepare("DELETE FROM meta WHERE key = ?").run(key);
119
+ });
120
+ }
121
+ // Disk cache keyed by the awwwards asset path (immutable content → no TTL).
122
+ async getImage(assetPath, fetcher) {
123
+ const ext = assetPath.endsWith(".png") ? ".png" : ".jpg";
124
+ const file = join(this.imagesDir, createHash("sha1").update(assetPath).digest("hex") + ext);
125
+ try {
126
+ return await readFile(file);
127
+ }
128
+ catch {
129
+ const buf = await fetcher();
130
+ await writeFile(file, buf);
131
+ return buf;
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,51 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ // preScroll lives in structure.ts so there is ONE scroll-through loop for
5
+ // both tools; this is an intentional circular import (structure.ts imports
6
+ // CAPTURE_INSTALL_HINT from here) — both sides only use the other's bindings
7
+ // at call time, which ESM resolves fine.
8
+ import { preScroll } from "./structure.js";
9
+ export const CAPTURE_INSTALL_HINT = "Full-page capture needs Playwright, which is an optional dependency.\n" +
10
+ "Install it with: npm install -D playwright && npx playwright install chromium\n" +
11
+ "Then retry the capture or structure tool.";
12
+ export async function captureLiveSite(url, imagesDir,
13
+ // "as string" keeps Playwright an unresolved optional dependency at compile time;
14
+ // Node resolves it (and may throw) at runtime, which the try/catch below handles.
15
+ loader = () => import("playwright"), opts) {
16
+ let chromium;
17
+ try {
18
+ ({ chromium } = await loader());
19
+ }
20
+ catch {
21
+ return { error: CAPTURE_INSTALL_HINT };
22
+ }
23
+ let browser;
24
+ try {
25
+ browser = await chromium.launch();
26
+ }
27
+ catch {
28
+ return { error: CAPTURE_INSTALL_HINT };
29
+ }
30
+ try {
31
+ const waitStrategy = opts?.waitStrategy ?? "load";
32
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
33
+ await page.goto(url, { waitUntil: waitStrategy, timeout: 45_000 });
34
+ // "load" can fire before late XHRs settle, so give the page a fixed
35
+ // settle window; networkidle already means the network went quiet.
36
+ if (waitStrategy === "load")
37
+ await page.waitForTimeout(3000);
38
+ await preScroll(page);
39
+ const file = join(imagesDir, "capture-" + createHash("sha1").update(url).digest("hex").slice(0, 12) + ".png");
40
+ await page.screenshot({ path: file, fullPage: true });
41
+ return { file, base64: (await readFile(file)).toString("base64") };
42
+ }
43
+ finally {
44
+ try {
45
+ await browser.close();
46
+ }
47
+ catch {
48
+ /* keep the primary error */
49
+ }
50
+ }
51
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { readFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { dirname } from "node:path";
10
+ import { AwwwardsClient } from "./awwwards.js";
11
+ import { Cache } from "./cache.js";
12
+ import { createHandlers } from "./server.js";
13
+ import { captureLiveSite } from "./capture.js";
14
+ import { runIndexer, shouldAutoIndex } from "./indexer.js";
15
+ // The handlers return ToolResponse, which is structurally identical to the
16
+ // SDK's CallToolResult at runtime ({ content, isError? }). CallToolResult's
17
+ // schema additionally carries a [k: string]: unknown index signature that a
18
+ // TypeScript interface cannot satisfy implicitly, so a cast bridges the two.
19
+ const asMcpResult = (p) => p;
20
+ // Shared by capture_live_site, analyze_page_structure and record_site_motion.
21
+ const waitStrategySchema = z
22
+ .enum(["load", "networkidle"])
23
+ .default("load")
24
+ .describe("'load' + settle works on heavy sites; 'networkidle' waits for total quiet");
25
+ const cacheRoot = process.env.AWWWARDS_CACHE_DIR ?? join(homedir(), ".awwwards-mcp");
26
+ let cache;
27
+ try {
28
+ cache = new Cache(cacheRoot);
29
+ }
30
+ catch (err) {
31
+ console.error(`awwwards-mcp: cannot initialize cache at ${cacheRoot}: ${err instanceof Error ? err.message : String(err)}`);
32
+ process.exit(1);
33
+ }
34
+ const client = new AwwwardsClient();
35
+ const handlers = createHandlers({
36
+ client,
37
+ cache,
38
+ // captureLiveSite's third positional is the injectable playwright loader,
39
+ // so the CaptureFn-shaped (url, dir, opts) call is adapted to land opts
40
+ // in the function's fourth (opts) position.
41
+ captureFn: (url, imagesDir, opts) => captureLiveSite(url, imagesDir, undefined, opts),
42
+ });
43
+ // Version must match package.json; reading it at runtime makes drift impossible.
44
+ const pkgJson = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../package.json"), "utf8"));
45
+ const server = new McpServer({ name: "awwwards-mcp", version: pkgJson.version });
46
+ server.tool("search_sites", "Search award-winning websites on Awwwards. Returns site cards with inline screenshots, live URLs, awards and tags.", {
47
+ query: z.string().describe("Free text matched against site titles and tags").optional(),
48
+ color: z
49
+ .string()
50
+ .regex(/^#?[0-9A-Fa-f]{6}$/)
51
+ .describe("Dominant color hex, e.g. '#404040'")
52
+ .optional(),
53
+ tags: z.array(z.string()).describe("Tag slugs, e.g. ['3d', 'portfolio']").optional(),
54
+ technology: z.string().describe("Technology slug, e.g. 'webgl', 'gsap', 'astro'").optional(),
55
+ award: z.enum(["sotd", "developer", "honorable"]).optional(),
56
+ sortBy: z
57
+ .enum(["score", "newest"])
58
+ .default("newest")
59
+ .describe("Sort results: by Awwwards jury score (details previously fetched) or newest first"),
60
+ count: z.number().int().min(1).max(12).default(6),
61
+ page: z.number().int().min(1).default(1),
62
+ }, (args) => asMcpResult(handlers.search_sites(args)));
63
+ server.tool("get_site_details", "Get the design DNA of one Awwwards site: color palette, technologies, design elements, awards, description and inline screenshot.", {
64
+ slug: z
65
+ .string()
66
+ .regex(/^[\w-]+$/)
67
+ .describe("Site slug from search_sites, e.g. 'l-i-s-a'"),
68
+ }, (args) => asMcpResult(handlers.get_site_details(args)));
69
+ server.tool("get_site_elements", "Get the design-element highlights of one Awwwards site: component-level visuals (3D models, video content, mobile layouts, microcopy) with poster images inline and video URLs.", {
70
+ slug: z
71
+ .string()
72
+ .regex(/^[\w-]+$/)
73
+ .describe("Site slug from search_sites, e.g. 'l-i-s-a'"),
74
+ }, (args) => asMcpResult(handlers.get_site_elements(args)));
75
+ server.tool("list_categories", "List the filter taxonomy available on Awwwards: color hexes and tag/technology slugs usable with search_sites.", {}, () => asMcpResult(handlers.list_categories()));
76
+ 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
+ url: z.string().url().describe("Absolute URL of the site to capture"),
78
+ waitStrategy: waitStrategySchema,
79
+ }, (args) => asMcpResult(handlers.capture_live_site(args)));
80
+ 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
+ url: z.string().url().describe("Absolute URL (https:// or file://) of the page to analyze"),
82
+ maxBands: z.number().int().min(5).max(60).default(40).describe("Cap on returned bands"),
83
+ waitStrategy: waitStrategySchema,
84
+ }, (args) => asMcpResult(handlers.analyze_page_structure(args)));
85
+ 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
+ url: z.string().url().describe("Absolute URL of the site to record"),
87
+ frames: z
88
+ .number()
89
+ .int()
90
+ .min(4)
91
+ .max(36)
92
+ .default(16)
93
+ .describe("Filmstrip tile count (default 16 → a 4x4 grid)"),
94
+ waitStrategy: waitStrategySchema,
95
+ }, (args) => asMcpResult(handlers.record_site_motion(args)));
96
+ // Auto-refresh: if the index is stale (or absent) and no crawl is running,
97
+ // re-index in the background. Serving is never blocked; errors are stderr-only.
98
+ if (shouldAutoIndex(cache)) {
99
+ void runIndexer({ client, cache, log: (m) => console.error(m) }).catch((err) => {
100
+ console.error(`awwwards-mcp: background index failed: ${err instanceof Error ? err.message : String(err)}`);
101
+ });
102
+ }
103
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { AwwwardsClient } from "./awwwards.js";
5
+ import { Cache } from "./cache.js";
6
+ import { runIndexer, IndexLockError } from "./indexer.js";
7
+ const cacheRoot = process.env.AWWWARDS_CACHE_DIR ?? join(homedir(), ".awwwards-mcp");
8
+ let cache;
9
+ try {
10
+ cache = new Cache(cacheRoot);
11
+ }
12
+ catch (err) {
13
+ console.error(`awwwards-index: cannot initialize cache at ${cacheRoot}: ${err instanceof Error ? err.message : String(err)}`);
14
+ process.exit(1);
15
+ }
16
+ const client = new AwwwardsClient();
17
+ try {
18
+ const result = await runIndexer({ client, cache, log: (m) => console.error(m) });
19
+ console.error(`awwwards-index: done — ${result.pagesDone} pages crawled, ${result.skipped} skipped, ` +
20
+ `${result.sitesIndexed} site rows upserted (${result.pagesTotal} tags total)`);
21
+ process.exit(0);
22
+ }
23
+ catch (err) {
24
+ if (err instanceof IndexLockError) {
25
+ console.error(`awwwards-index: ${err.message}`);
26
+ process.exit(0);
27
+ }
28
+ console.error(`awwwards-index aborted: ${err instanceof Error ? err.message : String(err)}`);
29
+ process.exit(1);
30
+ }
@@ -0,0 +1,116 @@
1
+ import { parseCategories, parseListing } from "./parsers.js";
2
+ export const INDEX_STALE_MS = 7 * 24 * 60 * 60 * 1000;
3
+ export const INDEX_LOCK_STALE_MS = 30 * 60 * 1000;
4
+ // Same value as server.ts CATEGORY_TTL_MS; redeclared to avoid importing the
5
+ // server module (and its MCP wiring) into the indexer.
6
+ const CATEGORY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
7
+ export class IndexLockError extends Error {
8
+ constructor() {
9
+ super("another index run is in progress");
10
+ }
11
+ }
12
+ export function isIndexStale(cache, now = Date.now) {
13
+ const status = cache.getMeta("index:status", Number.POSITIVE_INFINITY);
14
+ if (!status || !status.finishedAt)
15
+ return true;
16
+ // >= so the exact INDEX_STALE_MS boundary already counts as stale, matching
17
+ // the lock boundary semantics (exact boundary = expired).
18
+ return now() - status.finishedAt >= INDEX_STALE_MS;
19
+ }
20
+ export function shouldAutoIndex(cache, now = Date.now) {
21
+ const lock = cache.getMeta("index:lock", Number.POSITIVE_INFINITY);
22
+ if (lock && now() - lock.startedAt < INDEX_LOCK_STALE_MS)
23
+ return false;
24
+ return isIndexStale(cache, now);
25
+ }
26
+ export async function runIndexer(deps) {
27
+ const { client, cache } = deps;
28
+ const now = deps.now ?? Date.now;
29
+ const log = deps.log ?? (() => { });
30
+ const lock = cache.getMeta("index:lock", Number.POSITIVE_INFINITY);
31
+ if (lock && now() - lock.startedAt < INDEX_LOCK_STALE_MS)
32
+ throw new IndexLockError();
33
+ cache.setMeta("index:lock", { startedAt: now() });
34
+ try {
35
+ let cats = cache.getMeta("categories", CATEGORY_TTL_MS);
36
+ if (!cats || cats.filters.length === 0) {
37
+ cats = parseCategories(await client.getHtml("/websites/"));
38
+ if (cats.filters.length === 0) {
39
+ throw new Error("Awwwards layout may have changed: parsed 0 categories. The awwwards-mcp parser likely needs an update.");
40
+ }
41
+ cache.setMeta("categories", cats);
42
+ }
43
+ const tags = cats.filters;
44
+ const done = new Set(cache.getMeta("index:progress", Number.POSITIVE_INFINITY) ?? []);
45
+ const result = await crawl({ client, cache, now, log }, tags, done);
46
+ cache.setMeta("index:status", {
47
+ startedAt: result.startedAt,
48
+ finishedAt: now(),
49
+ pagesDone: done.size,
50
+ pagesTotal: tags.length,
51
+ sitesIndexed: result.sitesIndexed,
52
+ });
53
+ return {
54
+ pagesDone: result.pagesDone,
55
+ pagesTotal: tags.length,
56
+ sitesIndexed: result.sitesIndexed,
57
+ skipped: result.skipped,
58
+ };
59
+ }
60
+ catch (err) {
61
+ try {
62
+ const previous = cache.getMeta("index:status", Number.POSITIVE_INFINITY);
63
+ const progress = (cache.getMeta("index:progress", Number.POSITIVE_INFINITY) ?? []).length;
64
+ cache.setMeta("index:status", {
65
+ startedAt: previous?.startedAt,
66
+ pagesDone: progress,
67
+ pagesTotal: tagsCount(cache),
68
+ sitesIndexed: previous?.sitesIndexed ?? 0,
69
+ lastError: err instanceof Error ? err.message : String(err),
70
+ });
71
+ }
72
+ catch {
73
+ // the store is failing; do not mask the original abort error
74
+ }
75
+ throw err;
76
+ }
77
+ finally {
78
+ try {
79
+ cache.deleteMeta("index:lock");
80
+ }
81
+ catch {
82
+ // same: never mask the original error with a lock-release failure
83
+ }
84
+ }
85
+ }
86
+ // Total page count for abort-path status writes: prefer the last status object,
87
+ // fall back to the cached taxonomy size. 0 is accurate when the abort happened
88
+ // before any taxonomy was ever fetched.
89
+ function tagsCount(cache) {
90
+ return (cache.getMeta("index:status", Number.POSITIVE_INFINITY)?.pagesTotal ??
91
+ (cache.getMeta("categories", CATEGORY_TTL_MS)?.filters.length ?? 0));
92
+ }
93
+ async function crawl(deps, tags, done) {
94
+ const { client, cache, now, log } = deps;
95
+ const startedAt = now();
96
+ const skipped = [...tags].filter((t) => done.has(t)).length;
97
+ let sitesIndexed = 0;
98
+ let pagesDone = 0;
99
+ for (const tag of tags) {
100
+ if (done.has(tag))
101
+ continue;
102
+ const html = await client.getHtml(`/websites/${encodeURIComponent(tag)}/`);
103
+ const sites = parseListing(html);
104
+ if (sites.length === 0) {
105
+ throw new Error(`Awwwards layout may have changed: parsed 0 site cards on /websites/${tag}/. ` +
106
+ "The awwwards-mcp parser likely needs an update.");
107
+ }
108
+ cache.upsertSites(sites);
109
+ sitesIndexed += sites.length;
110
+ pagesDone += 1;
111
+ done.add(tag);
112
+ cache.setMeta("index:progress", [...done]);
113
+ log(`[${done.size}/${tags.length}] ${tag}: ${sites.length} sites`);
114
+ }
115
+ return { pagesDone, sitesIndexed, skipped, startedAt };
116
+ }