sbuilder-mcp 0.82.2 → 0.82.4
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 +14 -0
- package/CHANGELOG.vi.md +14 -0
- package/dist/transport/media.js +130 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,20 @@ 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.82.4] - 2026-10-10
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- `sb_media_upload` with `url` enforces a total time limit on the download, so a server that trickles bytes can no longer hang the call.
|
|
13
|
+
- `sb_media_upload` with `url` rejects a download that switches protocols, closes without answering or returns a status outside 200–599, instead of hanging or crashing the MCP process.
|
|
14
|
+
- `sb_media_upload` with `url` refuses a file larger than the platform's 100 MiB upload cap, early by `content-length` and while the body arrives.
|
|
15
|
+
- `sb_media_upload` with `url` honours the requested IP family and rejects unparseable resolved addresses during the address check.
|
|
16
|
+
|
|
17
|
+
## [0.82.3] - 2026-10-10
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- `sb_media_upload` with `url` pins the address check to the connection itself, so a DNS answer that changes between the check and the connect (DNS rebinding) can no longer reach a private or loopback host.
|
|
21
|
+
- `sb_media_upload` with `url` sends a user-agent header when it fetches a file, so CDNs that answer 403 without one now serve the download.
|
|
22
|
+
|
|
9
23
|
## [0.82.2] - 2026-10-10
|
|
10
24
|
|
|
11
25
|
### Fixed
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,20 @@ 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.82.4] - 2026-10-10
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- `sb_media_upload` với `url` áp dụng hạn chót tổng cho cả lần tải, nên server nhỏ giọt từng byte không còn làm treo lệnh gọi.
|
|
13
|
+
- `sb_media_upload` với `url` từ chối bản tải chuyển giao thức, đóng kết nối mà không trả lời hoặc trả status ngoài 200–599, thay vì treo hay làm sập process MCP.
|
|
14
|
+
- `sb_media_upload` với `url` từ chối file lớn hơn giới hạn upload 100 MiB của platform, ngay từ `content-length` và cả khi nội dung đang về.
|
|
15
|
+
- `sb_media_upload` với `url` tôn trọng họ địa chỉ IP được yêu cầu và từ chối địa chỉ phân giải không hợp lệ trong bước kiểm tra địa chỉ.
|
|
16
|
+
|
|
17
|
+
## [0.82.3] - 2026-10-10
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- `sb_media_upload` với `url` ghim bước kiểm tra địa chỉ vào chính kết nối, nên câu trả lời DNS thay đổi giữa lúc kiểm tra và lúc kết nối (DNS rebinding) không còn dẫn tới host riêng hoặc loopback.
|
|
21
|
+
- `sb_media_upload` với `url` gửi header user-agent khi tải file, nên các CDN trả 403 khi thiếu header này giờ cho tải bình thường.
|
|
22
|
+
|
|
9
23
|
## [0.82.2] - 2026-10-10
|
|
10
24
|
|
|
11
25
|
### Fixed
|
package/dist/transport/media.js
CHANGED
|
@@ -4,6 +4,8 @@ import { guardLocal } from './localfile.js';
|
|
|
4
4
|
import { basename, extname } from 'node:path';
|
|
5
5
|
import { lookup } from 'node:dns/promises';
|
|
6
6
|
import { BlockList, isIP } from 'node:net';
|
|
7
|
+
import { request as httpRequest } from 'node:http';
|
|
8
|
+
import { request as httpsRequest } from 'node:https';
|
|
7
9
|
import { ApiError } from './http.js';
|
|
8
10
|
import { siteToken } from '../tools/credentialpick.js';
|
|
9
11
|
/**
|
|
@@ -140,9 +142,10 @@ async function fromUrl(ctx, siteId, url, source) {
|
|
|
140
142
|
* checked — a hostname regex is beaten by `[::ffff:127.0.0.1]` or a DNS name that
|
|
141
143
|
* points inward — and a redirect is followed by hand, each hop checked again.
|
|
142
144
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
145
|
+
* The check is PINNED to the connection: the real fetch goes through node:http(s)
|
|
146
|
+
* with a `lookup` that checks the very address it hands to the socket, so a DNS
|
|
147
|
+
* answer that changes between "check" and "connect" (rebinding) still meets the
|
|
148
|
+
* check. Only an injected `ctx.fetchImpl` (tests) skips that path.
|
|
146
149
|
*/
|
|
147
150
|
// No ::ffff:0:0/96 rule: Node's BlockList already applies the IPv4 rules to an
|
|
148
151
|
// IPv4-mapped address, and that rule would match EVERY IPv4 address (measured).
|
|
@@ -169,15 +172,136 @@ async function assertPublic(ctx, raw) {
|
|
|
169
172
|
throw new Error(`sbuilder: could not resolve ${host}.`);
|
|
170
173
|
});
|
|
171
174
|
for (const a of addrs) {
|
|
172
|
-
if (
|
|
175
|
+
if (isPrivate(a))
|
|
173
176
|
throw refuse();
|
|
174
177
|
}
|
|
175
178
|
}
|
|
176
|
-
|
|
179
|
+
const isPrivate = (a) => PRIVATE.check(a, isIP(a) === 6 ? 'ipv6' : 'ipv4');
|
|
180
|
+
/** A `lookup` for node:net that refuses to hand a private address to the socket. */
|
|
181
|
+
export function pinnedLookup(resolve = (h) => lookup(h, { all: true })) {
|
|
182
|
+
return (host, opts, cb) => {
|
|
183
|
+
resolve(host)
|
|
184
|
+
.then((all) => {
|
|
185
|
+
// The verdict is worked out INSIDE a try and `cb` is called once: a throw
|
|
186
|
+
// here would be an unhandled rejection, which ends the MCP process.
|
|
187
|
+
let err = null;
|
|
188
|
+
let addrs = all;
|
|
189
|
+
try {
|
|
190
|
+
if (!all.length || all.some((a) => isIP(a.address) === 0 || isPrivate(a.address))) {
|
|
191
|
+
err = new Error('sbuilder: a media url must be a public http(s) address.');
|
|
192
|
+
}
|
|
193
|
+
else if (opts.family === 4 || opts.family === 6) {
|
|
194
|
+
addrs = all.filter((a) => a.family === opts.family);
|
|
195
|
+
if (!addrs.length)
|
|
196
|
+
err = new Error(`sbuilder: ${host} has no IPv${opts.family} address.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
err = e;
|
|
201
|
+
}
|
|
202
|
+
if (err)
|
|
203
|
+
cb(err);
|
|
204
|
+
else if (opts.all)
|
|
205
|
+
cb(null, addrs);
|
|
206
|
+
else
|
|
207
|
+
cb(null, addrs[0].address, addrs[0].family);
|
|
208
|
+
}, (e) => cb(e))
|
|
209
|
+
.catch(() => { });
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const NULL_BODY = new Set([101, 204, 205, 304]);
|
|
213
|
+
/** The platform's own upload cap (`media.MaxUploadBytes`, 100 MiB): nothing larger is worth holding. */
|
|
214
|
+
const MAX_FETCH_BYTES = 100 << 20;
|
|
215
|
+
/**
|
|
216
|
+
* `lookupFn` is the pin, and `deadlineMs` the whole-request limit; both injectable
|
|
217
|
+
* only so a test can reach a local server quickly.
|
|
218
|
+
*
|
|
219
|
+
* EVERY WAY OUT SETTLES THE PROMISE ONCE, and nothing throws from a socket
|
|
220
|
+
* callback (an uncaught exception ends the MCP process): a total deadline (an idle
|
|
221
|
+
* timeout is reset by every trickled byte), a `101`/upgrade or a close with no
|
|
222
|
+
* answer is a rejection, and the body is refused early by content-length and
|
|
223
|
+
* capped as it arrives.
|
|
224
|
+
*
|
|
225
|
+
* ponytail: the body is held whole (about 3 copies at peak, up to the cap); stream
|
|
226
|
+
* it into the upload if concurrent large fetches ever matter.
|
|
227
|
+
*/
|
|
228
|
+
export function pinnedFetch(url, lookupFn = pinnedLookup(), deadlineMs = 60_000) {
|
|
229
|
+
const u = new URL(url);
|
|
230
|
+
const send = u.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
let done = false;
|
|
233
|
+
const deadline = setTimeout(() => req.destroy(new Error('sbuilder: the media url took too long.')), deadlineMs);
|
|
234
|
+
const ok = (r) => {
|
|
235
|
+
if (done)
|
|
236
|
+
return;
|
|
237
|
+
done = true;
|
|
238
|
+
clearTimeout(deadline);
|
|
239
|
+
resolve(r);
|
|
240
|
+
};
|
|
241
|
+
const fail = (e) => {
|
|
242
|
+
if (done)
|
|
243
|
+
return;
|
|
244
|
+
done = true;
|
|
245
|
+
clearTimeout(deadline);
|
|
246
|
+
reject(e);
|
|
247
|
+
};
|
|
248
|
+
// The headers global fetch sends: many CDNs answer 403 to a request with no agent.
|
|
249
|
+
const headers = { 'user-agent': 'sbuilder-mcp', accept: '*/*' };
|
|
250
|
+
const req = send(u, { method: 'GET', headers, lookup: lookupFn }, (res) => {
|
|
251
|
+
if (Number(res.headers['content-length'] ?? 0) > MAX_FETCH_BYTES) {
|
|
252
|
+
req.destroy(new Error('sbuilder: the media url is larger than an upload may be.'));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
let chunks = [];
|
|
256
|
+
let size = 0;
|
|
257
|
+
res.on('data', (c) => {
|
|
258
|
+
size += c.length;
|
|
259
|
+
if (size > MAX_FETCH_BYTES)
|
|
260
|
+
req.destroy(new Error('sbuilder: the media url is larger than an upload may be.'));
|
|
261
|
+
else
|
|
262
|
+
chunks.push(c);
|
|
263
|
+
});
|
|
264
|
+
res.on('error', fail);
|
|
265
|
+
res.on('end', () => {
|
|
266
|
+
try {
|
|
267
|
+
const h = new Headers();
|
|
268
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
269
|
+
if (v === undefined)
|
|
270
|
+
continue;
|
|
271
|
+
try {
|
|
272
|
+
h.set(k, Array.isArray(v) ? v.join(', ') : v);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// A header value fetch would refuse (non-ByteString) — not one this needs.
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const status = res.statusCode ?? 502;
|
|
279
|
+
if (status < 200 || status > 599)
|
|
280
|
+
throw new Error(`sbuilder: the media url answered status ${status}.`);
|
|
281
|
+
const body = NULL_BODY.has(status) ? null : Buffer.concat(chunks);
|
|
282
|
+
chunks = [];
|
|
283
|
+
ok(new Response(body, { status, headers: h }));
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
fail(e);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
req.setTimeout(30_000, () => req.destroy(new Error('sbuilder: the media url timed out.')));
|
|
291
|
+
req.on('upgrade', (_res, sock) => {
|
|
292
|
+
sock.destroy();
|
|
293
|
+
fail(new Error('sbuilder: the media url tried to switch protocols.'));
|
|
294
|
+
});
|
|
295
|
+
req.on('error', fail);
|
|
296
|
+
req.on('close', () => fail(new Error('sbuilder: the media url closed without an answer.')));
|
|
297
|
+
req.end();
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
export async function fetchPublic(ctx, doFetch, url) {
|
|
177
301
|
let at = url;
|
|
178
302
|
for (let hop = 0; hop < 4; hop++) {
|
|
179
303
|
await assertPublic(ctx, at);
|
|
180
|
-
const res = await doFetch(at, { redirect: 'manual' });
|
|
304
|
+
const res = ctx.fetchImpl ? await doFetch(at, { redirect: 'manual' }) : await pinnedFetch(at);
|
|
181
305
|
const next = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
|
|
182
306
|
if (!next)
|
|
183
307
|
return res;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.82.
|
|
3
|
+
"version": "0.82.4",
|
|
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",
|