@sunkimkk/inspo-mcp 0.0.0-stage → 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.
@@ -0,0 +1,469 @@
1
+ // Pinterest client built on the JSON "resource" endpoints the pinterest.com web app uses.
2
+ // Works without login and without a browser. Logged-out search results carry obfuscated
3
+ // pin ids ("e1_..."), so pin details and related pins need a real pin link or numeric id.
4
+ import { fetch, ProxyAgent } from 'undici';
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs/promises';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+
10
+ export const BASE = 'https://www.pinterest.com';
11
+ const UA =
12
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
13
+ const LOCALE = process.env.MCP_PINTEREST_LOCALE || 'en-US';
14
+ const TIMEOUT_MS = Number(process.env.MCP_PINTEREST_TIMEOUT_MS) || 20000;
15
+ const MAX_PAGES = 12;
16
+
17
+ const proxyUrl =
18
+ process.env.MCP_PINTEREST_PROXY ||
19
+ process.env.HTTPS_PROXY ||
20
+ process.env.https_proxy ||
21
+ process.env.HTTP_PROXY ||
22
+ process.env.http_proxy ||
23
+ '';
24
+ const dispatcher = proxyUrl ? new ProxyAgent(proxyUrl) : undefined;
25
+
26
+ export const DOWNLOAD_ROOT =
27
+ process.env.MCP_PINTEREST_DOWNLOAD_DIR || path.join(os.homedir(), 'Downloads', 'pinterest-mcp');
28
+
29
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
+
31
+ async function request(url, init = {}, retries = 2) {
32
+ for (let attempt = 0; ; attempt++) {
33
+ let res;
34
+ try {
35
+ res = await fetch(url, {
36
+ ...init,
37
+ dispatcher,
38
+ signal: AbortSignal.timeout(TIMEOUT_MS),
39
+ headers: { 'User-Agent': UA, 'Accept-Language': LOCALE, ...init.headers },
40
+ });
41
+ } catch (err) {
42
+ if (attempt < retries) {
43
+ await sleep(600 * (attempt + 1));
44
+ continue;
45
+ }
46
+ const hint = proxyUrl ? '' : ' (if pinterest.com is blocked on your network, set MCP_PINTEREST_PROXY)';
47
+ throw new Error(`Network error reaching ${new URL(url).host}: ${err.cause?.message || err.message}${hint}`);
48
+ }
49
+ if ((res.status === 429 || res.status >= 500) && attempt < retries) {
50
+ await sleep(1000 * (attempt + 1));
51
+ continue;
52
+ }
53
+ return res;
54
+ }
55
+ }
56
+
57
+ // Pinterest ties pagination bookmarks to the anonymous session cookie, so keep one jar
58
+ // for www.pinterest.com and send it back on every resource call.
59
+ const jar = new Map();
60
+ function remember(res) {
61
+ for (const c of res.headers.getSetCookie?.() || []) {
62
+ const [pair] = c.split(';');
63
+ const i = pair.indexOf('=');
64
+ if (i > 0) jar.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim());
65
+ }
66
+ }
67
+ const cookieHeader = () => [...jar].map(([k, v]) => `${k}=${v}`).join('; ');
68
+
69
+ async function resource(name, options, sourceUrl, handler) {
70
+ const qs = new URLSearchParams({
71
+ source_url: sourceUrl,
72
+ data: JSON.stringify({ options, context: {} }),
73
+ _: String(Date.now()),
74
+ });
75
+ const res = await request(`${BASE}/resource/${name}/get/?${qs}`, {
76
+ headers: {
77
+ Accept: 'application/json, text/javascript, */*; q=0.01',
78
+ 'X-Requested-With': 'XMLHttpRequest',
79
+ 'X-Pinterest-AppState': 'active',
80
+ 'X-Pinterest-PWS-Handler': handler,
81
+ ...(jar.size ? { Cookie: cookieHeader() } : {}),
82
+ ...(jar.get('csrftoken') ? { 'X-CSRFToken': jar.get('csrftoken') } : {}),
83
+ },
84
+ });
85
+ remember(res);
86
+ let body;
87
+ try {
88
+ body = await res.json();
89
+ } catch {
90
+ throw new Error(`Pinterest returned a non-JSON response (HTTP ${res.status}) for ${name}`);
91
+ }
92
+ const rr = body?.resource_response;
93
+ if (!res.ok || rr?.error) {
94
+ const msg = rr?.error?.message || rr?.message || `HTTP ${res.status}`;
95
+ const err = new Error(`Pinterest ${name}: ${msg}`);
96
+ err.status = rr?.error?.http_status || res.status;
97
+ throw err;
98
+ }
99
+ return { data: rr?.data, bookmark: rr?.bookmark };
100
+ }
101
+
102
+ // ---------- normalisation ----------
103
+
104
+ const clean = (s, max = 280) => {
105
+ if (!s || typeof s !== 'string') return undefined;
106
+ const t = s.replace(/\s+/g, ' ').trim();
107
+ if (!t) return undefined;
108
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
109
+ };
110
+
111
+ // Hashtag walls ("#ui #ux #design …") waste tokens; keep the prose and the first few tags.
112
+ function tidyTags(s) {
113
+ if (!s) return s;
114
+ const tags = s.match(/#[^\s#]+/g) || [];
115
+ if (tags.length <= 5) return s;
116
+ const prose = s.replace(/#[^\s#]+/g, '').replace(/\s+/g, ' ').trim();
117
+ return `${prose} ${tags.slice(0, 5).join(' ')}`.trim();
118
+ }
119
+
120
+ function pickImage(images, ...keys) {
121
+ for (const k of keys) if (images?.[k]?.url) return images[k];
122
+ return undefined;
123
+ }
124
+
125
+ // Video pins carry their streams either on p.videos (regular video pins, usually HLS only)
126
+ // or on story_pin_data blocks (idea pins, usually with MP4 renditions). Prefer a 720p MP4.
127
+ // HLS-only pins still have a single-file rendition next to the playlist:
128
+ // mc/hls/<path>.m3u8 -> mc/720p/<path>.mp4
129
+ // iht/hls/<path>.m3u8 -> iht/hls/<path>_720w.cmfv (fragmented MP4, video track only)
130
+ const MP4_KEYS = ['V_720P', 'V_EXP7', 'V_EXP6', 'V_EXP5', 'V_EXP4', 'V_EXP3'];
131
+ export function pickVideo(p) {
132
+ const lists = [
133
+ p.videos?.video_list,
134
+ ...(p.story_pin_data?.pages || []).flatMap((pg) => (pg?.blocks || []).map((b) => b?.video?.video_list)),
135
+ ].filter(Boolean);
136
+ if (!lists.length) return undefined;
137
+ const all = lists.flatMap((vl) => Object.entries(vl).map(([key, v]) => ({ key, ...v }))).filter((v) => v.url);
138
+ const mp4 =
139
+ MP4_KEYS.map((k) => all.find((v) => v.key === k)).find(Boolean) || all.find((v) => /\.mp4($|\?)/.test(v.url));
140
+ const hls = all.find((v) => /\.m3u8($|\?)/.test(v.url));
141
+ const derived = hls?.url
142
+ .replace(/\/videos\/mc\/hls\/(.+)\.m3u8$/, '/videos/mc/720p/$1.mp4')
143
+ .replace(/\/videos\/iht\/hls\/(.+)\.m3u8$/, '/videos/iht/hls/$1_720w.cmfv');
144
+ const best = mp4 || hls;
145
+ if (!best) return undefined;
146
+ return strip({
147
+ video_url: mp4?.url || (derived !== hls?.url ? derived : undefined) || hls.url,
148
+ video_hls: hls?.url,
149
+ video_width: best.width,
150
+ video_height: best.height,
151
+ video_seconds: best.duration ? Math.round(best.duration / 1000) : undefined,
152
+ });
153
+ }
154
+
155
+ // Some pins were only ever uploaded as a thumbnail: their "originals" URL is no larger than
156
+ // the 236x rendition. Flag them so a model doesn't pick one expecting a full-size image.
157
+ export const LOW_RES_WIDTH = 500;
158
+
159
+ export function normalizePin(p) {
160
+ if (!p || p.type !== 'pin' || p.is_promoted) return null;
161
+ const orig = pickImage(p.images, 'orig', '736x', '564x', '474x', '236x');
162
+ if (!orig) return null;
163
+ const lowRes = Number(orig.width) > 0 && orig.width < LOW_RES_WIDTH;
164
+ const numeric = /^\d+$/.test(String(p.id));
165
+ const saves = p.aggregated_pin_data?.aggregated_stats?.saves ?? p.repin_count;
166
+ const reactions = p.reaction_counts
167
+ ? Object.values(p.reaction_counts).reduce((a, b) => a + (Number(b) || 0), 0)
168
+ : undefined;
169
+ return strip({
170
+ id: numeric ? String(p.id) : undefined,
171
+ pin_url: numeric ? `${BASE}/pin/${p.id}/` : undefined,
172
+ title: clean(p.title || p.grid_title, 140),
173
+ description: clean(tidyTags(p.description), 160),
174
+ alt_text: clean(p.auto_alt_text || p.seo_alt_text, 200),
175
+ image_url: orig.url,
176
+ width: orig.width,
177
+ height: orig.height,
178
+ low_res: lowRes || undefined,
179
+ dominant_color: p.dominant_color,
180
+ ...(pickVideo(p) || {}),
181
+ source_link: p.link || undefined,
182
+ source_domain: p.domain && p.domain !== 'Uploaded by user' ? p.domain : undefined,
183
+ board: p.board?.url ? strip({ name: p.board.name, url: `${BASE}${p.board.url}` }) : undefined,
184
+ pinner: p.pinner?.username || undefined,
185
+ saves: Number.isFinite(saves) && saves > 0 ? saves : undefined,
186
+ reactions: reactions || undefined,
187
+ created_at: p.created_at ? new Date(p.created_at).toISOString().slice(0, 10) : undefined,
188
+ });
189
+ }
190
+
191
+ function strip(obj) {
192
+ for (const k of Object.keys(obj)) if (obj[k] === undefined || obj[k] === null || obj[k] === '') delete obj[k];
193
+ return obj;
194
+ }
195
+
196
+ const MEDIA = {
197
+ any: () => true,
198
+ video: (pin) => Boolean(pin.video_url),
199
+ image: (pin) => !pin.video_url,
200
+ };
201
+
202
+ // Returns how many unseen pins the page had (before the media filter), which is what
203
+ // tells a real next page apart from a replayed one.
204
+ function collect(items, out, seen, limit, keep) {
205
+ let fresh = 0;
206
+ for (const raw of items || []) {
207
+ if (out.length >= limit) break;
208
+ const pin = normalizePin(raw);
209
+ if (!pin || seen.has(pin.image_url)) continue;
210
+ seen.add(pin.image_url);
211
+ fresh++;
212
+ if (keep(pin)) out.push(pin);
213
+ }
214
+ return fresh;
215
+ }
216
+
217
+ // Paginates a resource until `limit` pins are collected or the feed ends.
218
+ // Pinterest now and then ignores the bookmark and replays page one marked "-end-";
219
+ // a page that adds nothing new, or an early "-end-" when `expected` says more exist,
220
+ // is retried with the same bookmark before giving up.
221
+ async function paged(fetchPage, limit, bookmark, expected = 0, media = 'any') {
222
+ const keep = MEDIA[media] || MEDIA.any;
223
+ const out = [];
224
+ const seen = new Set();
225
+ let next = bookmark || undefined;
226
+ let retries = 0;
227
+ for (let page = 0; page < MAX_PAGES && out.length < limit; page++) {
228
+ const { items, bookmark: bm } = await fetchPage(next);
229
+ const added = collect(items, out, seen, limit, keep);
230
+ const endedEarly = media === 'any' && (!bm || bm === '-end-') && out.length < Math.min(limit, expected * 0.9);
231
+ if (((next && !added) || endedEarly) && retries < 3) {
232
+ retries++;
233
+ // A degraded session keeps serving the same short feed; start a fresh one.
234
+ if (!next) jar.clear();
235
+ await sleep(300 * retries);
236
+ page--;
237
+ continue;
238
+ }
239
+ retries = 0;
240
+ next = bm && bm !== '-end-' ? bm : undefined;
241
+ if (!next || !items?.length) break;
242
+ }
243
+ return { pins: out, next_bookmark: next };
244
+ }
245
+
246
+ // ---------- public API ----------
247
+
248
+ export async function searchPins(query, { limit = 20, bookmark, media = 'any' } = {}) {
249
+ const sourceUrl = `/search/pins/?q=${encodeURIComponent(query)}&rs=typed`;
250
+ return paged(async (bm) => {
251
+ const options = { query, scope: 'pins', rs: 'typed', redux_normalize_feed: true };
252
+ if (bm) options.bookmarks = [bm];
253
+ const { data, bookmark: next } = await resource('BaseSearchResource', options, sourceUrl, 'www/search/[scope].js');
254
+ return { items: data?.results, bookmark: next };
255
+ }, limit, bookmark, 0, media);
256
+ }
257
+
258
+ // Accepts a numeric id, a pinterest.*/pin/<id> URL (any regional domain) or a pin.it short link.
259
+ export async function resolvePinId(input) {
260
+ const s = String(input).trim();
261
+ if (/^\d+$/.test(s)) return s;
262
+ if (/^e1_/.test(s)) {
263
+ throw new Error(
264
+ 'This is an anonymous search-result id, which Pinterest does not let logged-out clients look up. ' +
265
+ 'Everything it has is already in the search result; for details or related pins use a real pin link (pinterest.com/pin/<number>/).'
266
+ );
267
+ }
268
+ let url;
269
+ try {
270
+ url = new URL(/^https?:\/\//.test(s) ? s : `https://${s}`);
271
+ } catch {
272
+ throw new Error(`Not a pin id or pin URL: ${s}`);
273
+ }
274
+ if (url.hostname === 'pin.it') {
275
+ const res = await request(url.href, { redirect: 'follow' });
276
+ url = new URL(res.url);
277
+ await res.body?.cancel();
278
+ }
279
+ const m = url.pathname.match(/\/pin\/(?:[^/]*--)?(\d+)/);
280
+ if (!m) throw new Error(`Could not find a pin id in ${s}`);
281
+ return m[1];
282
+ }
283
+
284
+ export async function getPin(input) {
285
+ const id = await resolvePinId(input);
286
+ const { data } = await resource('PinResource', { id, field_set_key: 'detailed' }, `/pin/${id}/`, 'www/pin/[id].js');
287
+ const pin = normalizePin({ ...data, is_promoted: false });
288
+ if (!pin) throw new Error(`Pin ${id} has no image`);
289
+ return strip({
290
+ ...pin,
291
+ description: clean(data.description, 2000),
292
+ comments: data.comment_count || undefined,
293
+ hashtags: data.hashtags?.length ? data.hashtags : undefined,
294
+ rich_summary: data.rich_summary?.display_name ? clean(data.rich_summary.display_name, 200) : undefined,
295
+ });
296
+ }
297
+
298
+ export async function relatedPins(input, { limit = 20, bookmark, media = 'any' } = {}) {
299
+ const id = await resolvePinId(input);
300
+ return paged(async (bm) => {
301
+ const options = {
302
+ pin_id: id,
303
+ context_pin_ids: [],
304
+ search_query: '',
305
+ source: 'deep_linking',
306
+ top_level_source: 'deep_linking',
307
+ top_level_source_depth: 1,
308
+ is_pdp: false,
309
+ };
310
+ if (bm) options.bookmarks = [bm];
311
+ const { data, bookmark: next } = await resource('RelatedModulesResource', options, `/pin/${id}/`, 'www/pin/[id].js');
312
+ return { items: data, bookmark: next };
313
+ }, limit, bookmark, 0, media);
314
+ }
315
+
316
+ function parseBoard(input) {
317
+ let s = String(input).trim();
318
+ if (/^https?:\/\//.test(s) || /pinterest\./.test(s)) {
319
+ s = new URL(/^https?:\/\//.test(s) ? s : `https://${s}`).pathname;
320
+ }
321
+ const parts = s.split('/').filter(Boolean);
322
+ if (parts.length < 2) throw new Error(`Expected a board URL like pinterest.com/<user>/<board>/, got: ${input}`);
323
+ return { username: decodeURIComponent(parts[0]), slug: decodeURIComponent(parts[1]), path: `/${parts[0]}/${parts[1]}/` };
324
+ }
325
+
326
+ export async function boardPins(input, { limit = 25, bookmark, media = 'any' } = {}) {
327
+ const b = parseBoard(input);
328
+ const handler = 'www/[username]/[slug].js';
329
+ const { data: board } = await resource(
330
+ 'BoardResource',
331
+ { username: b.username, slug: b.slug, field_set_key: 'detailed' },
332
+ b.path,
333
+ handler
334
+ );
335
+ const result = await paged(async (bm) => {
336
+ const options = { board_id: board.id, board_url: b.path, page_size: 100, field_set_key: 'react_grid_pin' };
337
+ if (bm) options.bookmarks = [bm];
338
+ const { data, bookmark: next } = await resource('BoardFeedResource', options, b.path, handler);
339
+ return { items: data, bookmark: next };
340
+ }, limit, bookmark, board.pin_count, media);
341
+ return {
342
+ board: strip({
343
+ name: board.name,
344
+ url: `${BASE}${b.path}`,
345
+ description: clean(board.description),
346
+ pin_count: board.pin_count,
347
+ owner: board.owner?.username,
348
+ }),
349
+ ...result,
350
+ };
351
+ }
352
+
353
+ // ---------- images ----------
354
+
355
+ function assertPinimg(u) {
356
+ let url;
357
+ try {
358
+ url = new URL(u);
359
+ } catch {
360
+ throw new Error(`Invalid image URL: ${u}`);
361
+ }
362
+ if (url.protocol !== 'https:' || !/(^|\.)pinimg\.com$/.test(url.hostname)) {
363
+ throw new Error(`Only https://*.pinimg.com image URLs are allowed: ${u}`);
364
+ }
365
+ return url;
366
+ }
367
+
368
+ // i.pinimg.com/<size>/aa/bb/cc/hash.jpg — swap the size segment. Resized renditions are
369
+ // always JPEG, even when the original is a PNG or GIF, so the extension changes with them.
370
+ export function resizeUrl(u, size) {
371
+ const swapped = u.replace(/(pinimg\.com\/)(originals|\d+x\d*(?:_RS)?)\//, `$1${size}/`);
372
+ return size === 'originals' ? swapped : swapped.replace(/\.(png|gif|webp)$/i, '.jpg');
373
+ }
374
+
375
+ const EXT = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'video/mp4': 'mp4' };
376
+
377
+ async function fetchImage(u, sizes) {
378
+ assertPinimg(u);
379
+ const candidates = [...new Set(sizes.map((s) => resizeUrl(u, s)).concat(u))];
380
+ let lastErr;
381
+ for (const c of candidates) {
382
+ const res = await request(c, { headers: { Referer: `${BASE}/` } }, 1);
383
+ const type = (res.headers.get('content-type') || '').split(';')[0];
384
+ if (res.ok && type.startsWith('image/')) {
385
+ return { url: c, type, buf: Buffer.from(await res.arrayBuffer()) };
386
+ }
387
+ await res.body?.cancel();
388
+ lastErr = `HTTP ${res.status}`;
389
+ }
390
+ throw new Error(`Could not fetch ${u}: ${lastErr}`);
391
+ }
392
+
393
+ const MAX_VIDEO_BYTES = 200 * 1024 * 1024;
394
+ const isVideoUrl = (u) => /\.(mp4|m3u8|cmfv)($|\?)/i.test(u);
395
+
396
+ async function fetchVideo(u) {
397
+ assertPinimg(u);
398
+ if (/\.m3u8($|\?)/i.test(u)) {
399
+ throw new Error(`${u} is an HLS stream with no MP4 rendition; open it in Safari or convert it with ffmpeg`);
400
+ }
401
+ // A _720w.cmfv rendition may not exist for small videos; step down before giving up.
402
+ const tries = /_\d+w\.cmfv$/.test(u) ? ['720w', '540w', '360w'].map((w) => u.replace(/_\d+w\.cmfv$/, `_${w}.cmfv`)) : [u];
403
+ let res;
404
+ let type = '';
405
+ for (const t of tries) {
406
+ res = await request(t, { headers: { Referer: `${BASE}/` } }, 1);
407
+ type = (res.headers.get('content-type') || '').split(';')[0];
408
+ if (res.ok && type.startsWith('video/')) {
409
+ u = t;
410
+ break;
411
+ }
412
+ await res.body?.cancel();
413
+ }
414
+ if (!res.ok || !type.startsWith('video/')) {
415
+ throw new Error(`Could not fetch ${u}: HTTP ${res.status}${res.ok ? ` (${type})` : ''}; try the pin's video_hls stream instead`);
416
+ }
417
+ if (Number(res.headers.get('content-length')) > MAX_VIDEO_BYTES) {
418
+ await res.body?.cancel();
419
+ throw new Error(`${u} is larger than ${MAX_VIDEO_BYTES / 1024 / 1024} MB`);
420
+ }
421
+ return { url: u, type, buf: Buffer.from(await res.arrayBuffer()) };
422
+ }
423
+
424
+ export async function fetchPreview(u, size = '236x') {
425
+ const { type, buf } = await fetchImage(u, [size, '474x']);
426
+ return { mimeType: type, data: buf.toString('base64') };
427
+ }
428
+
429
+ async function mapLimit(items, n, fn) {
430
+ const out = new Array(items.length);
431
+ let i = 0;
432
+ await Promise.all(
433
+ Array.from({ length: Math.min(n, items.length) }, async () => {
434
+ while (i < items.length) {
435
+ const idx = i++;
436
+ out[idx] = await fn(items[idx], idx);
437
+ }
438
+ })
439
+ );
440
+ return out;
441
+ }
442
+
443
+ const safeName = (s) =>
444
+ String(s || '')
445
+ .normalize('NFKC')
446
+ .replace(/[\\/:*?"<>|\x00-\x1f]+/g, ' ')
447
+ .replace(/\s+/g, '_')
448
+ .replace(/^[._]+|[._]+$/g, '')
449
+ .slice(0, 60);
450
+
451
+ export async function downloadImages(urls, { folder, size = 'originals', directory } = {}) {
452
+ const root = path.resolve(directory || DOWNLOAD_ROOT);
453
+ const dir = folder ? path.join(root, safeName(folder) || 'pins') : root;
454
+ await fs.mkdir(dir, { recursive: true });
455
+ const sizes = size === 'originals' ? ['originals', '736x'] : [size, '736x', 'originals'];
456
+ const results = await mapLimit(urls, 4, async (u, idx) => {
457
+ try {
458
+ const img = isVideoUrl(u) ? await fetchVideo(u) : await fetchImage(u, sizes);
459
+ const hash = path.basename(new URL(img.url).pathname).replace(/\.\w+$/, '') ||
460
+ crypto.createHash('sha1').update(img.url).digest('hex').slice(0, 16);
461
+ const file = path.join(dir, `${String(idx + 1).padStart(2, '0')}_${hash}.${EXT[img.type] || 'jpg'}`);
462
+ await fs.writeFile(file, img.buf);
463
+ return { ok: true, url: img.url, path: file, bytes: img.buf.length };
464
+ } catch (err) {
465
+ return { ok: false, url: u, error: err.message };
466
+ }
467
+ });
468
+ return { directory: dir, saved: results.filter((r) => r.ok), failed: results.filter((r) => !r.ok) };
469
+ }
@@ -0,0 +1,167 @@
1
+ // Dribbble tools. Names and inputs match the standalone dribbble-mcp.
2
+ import {
3
+ CATEGORIES,
4
+ DOWNLOAD_ROOT,
5
+ SORTS,
6
+ downloadMedia,
7
+ exploreShots,
8
+ fetchPreview,
9
+ searchShots,
10
+ userShots,
11
+ } from '../sources/dribbble.js';
12
+
13
+ export default function registerDribbble({ tool, z, json, READ_ONLY, MAX_PREVIEWS }) {
14
+ async function withPreviews(shots, count) {
15
+ const picked = shots.slice(0, Math.min(count, MAX_PREVIEWS));
16
+ const blocks = await Promise.all(
17
+ picked.map(async (s, i) => {
18
+ try {
19
+ const img = await fetchPreview(s.image_url);
20
+ const motion = s.video_url || s.gif_url ? ' [motion: still frame shown]' : '';
21
+ return [{ type: 'text', text: `#${i + 1}${s.title ? ` ${s.title}` : ''}${motion}` }, { type: 'image', ...img }];
22
+ } catch {
23
+ return [];
24
+ }
25
+ })
26
+ );
27
+ return blocks.flat();
28
+ }
29
+
30
+ const limit = (def, max) =>
31
+ z.number().int().min(1).max(max).default(def).describe(`How many shots to return (1-${max}, default ${def})`);
32
+ const page = z.number().int().min(1).default(1).describe('Start page; pass next_page from a previous call to continue');
33
+ const sort = z.enum(SORTS).default('popular').describe('"popular" (default) or "recent"');
34
+ const media = z
35
+ .enum(['any', 'video', 'image'])
36
+ .default('any')
37
+ .describe('Only animated shots ("video": MP4 or GIF), only still images ("image"), or both (default)');
38
+ const previews = z
39
+ .number()
40
+ .int()
41
+ .min(0)
42
+ .max(MAX_PREVIEWS)
43
+ .default(0)
44
+ .describe(
45
+ `Also attach small thumbnails of the first N shots so you can actually look at them (0-${MAX_PREVIEWS}, default 0). ` +
46
+ 'Use this when judging visual style, layout or colour matters.'
47
+ );
48
+
49
+ const listResult = (extra, res) => [json({ ...extra, count: res.shots.length, next_page: res.next_page, shots: res.shots })];
50
+
51
+ const SHOT_FIELDS =
52
+ 'Each shot has title, tags, image_url (full-size original on cdn.dribbble.com), video_url (MP4) or gif_url for ' +
53
+ 'animated shots, dominant colour, designer and designer_url, likes, views and publish date.';
54
+
55
+ tool(
56
+ 'dribbble_search',
57
+ {
58
+ title: 'Search Dribbble',
59
+ description:
60
+ `Search Dribbble shots by keyword for UI, product, brand and illustration references. ${SHOT_FIELDS} ` +
61
+ 'Dribbble only gives logged-out visitors the first batch of search results (about 36-48 shots), so next_page is ' +
62
+ 'usually empty; for more, try other keywords, dribbble_explore, or a designer\'s dribbble_user_shots. ' +
63
+ 'English keywords work best.',
64
+ inputSchema: { query: z.string().min(1).describe('Search keywords'), sort, limit: limit(24, 48), media, previews },
65
+ annotations: READ_ONLY,
66
+ },
67
+ async ({ query, sort, limit, media, previews }) => {
68
+ const res = await searchShots(query, { sort, limit, media });
69
+ const content = listResult({ query, sort }, res);
70
+ if (previews) content.push(...(await withPreviews(res.shots, previews)));
71
+ return { content };
72
+ }
73
+ );
74
+
75
+ tool(
76
+ 'dribbble_explore',
77
+ {
78
+ title: 'Explore Dribbble',
79
+ description:
80
+ `Browse Dribbble's popular or recent shots, optionally within a category (${CATEGORIES.join(', ')}). ` +
81
+ `Pages normally. ${SHOT_FIELDS}`,
82
+ inputSchema: {
83
+ category: z.enum(CATEGORIES).optional().describe('Category; omit for all'),
84
+ sort,
85
+ limit: limit(24, 100),
86
+ page,
87
+ media,
88
+ previews,
89
+ },
90
+ annotations: READ_ONLY,
91
+ },
92
+ async ({ category, sort, limit, page, media, previews }) => {
93
+ const res = await exploreShots({ category, sort, limit, page, media });
94
+ const content = listResult({ category, sort }, res);
95
+ if (previews) content.push(...(await withPreviews(res.shots, previews)));
96
+ return { content };
97
+ }
98
+ );
99
+
100
+ tool(
101
+ 'dribbble_user_shots',
102
+ {
103
+ title: 'Shots by a Dribbble designer',
104
+ description:
105
+ 'List the shots on a designer\'s or team\'s profile, e.g. a designer_url from another result. Accepts ' +
106
+ `dribbble.com/<name>, "@name" or "name". Pages normally. ${SHOT_FIELDS}`,
107
+ inputSchema: { user: z.string().min(1).describe('Profile URL or username'), limit: limit(24, 100), page, media, previews },
108
+ annotations: READ_ONLY,
109
+ },
110
+ async ({ user, limit, page, media, previews }) => {
111
+ const res = await userShots(user, { limit, page, media });
112
+ const content = [json({ designer_url: res.designer_url, count: res.shots.length, next_page: res.next_page, shots: res.shots })];
113
+ if (previews) content.push(...(await withPreviews(res.shots, previews)));
114
+ return { content };
115
+ }
116
+ );
117
+
118
+ tool(
119
+ 'dribbble_preview_images',
120
+ {
121
+ title: 'Look at Dribbble images',
122
+ description:
123
+ 'Fetch small JPEG thumbnails of shot images (image_url values from other tools) so you can see them. ' +
124
+ `Up to ${MAX_PREVIEWS} at a time. Animated shots: pass image_url, which is their still frame.`,
125
+ inputSchema: {
126
+ image_urls: z.array(z.string()).min(1).max(MAX_PREVIEWS).describe('cdn.dribbble.com image URLs'),
127
+ width: z.union([z.literal(400), z.literal(800), z.literal(1200)]).default(400).describe('Thumbnail width; larger costs more tokens'),
128
+ },
129
+ annotations: READ_ONLY,
130
+ },
131
+ async ({ image_urls, width }) => {
132
+ const content = [];
133
+ for (const [i, u] of image_urls.entries()) {
134
+ try {
135
+ content.push({ type: 'text', text: `#${i + 1} ${u}` }, { type: 'image', ...(await fetchPreview(u, width)) });
136
+ } catch (err) {
137
+ content.push({ type: 'text', text: `#${i + 1} failed: ${err.message}` });
138
+ }
139
+ }
140
+ return { content };
141
+ }
142
+ );
143
+
144
+ tool(
145
+ 'dribbble_download',
146
+ {
147
+ title: 'Download Dribbble images and videos',
148
+ description:
149
+ 'Save shot originals to disk: image_url (full-resolution PNG/JPG), video_url (MP4) or gif_url. ' +
150
+ `Default location: ${DOWNLOAD_ROOT}/<folder>. Returns the saved file paths. Shots belong to their designers: ` +
151
+ 'use downloads as private references and keep the shot_url for credit.',
152
+ inputSchema: {
153
+ urls: z.array(z.string()).min(1).max(200).describe('image_url, video_url or gif_url values (cdn.dribbble.com)'),
154
+ folder: z.string().optional().describe('Sub-folder name, e.g. the search topic'),
155
+ directory: z.string().optional().describe(`Absolute base directory (default ${DOWNLOAD_ROOT})`),
156
+ },
157
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
158
+ },
159
+ async ({ urls, folder, directory }) => {
160
+ const res = await downloadMedia(urls, { folder, directory });
161
+ return {
162
+ content: [json({ directory: res.directory, saved_count: res.saved.length, ...res })],
163
+ isError: res.saved.length === 0,
164
+ };
165
+ }
166
+ );
167
+ }