@typeroll/mcp-server 0.42.0 → 0.42.1
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/dist/public-http.js +92 -0
- package/dist/tools/media.js +7 -3
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import dns from 'node:dns/promises';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
const blockedV4 = new net.BlockList();
|
|
7
|
+
for (const [address, prefix] of [
|
|
8
|
+
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
|
9
|
+
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
|
10
|
+
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
|
11
|
+
['224.0.0.0', 3],
|
|
12
|
+
])
|
|
13
|
+
blockedV4.addSubnet(address, prefix, 'ipv4');
|
|
14
|
+
const globalV6 = new net.BlockList();
|
|
15
|
+
globalV6.addSubnet('2000::', 3, 'ipv6');
|
|
16
|
+
const blockedV6 = new net.BlockList();
|
|
17
|
+
blockedV6.addSubnet('2001::', 23, 'ipv6');
|
|
18
|
+
blockedV6.addSubnet('2001:db8::', 32, 'ipv6');
|
|
19
|
+
blockedV6.addSubnet('2002::', 16, 'ipv6');
|
|
20
|
+
function isPublicAddress(address) {
|
|
21
|
+
if (net.isIPv4(address))
|
|
22
|
+
return !blockedV4.check(address, 'ipv4');
|
|
23
|
+
if (net.isIPv6(address))
|
|
24
|
+
return globalV6.check(address, 'ipv6') && !blockedV6.check(address, 'ipv6');
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
async function destination(url) {
|
|
28
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) {
|
|
29
|
+
throw new Error('Source URL must use public HTTP(S) without credentials');
|
|
30
|
+
}
|
|
31
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
32
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local')) {
|
|
33
|
+
throw new Error('Source URL must use a public address');
|
|
34
|
+
}
|
|
35
|
+
const family = net.isIP(hostname);
|
|
36
|
+
const addresses = family ? [{ address: hostname, family }] : await dns.lookup(hostname, { all: true, verbatim: true });
|
|
37
|
+
if (!addresses.length || addresses.some(({ address }) => !isPublicAddress(address))) {
|
|
38
|
+
throw new Error('Source URL must use a public address');
|
|
39
|
+
}
|
|
40
|
+
return addresses[0];
|
|
41
|
+
}
|
|
42
|
+
/** Connect directly to the checked IP, keeping the original Host and TLS name. */
|
|
43
|
+
function requestPinned(url, address) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
46
|
+
const req = transport.request(url, {
|
|
47
|
+
method: 'GET',
|
|
48
|
+
agent: false,
|
|
49
|
+
signal: AbortSignal.timeout(30_000),
|
|
50
|
+
headers: { 'Accept-Encoding': 'identity', 'User-Agent': 'Typeroll-Media-Import/1.0' },
|
|
51
|
+
// Never resolve again after validation, including on dual-stack hosts.
|
|
52
|
+
lookup: (_hostname, options, callback) => {
|
|
53
|
+
if (options.all)
|
|
54
|
+
callback(null, [address]);
|
|
55
|
+
else
|
|
56
|
+
callback(null, address.address, address.family);
|
|
57
|
+
},
|
|
58
|
+
}, (incoming) => {
|
|
59
|
+
const headers = new Headers();
|
|
60
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
61
|
+
if (Array.isArray(value))
|
|
62
|
+
value.forEach((item) => headers.append(name, item));
|
|
63
|
+
else if (value !== undefined)
|
|
64
|
+
headers.set(name, value);
|
|
65
|
+
}
|
|
66
|
+
const status = incoming.statusCode ?? 502;
|
|
67
|
+
const noBody = [204, 205, 304].includes(status);
|
|
68
|
+
if (noBody)
|
|
69
|
+
incoming.resume();
|
|
70
|
+
resolve(new Response(noBody ? null : Readable.toWeb(incoming), { status, headers }));
|
|
71
|
+
});
|
|
72
|
+
req.on('error', reject);
|
|
73
|
+
req.end();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Every redirect is a fresh trust decision; fetch's automatic redirects are unsafe here. */
|
|
77
|
+
export async function fetchPublicSource(rawUrl) {
|
|
78
|
+
let url = new URL(rawUrl);
|
|
79
|
+
for (let redirects = 0;; redirects++) {
|
|
80
|
+
const address = await destination(url);
|
|
81
|
+
const response = await requestPinned(url, address);
|
|
82
|
+
if (![301, 302, 303, 307, 308].includes(response.status))
|
|
83
|
+
return response;
|
|
84
|
+
await response.body?.cancel();
|
|
85
|
+
if (redirects >= 5)
|
|
86
|
+
throw new Error('Source URL has too many redirects');
|
|
87
|
+
const location = response.headers.get('location');
|
|
88
|
+
if (!location)
|
|
89
|
+
throw new Error('Source redirect has no destination');
|
|
90
|
+
url = new URL(location, url);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/tools/media.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Media tools (list + signed upload URL + metadata patch).
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { fetchPublicSource } from '../public-http.js';
|
|
3
4
|
import { ok, withErrorBoundary } from './helpers.js';
|
|
4
5
|
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
5
6
|
// Best-effort content-type inference from the URL extension. The server's
|
|
@@ -39,6 +40,7 @@ function filenameFromUrl(url, fallback) {
|
|
|
39
40
|
async function readSourceBytes(response) {
|
|
40
41
|
const declared = Number(response.headers.get('content-length'));
|
|
41
42
|
if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) {
|
|
43
|
+
await response.body?.cancel();
|
|
42
44
|
throw new Error(`Source file too large (max ${MAX_UPLOAD_BYTES} bytes)`);
|
|
43
45
|
}
|
|
44
46
|
if (!response.body)
|
|
@@ -67,9 +69,11 @@ async function readSourceBytes(response) {
|
|
|
67
69
|
}
|
|
68
70
|
async function uploadFromUrl(args, deps) {
|
|
69
71
|
const filename = filenameFromUrl(args.source_url, args.filename);
|
|
70
|
-
const sourceRes = await
|
|
71
|
-
if (!sourceRes.ok)
|
|
72
|
+
const sourceRes = await fetchPublicSource(args.source_url);
|
|
73
|
+
if (!sourceRes.ok) {
|
|
74
|
+
await sourceRes.body?.cancel();
|
|
72
75
|
throw new Error(`Failed to fetch source URL: ${sourceRes.status} ${sourceRes.statusText}`);
|
|
76
|
+
}
|
|
73
77
|
const buf = await readSourceBytes(sourceRes);
|
|
74
78
|
const sourceCt = sourceRes.headers.get('content-type')?.split(';')[0]?.trim();
|
|
75
79
|
const contentType = args.content_type ?? sourceCt ?? inferContentType(filename);
|
|
@@ -133,7 +137,7 @@ export const mediaTools = [
|
|
|
133
137
|
},
|
|
134
138
|
{
|
|
135
139
|
name: 'upload_media_from_url',
|
|
136
|
-
description: 'Fetch an image (or PDF) from any public URL and push it to the site\'s media library in one call. The
|
|
140
|
+
description: 'Fetch an image (or PDF) from any public URL and push it to the site\'s media library in one call. The MCP server downloads the source from verified public HTTP(S) destinations; private addresses and unsafe redirects are refused. Integrity-safe: the bytes are streamed, never transcribed as base64 through the model, so (unlike a large `upload_media_inline` payload) they can\'t be silently corrupted in transit. Returns { media_id, cdn_url, filename }.',
|
|
137
141
|
inputSchema: {
|
|
138
142
|
source_url: z.string().url().describe('Public URL to download the image from.'),
|
|
139
143
|
filename: z.string().optional().describe('Override the filename used on R2. Defaults to the last path segment of source_url.'),
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@typeroll/mcp-server",
|
|
3
|
-
"version": "0.42.
|
|
3
|
+
"version": "0.42.1",
|
|
4
4
|
"description": "Model Context Protocol server for the Typeroll public API. Use with Claude Code or any MCP-compatible client to manage a Typeroll site.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|