sbuilder-mcp 0.24.0 → 0.26.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/CHANGELOG.md +13 -0
- package/CHANGELOG.vi.md +13 -0
- package/README.md +1 -1
- package/README.vi.md +7 -1
- package/dist/tools/live.js +73 -5
- package/dist/tools/page.js +97 -1
- package/dist/transport/stock.js +95 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,19 @@ All notable changes to this project are documented in this file.
|
|
|
6
6
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
7
7
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
8
|
|
|
9
|
+
## [0.26.0] - 2026-09-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_media_upload can now search for a real photograph: `query` returns real photographs with the description each photographer wrote, and `pick` uploads the chosen one into the site's own library instead of hotlinking it.
|
|
13
|
+
- sb_media_upload's search takes `orientation` (landscape, portrait, square) to ask the search itself for the right shape instead of cropping the result afterward.
|
|
14
|
+
- sb_media_upload's search results carry photographer credit (name, profile, photo page), and the uploaded asset's name defaults to the photograph's own description so the library stays searchable by what each image shows.
|
|
15
|
+
- An optional `PEXELS_API_KEY` calls Pexels directly for the image search; without one it falls back to a shared proxy, so an `npx` install with no configuration still finds real images.
|
|
16
|
+
|
|
17
|
+
## [0.25.0] - 2026-09-10
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- sb_page_create now attaches the site's own header and footer to a newly created page by default, read off the site's home page, so a page built with these tools carries the same navigation and footer as the rest of the site instead of arriving bare; pass chrome:false to skip it.
|
|
21
|
+
|
|
9
22
|
## [0.24.0] - 2026-09-10
|
|
10
23
|
|
|
11
24
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,19 @@ Mọi thay đổi đáng chú ý của dự án được ghi lại trong file n
|
|
|
6
6
|
Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
7
7
|
và dự án tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
8
|
|
|
9
|
+
## [0.26.0] - 2026-09-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_media_upload giờ có thể tìm ảnh chụp thật: `query` trả về ảnh chụp thật kèm mô tả do chính người chụp viết, và `pick` upload đúng tấm được chọn vào thư viện của site thay vì hotlink.
|
|
13
|
+
- Phần tìm ảnh của sb_media_upload nhận `orientation` (landscape, portrait, square) để hỏi thẳng bộ tìm kiếm về hình dạng phù hợp thay vì cắt ảnh lại sau đó.
|
|
14
|
+
- Kết quả tìm ảnh của sb_media_upload mang theo thông tin ghi công (tên người chụp, trang cá nhân, trang ảnh), và tên của asset sau khi upload mặc định lấy theo mô tả của bức ảnh để thư viện tìm được theo nội dung ảnh.
|
|
15
|
+
- `PEXELS_API_KEY` là tuỳ chọn, dùng để gọi thẳng Pexels cho phần tìm ảnh; không có key thì dùng một proxy chung, nên một bản cài `npx` không cấu hình gì vẫn tìm được ảnh thật.
|
|
16
|
+
|
|
17
|
+
## [0.25.0] - 2026-09-10
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- sb_page_create giờ mặc định gắn header và footer của chính site vào trang mới tạo, đọc từ trang chủ của site, nên một trang được dựng bằng các tool này mang cùng menu điều hướng và footer với phần còn lại của site thay vì trống trơn; truyền chrome:false để bỏ qua.
|
|
21
|
+
|
|
9
22
|
## [0.24.0] - 2026-09-10
|
|
10
23
|
|
|
11
24
|
### Added
|
package/README.md
CHANGED
|
@@ -93,7 +93,7 @@ make, because those mean "this person's account".
|
|
|
93
93
|
| `sb_publish` | Compile the draft into the live page (cascades to shared globals) |
|
|
94
94
|
| `sb_review` | Every defect a visitor would see, each with its fix, plus the five gaps between this store and a paid order |
|
|
95
95
|
| `sb_media_list` | The site's media library |
|
|
96
|
-
| `sb_media_upload` | Add an image and get its URL —
|
|
96
|
+
| `sb_media_upload` | Add an image and get its URL — a local path, a URL the platform fetches, or a SEARCH for real photographs you read and pick from |
|
|
97
97
|
| `sb_live_join` | Join the editor's live-edit room as a visible peer — edits then appear live |
|
|
98
98
|
| `sb_look` | Save, render, and return screenshots plus measured node boxes and layout defects measured on the render |
|
|
99
99
|
| `sb_event` | Give a node a click action — open the cart, go to a page, open a pop-up |
|
package/README.vi.md
CHANGED
|
@@ -65,6 +65,12 @@ là "tài khoản của người này".
|
|
|
65
65
|
|
|
66
66
|
`SB_API` mặc định `http://localhost:8080`. Bí mật chỉ đọc từ biến môi trường.
|
|
67
67
|
|
|
68
|
+
`PEXELS_API_KEY` là **tuỳ chọn**, chỉ ảnh hưởng phần tìm ảnh của `sb_media_upload`: có key thì gọi
|
|
69
|
+
thẳng Pexels (key miễn phí ở <https://www.pexels.com/api/>), không có thì dùng proxy chung mà nhà
|
|
70
|
+
mình đang chạy (`PEXELS_PROXY_BASE`, mặc định `https://mcp.toolvn.io.vn`) — nên bản cài không cấu
|
|
71
|
+
hình gì vẫn tìm được ảnh. Proxy giữ key của chính nó và không bao giờ gửi key về client; đó là ưu
|
|
72
|
+
ái chứ không phải cam kết, và kết quả nói rõ cửa nào đã trả lời.
|
|
73
|
+
|
|
68
74
|
## Bộ tool
|
|
69
75
|
|
|
70
76
|
| Tool | Làm gì |
|
|
@@ -90,7 +96,7 @@ là "tài khoản của người này".
|
|
|
90
96
|
| `sb_publish` | Biên dịch bản nháp thành trang live (lan sang global dùng chung) |
|
|
91
97
|
| `sb_review` | Mọi khiếm khuyết người xem sẽ thấy, kèm lệnh sửa từng cái, và năm khoảng trống chắn giữa cửa hàng với một đơn đã thanh toán |
|
|
92
98
|
| `sb_media_list` | Thư viện ảnh của site |
|
|
93
|
-
| `sb_media_upload` | Thêm ảnh và lấy URL —
|
|
99
|
+
| `sb_media_upload` | Thêm ảnh và lấy URL — file trên máy, một URL để nền tảng tự tải, hoặc TÌM ảnh chụp thật để đọc mô tả rồi chọn |
|
|
94
100
|
| `sb_live_join` | Vào phòng live-edit của editor như một peer nhìn thấy được — sửa gì hiện ngay |
|
|
95
101
|
| `sb_look` | Lưu, render, trả về ảnh chụp kèm box đo được của node và lỗi bố cục đo trên bản render |
|
|
96
102
|
| `sb_event` | Gắn click action cho một node — mở giỏ, sang trang, mở pop-up |
|
package/dist/tools/live.js
CHANGED
|
@@ -27,6 +27,7 @@ import { LiveSession } from '../live/session.js';
|
|
|
27
27
|
import { refuseAppBlockInterior } from '../domains/site/builder.js';
|
|
28
28
|
import { childrenOf, isOverlay, overlayRoot, subtreeIds } from '../core/tree.js';
|
|
29
29
|
import { siteToken } from './credentialpick.js';
|
|
30
|
+
import { searchStock } from '../transport/stock.js';
|
|
30
31
|
import { siteFor } from './context.js';
|
|
31
32
|
import { projectList, MEDIA_FIELDS } from './project.js';
|
|
32
33
|
/**
|
|
@@ -403,21 +404,88 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
403
404
|
}), 'assets', MEDIA_FIELDS)));
|
|
404
405
|
server.registerTool('sb_media_upload', {
|
|
405
406
|
description: 'Put an image into the media library and get its URL back, ready for sb_set. Takes a ' +
|
|
406
|
-
'local
|
|
407
|
-
'
|
|
407
|
+
'local path, a URL, or a SEARCH — `query` returns real photographs with their own ' +
|
|
408
|
+
'descriptions, and `pick` uploads the one you chose. The only way to add an image.',
|
|
408
409
|
inputSchema: {
|
|
409
410
|
site_id: z.string().optional(),
|
|
410
411
|
path: z.string().optional().describe('A file on this machine'),
|
|
411
412
|
url: z.string().optional().describe('Fetched, then uploaded'),
|
|
413
|
+
query: z.string().optional().describe('Search real photographs; read the descriptions, then pick'),
|
|
414
|
+
orientation: z.enum(['landscape', 'portrait', 'square']).optional(),
|
|
415
|
+
pick: z.number().int().optional().describe('The id of the search result to upload'),
|
|
412
416
|
name: z.string().optional(),
|
|
413
417
|
folder_id: z.string().optional(),
|
|
414
418
|
dry_run: z.boolean().optional(),
|
|
415
419
|
},
|
|
416
420
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
417
|
-
}, async ({ site_id: given, path, url, name, folder_id, dry_run }) => {
|
|
421
|
+
}, async ({ site_id: given, path, url, name, folder_id, query, orientation, pick, dry_run }) => {
|
|
418
422
|
const site_id = siteFor(ctx, given);
|
|
419
|
-
|
|
420
|
-
|
|
423
|
+
// A SEARCH IS NOT A GUESS, and the difference is the whole reason this is
|
|
424
|
+
// two steps. Rule 7 records what a keyword glued into a URL returns —
|
|
425
|
+
// `loremflickr` answered "kids,clothing" with a cat statue — and the fault
|
|
426
|
+
// was never stock photography, it was that nobody looked. Every result
|
|
427
|
+
// here carries what it actually SHOWS, so the caller reads the
|
|
428
|
+
// descriptions and CHOOSES; uploading the first hit unread would rebuild
|
|
429
|
+
// the cat statue with better plumbing.
|
|
430
|
+
if (query) {
|
|
431
|
+
const found = await searchStock(ctx.fetchImpl ?? fetch, query, {
|
|
432
|
+
perPage: 8,
|
|
433
|
+
orientation,
|
|
434
|
+
});
|
|
435
|
+
const chosen = pick !== undefined ? found.photos.find((p) => p.id === pick) : undefined;
|
|
436
|
+
if (!chosen) {
|
|
437
|
+
return text({
|
|
438
|
+
...(pick !== undefined ? { no_such_pick: pick } : {}),
|
|
439
|
+
found: found.photos.map((p) => ({
|
|
440
|
+
pick: p.id,
|
|
441
|
+
shows: p.alt || '(the photographer left no description)',
|
|
442
|
+
size: `${p.width}x${p.height}`,
|
|
443
|
+
by: p.photographer,
|
|
444
|
+
})),
|
|
445
|
+
via: found.via,
|
|
446
|
+
next: 'Read what each one SHOWS, then re-call with pick:<id> and dry_run:false. The photo ' +
|
|
447
|
+
"is uploaded into this site's own library, never hotlinked.",
|
|
448
|
+
...(found.via === 'proxy'
|
|
449
|
+
? {
|
|
450
|
+
key: 'No PEXELS_API_KEY, so this used the shared proxy — a courtesy, not a ' +
|
|
451
|
+
'guarantee. A free key at https://www.pexels.com/api/ calls Pexels directly.',
|
|
452
|
+
}
|
|
453
|
+
: {}),
|
|
454
|
+
licence: ctx.notices.once('stock_licence', 'Pexels photographs are free for commercial use and attribution is appreciated ' +
|
|
455
|
+
'rather than required, so a storefront can carry one without printing a credit ' +
|
|
456
|
+
'line. The photographer and the photo page come back with each result if you ' +
|
|
457
|
+
'want to credit anyway.'),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (dry_run !== false) {
|
|
461
|
+
return text({
|
|
462
|
+
dry_run: true,
|
|
463
|
+
would_upload: chosen.url,
|
|
464
|
+
shows: chosen.alt,
|
|
465
|
+
by: chosen.photographer,
|
|
466
|
+
into: site_id,
|
|
467
|
+
note: 'Nothing was sent. Re-call with dry_run:false to upload.',
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
const asset = await uploadMedia(ctx, site_id, {
|
|
471
|
+
url: chosen.url,
|
|
472
|
+
// THE DESCRIPTION BECOMES THE NAME, so the library is searchable by
|
|
473
|
+
// what the photographs show and the alt on the page means something.
|
|
474
|
+
name: name ?? chosen.alt ?? undefined,
|
|
475
|
+
folderId: folder_id,
|
|
476
|
+
});
|
|
477
|
+
return text({
|
|
478
|
+
asset,
|
|
479
|
+
shows: chosen.alt,
|
|
480
|
+
credit: { by: chosen.photographer, profile: chosen.photographer_url, photo: chosen.page_url },
|
|
481
|
+
next: asset.url
|
|
482
|
+
? `Use it: sb_set id "<node>", namespace specials, keys { "src": ${JSON.stringify(asset.url)} }`
|
|
483
|
+
: 'Uploaded, but the server returned no url — read it back with sb_media_list.',
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
if (!path && !url) {
|
|
487
|
+
throw new Error('sbuilder: give sb_media_upload a path, a url, or a query to search');
|
|
488
|
+
}
|
|
421
489
|
if (dry_run !== false) {
|
|
422
490
|
return text({
|
|
423
491
|
dry_run: true,
|
package/dist/tools/page.js
CHANGED
|
@@ -224,6 +224,49 @@ const specSchema = z.lazy(() => z.object({
|
|
|
224
224
|
specials: z.record(z.unknown()).optional(),
|
|
225
225
|
children: z.array(specSchema).optional(),
|
|
226
226
|
}));
|
|
227
|
+
/**
|
|
228
|
+
* THE SITE'S OWN HEADER AND FOOTER, read off the page that already answers it.
|
|
229
|
+
*
|
|
230
|
+
* A site can hold several globals of each kind — this one holds four headers and
|
|
231
|
+
* two footers, most of them experiments — so "the first header" is a guess and
|
|
232
|
+
* a name is a label nobody promised to keep. The HOME PAGE is the site's own
|
|
233
|
+
* answer: whatever chrome it carries is the chrome this site wears.
|
|
234
|
+
*
|
|
235
|
+
* Silent on anything it cannot read. A page created without its chrome is a page
|
|
236
|
+
* a person can fix; a page created with the WRONG chrome is one nobody notices.
|
|
237
|
+
*/
|
|
238
|
+
async function siteChrome(ctx, siteId) {
|
|
239
|
+
try {
|
|
240
|
+
const listed = (await request({
|
|
241
|
+
base: ctx.base,
|
|
242
|
+
method: 'GET',
|
|
243
|
+
path: `/api/sites/${encodeURIComponent(siteId)}/pages`,
|
|
244
|
+
token: siteToken(ctx),
|
|
245
|
+
fetchImpl: ctx.fetchImpl,
|
|
246
|
+
}));
|
|
247
|
+
const home = (listed.pages ?? []).find((p) => p.isHomepage === true);
|
|
248
|
+
if (!home || typeof home.id !== 'string')
|
|
249
|
+
return {};
|
|
250
|
+
const src = await loadSource(ctx, siteId, home.id);
|
|
251
|
+
const doc = PageDoc.from(src.document);
|
|
252
|
+
const out = {};
|
|
253
|
+
for (const id of doc.node(doc.doc.root_node_id).data.nodes) {
|
|
254
|
+
const sp = doc.doc.nodes[id]?.specials;
|
|
255
|
+
const gid = sp?.globalId;
|
|
256
|
+
const kind = sp?.globalKind;
|
|
257
|
+
if (typeof gid !== 'string')
|
|
258
|
+
continue;
|
|
259
|
+
if (kind === 'header' && !out.header)
|
|
260
|
+
out.header = gid;
|
|
261
|
+
if (kind === 'footer' && !out.footer)
|
|
262
|
+
out.footer = gid;
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return {};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
227
270
|
export function registerPageTools(server, ctx) {
|
|
228
271
|
const session = new PageSession(ctx);
|
|
229
272
|
server.registerTool('sb_page_open', {
|
|
@@ -821,12 +864,13 @@ export function registerPageTools(server, ctx) {
|
|
|
821
864
|
is_homepage: z.boolean().optional(),
|
|
822
865
|
settings: z.record(z.unknown()).optional(),
|
|
823
866
|
seed: z.boolean().optional().describe('Default true; false creates a blank page.'),
|
|
867
|
+
chrome: z.boolean().optional().describe("Carry the site's header and footer, default true"),
|
|
824
868
|
locale: z.string().optional().describe("vi (default) or en — the complete page's wording."),
|
|
825
869
|
headline: z.string().optional().describe("The complete page's thank-you line."),
|
|
826
870
|
dry_run: z.boolean().optional(),
|
|
827
871
|
},
|
|
828
872
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
829
|
-
}, async ({ site_id: given, name, type, slug, is_homepage, settings, seed, locale, headline, dry_run }) => {
|
|
873
|
+
}, async ({ site_id: given, name, type, slug, is_homepage, settings, seed, chrome, locale, headline, dry_run }) => {
|
|
830
874
|
const site_id = siteFor(ctx, given);
|
|
831
875
|
const path = `/api/sites/${encodeURIComponent(site_id)}/pages`;
|
|
832
876
|
// TYPE IS THE ROUTE for several kinds of page: /checkout and
|
|
@@ -851,12 +895,25 @@ export function registerPageTools(server, ctx) {
|
|
|
851
895
|
// exists.
|
|
852
896
|
const willSeed = seed !== false && hasSeed(type);
|
|
853
897
|
const summary = willSeed && type ? seedSummary(type) : null;
|
|
898
|
+
// WHAT MAKES THE NEW PAGE PART OF THE SITE.
|
|
899
|
+
//
|
|
900
|
+
// A page created through the editor carries the site's header and footer;
|
|
901
|
+
// one created here carried NEITHER, so an agent building a site produced
|
|
902
|
+
// pages with no navigation and no footer on a site that has both — and
|
|
903
|
+
// nothing reported it, because `sb_review` reads the page and the page is
|
|
904
|
+
// fine, while `siteChrome` asks whether the SITE has globals and it does.
|
|
905
|
+
// Measured: three pages built with these tools, every one of them bare,
|
|
906
|
+
// beside a store page carrying its header as ROOT's first child.
|
|
907
|
+
const wear = chrome !== false ? await siteChrome(ctx, site_id) : {};
|
|
854
908
|
if (dry_run !== false) {
|
|
855
909
|
return text({
|
|
856
910
|
dry_run: true,
|
|
857
911
|
would_post: path,
|
|
858
912
|
body: redact(body),
|
|
859
913
|
...(summary ? { would_seed: { type, ...summary } } : {}),
|
|
914
|
+
...(wear.header || wear.footer
|
|
915
|
+
? { would_wear: { ...(wear.header ? { header: wear.header } : {}), ...(wear.footer ? { footer: wear.footer } : {}) } }
|
|
916
|
+
: {}),
|
|
860
917
|
});
|
|
861
918
|
}
|
|
862
919
|
const res = redact(await request({
|
|
@@ -907,9 +964,48 @@ export function registerPageTools(server, ctx) {
|
|
|
907
964
|
}
|
|
908
965
|
}
|
|
909
966
|
}
|
|
967
|
+
// THE REFERENCE SHAPE IS THE PLATFORM'S OWN (decompose.go:382): a
|
|
968
|
+
// flex-section carrying `globalRef` + `globalKind`. Header FIRST and
|
|
969
|
+
// footer LAST, because compose turns them into real bands and ROOT's
|
|
970
|
+
// children must read header, middle, footer or every save is refused.
|
|
971
|
+
let wearing;
|
|
972
|
+
if ((wear.header || wear.footer) && typeof newId === 'string' && newId) {
|
|
973
|
+
try {
|
|
974
|
+
await session.open(site_id, newId);
|
|
975
|
+
const doc = session.current();
|
|
976
|
+
const patches = [];
|
|
977
|
+
const ids = [];
|
|
978
|
+
if (wear.header) {
|
|
979
|
+
const made = addSubtree(doc, doc.doc.root_node_id, {
|
|
980
|
+
type: 'flex-section',
|
|
981
|
+
specials: { globalRef: wear.header, globalKind: 'header' },
|
|
982
|
+
}, 0);
|
|
983
|
+
doc.apply(made.patches);
|
|
984
|
+
ids.push('header');
|
|
985
|
+
}
|
|
986
|
+
if (wear.footer) {
|
|
987
|
+
const made = addSubtree(doc, doc.doc.root_node_id, {
|
|
988
|
+
type: 'flex-section',
|
|
989
|
+
specials: { globalRef: wear.footer, globalKind: 'footer' },
|
|
990
|
+
});
|
|
991
|
+
doc.apply(made.patches);
|
|
992
|
+
ids.push('footer');
|
|
993
|
+
}
|
|
994
|
+
void patches;
|
|
995
|
+
await session.save();
|
|
996
|
+
wearing = { carries: ids, open: newId };
|
|
997
|
+
}
|
|
998
|
+
catch (e) {
|
|
999
|
+
wearing = {
|
|
1000
|
+
failed: e.message.replace(/^sbuilder:\s*/, '').slice(0, 160),
|
|
1001
|
+
note: 'The page exists. Attach the chrome by hand, or create it again.',
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
910
1005
|
return text({
|
|
911
1006
|
...res,
|
|
912
1007
|
...(seeded ? { seeded } : {}),
|
|
1008
|
+
...(wearing ? { chrome: wearing } : {}),
|
|
913
1009
|
...(renamed
|
|
914
1010
|
? {
|
|
915
1011
|
slug_renamed: `The slug "${slug}" was already taken, so the platform stored ` +
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REAL PHOTOGRAPHS, WITH REAL DESCRIPTIONS.
|
|
3
|
+
*
|
|
4
|
+
* Rule 7 of the design skill is the reason this exists and the reason it is
|
|
5
|
+
* shaped the way it is: "keyword stock imagery is not a source — `loremflickr`
|
|
6
|
+
* answered 'kids,clothing' with a cat statue and a photo of an adult." That
|
|
7
|
+
* failure is not about stock photography, it is about GUESSING: a keyword glued
|
|
8
|
+
* into a URL returns something nobody looked at.
|
|
9
|
+
*
|
|
10
|
+
* A search API is a different thing entirely, because every result carries what
|
|
11
|
+
* it actually SHOWS. Pexels answers "coffee shop" with
|
|
12
|
+
* "Three young adults engage at a cafe counter, using mobile devices" — so the
|
|
13
|
+
* caller can read the descriptions and CHOOSE, which is what a person does and
|
|
14
|
+
* what the cat statue proves nobody was doing.
|
|
15
|
+
*
|
|
16
|
+
* PEXELS, because it is already this family's answer: `webcake-landing-mcp`
|
|
17
|
+
* ships the same client, down to the shared proxy below, and a second house
|
|
18
|
+
* standard for the same job is a second place for it to drift. Its licence is
|
|
19
|
+
* free for commercial use with attribution appreciated rather than required,
|
|
20
|
+
* which is what makes an image usable on a merchant's storefront without
|
|
21
|
+
* printing a credit line they did not ask for.
|
|
22
|
+
*
|
|
23
|
+
* THE KEY IS OPTIONAL, and that is deliberate: with `PEXELS_API_KEY` this calls
|
|
24
|
+
* Pexels directly, and without one it calls the shared proxy the sibling repo
|
|
25
|
+
* already runs (`https://mcp.toolvn.io.vn/api/images/search`), which holds a key
|
|
26
|
+
* and answers the same shape. An `npx` install with no configuration at all
|
|
27
|
+
* still finds images.
|
|
28
|
+
*/
|
|
29
|
+
const PEXELS_SEARCH = 'https://api.pexels.com/v1/search';
|
|
30
|
+
const PROXY_DEFAULT = 'https://mcp.toolvn.io.vn';
|
|
31
|
+
const PROXY_PATH = '/api/images/search';
|
|
32
|
+
const TIMEOUT_MS = 20_000;
|
|
33
|
+
/** The shape both doors answer, normalised here so the caller cannot tell them apart. */
|
|
34
|
+
function normalise(p) {
|
|
35
|
+
const src = (p.src ?? {});
|
|
36
|
+
// `large2x` is ~1880px wide, which is a hero at 1440 and still a few hundred
|
|
37
|
+
// KB; `original` is the native file and on a 3800px photograph that is tens of
|
|
38
|
+
// megabytes for a band nobody will view above 1440.
|
|
39
|
+
const url = src.large2x || src.large || src.original || '';
|
|
40
|
+
if (!url)
|
|
41
|
+
return null;
|
|
42
|
+
return {
|
|
43
|
+
id: Number(p.id ?? 0),
|
|
44
|
+
alt: typeof p.alt === 'string' ? p.alt : '',
|
|
45
|
+
width: Number(p.width ?? 0),
|
|
46
|
+
height: Number(p.height ?? 0),
|
|
47
|
+
photographer: typeof p.photographer === 'string' ? p.photographer : '',
|
|
48
|
+
photographer_url: typeof p.photographer_url === 'string' ? p.photographer_url : '',
|
|
49
|
+
page_url: typeof p.url === 'string' ? p.url : '',
|
|
50
|
+
url,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Search for photographs.
|
|
55
|
+
*
|
|
56
|
+
* `orientation` is a SHAPE request, not a subject one — a hero panel filled with
|
|
57
|
+
* a portrait crop is the aspect-ratio mistake rule 6 is about, and it is far
|
|
58
|
+
* cheaper to ask the search than to crop afterwards.
|
|
59
|
+
*/
|
|
60
|
+
export async function searchStock(fetchImpl, query, opts = {}) {
|
|
61
|
+
const q = new URLSearchParams({
|
|
62
|
+
query,
|
|
63
|
+
per_page: String(Math.min(Math.max(opts.perPage ?? 8, 1), 30)),
|
|
64
|
+
});
|
|
65
|
+
if (opts.orientation)
|
|
66
|
+
q.set('orientation', opts.orientation);
|
|
67
|
+
const key = (process.env.PEXELS_API_KEY ?? '').trim();
|
|
68
|
+
const proxy = (process.env.PEXELS_PROXY_BASE ?? PROXY_DEFAULT).replace(/\/+$/, '');
|
|
69
|
+
const [url, headers, via] = key
|
|
70
|
+
? [`${PEXELS_SEARCH}?${q}`, { Authorization: key }, 'pexels']
|
|
71
|
+
: [`${proxy}${PROXY_PATH}?${q}`, {}, 'proxy'];
|
|
72
|
+
let res;
|
|
73
|
+
try {
|
|
74
|
+
res = await fetchImpl(url, {
|
|
75
|
+
headers: { Accept: 'application/json', ...headers },
|
|
76
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
throw new Error(`sbuilder: could not reach the image search (${via}) — ${e.message}. ` +
|
|
81
|
+
'Set PEXELS_API_KEY for a direct connection, or PEXELS_PROXY_BASE to another host.');
|
|
82
|
+
}
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
throw new Error(`sbuilder: the image search (${via}) answered ${res.status}. ` +
|
|
85
|
+
(via === 'proxy'
|
|
86
|
+
? 'The shared proxy is a courtesy, not a guarantee — set PEXELS_API_KEY (free at ' +
|
|
87
|
+
'https://www.pexels.com/api/) to call Pexels directly.'
|
|
88
|
+
: 'Check PEXELS_API_KEY.'));
|
|
89
|
+
}
|
|
90
|
+
const body = (await res.json());
|
|
91
|
+
const photos = (body.photos ?? [])
|
|
92
|
+
.map(normalise)
|
|
93
|
+
.filter((p) => p !== null);
|
|
94
|
+
return { photos, via };
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "MCP server that designs and operates a Store Builder site — pages, data, theme and publish — through the platform's own API and live-edit protocol.",
|
|
5
5
|
"mcpName": "io.github.vuluu2k/sbuilder-mcp",
|
|
6
6
|
"type": "module",
|