@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.
@@ -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
+ }
@@ -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 fetch(args.source_url);
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 bytes pass through the agent\'s machine they do NOT go through the Typeroll API so this works whenever your agent can `fetch()` the source. 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 }.',
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
@@ -8,4 +8,4 @@
8
8
  //
9
9
  // Keep it in lockstep with package.json: tests/version.test.ts asserts
10
10
  // VERSION === package.json.version, so a bump that forgets this line fails CI.
11
- export const VERSION = '0.42.0';
11
+ export const VERSION = '0.42.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.42.0",
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": {