@rog0x/mcp-image-tools 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,193 @@
1
+ export interface FaviconResult {
2
+ domain: string;
3
+ favicons: FaviconEntry[];
4
+ checkedLocations: string[];
5
+ }
6
+
7
+ export interface FaviconEntry {
8
+ url: string;
9
+ source: string;
10
+ type: string | null;
11
+ sizes: string | null;
12
+ }
13
+
14
+ async function fetchText(url: string, timeoutMs = 10000): Promise<{ text: string; ok: boolean }> {
15
+ const controller = new AbortController();
16
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
17
+ try {
18
+ const response = await fetch(url, {
19
+ signal: controller.signal,
20
+ redirect: "follow",
21
+ headers: { "User-Agent": "MCP-Image-Tools/1.0" },
22
+ });
23
+ if (!response.ok) return { text: "", ok: false };
24
+ const text = await response.text();
25
+ return { text, ok: true };
26
+ } catch {
27
+ return { text: "", ok: false };
28
+ } finally {
29
+ clearTimeout(timeout);
30
+ }
31
+ }
32
+
33
+ async function checkUrl(url: string, timeoutMs = 8000): Promise<boolean> {
34
+ const controller = new AbortController();
35
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
36
+ try {
37
+ const response = await fetch(url, {
38
+ method: "HEAD",
39
+ signal: controller.signal,
40
+ redirect: "follow",
41
+ });
42
+ return response.ok;
43
+ } catch {
44
+ return false;
45
+ } finally {
46
+ clearTimeout(timeout);
47
+ }
48
+ }
49
+
50
+ function extractLinkTags(html: string, baseUrl: string): FaviconEntry[] {
51
+ const entries: FaviconEntry[] = [];
52
+ // Match <link> tags with rel containing "icon" or "apple-touch-icon"
53
+ const linkRegex = /<link\s+[^>]*>/gi;
54
+ let match: RegExpExecArray | null;
55
+
56
+ while ((match = linkRegex.exec(html)) !== null) {
57
+ const tag = match[0];
58
+ const relMatch = tag.match(/rel\s*=\s*["']([^"']+)["']/i);
59
+ if (!relMatch) continue;
60
+
61
+ const rel = relMatch[1].toLowerCase();
62
+ if (!rel.includes("icon")) continue;
63
+
64
+ const hrefMatch = tag.match(/href\s*=\s*["']([^"']+)["']/i);
65
+ if (!hrefMatch) continue;
66
+
67
+ const href = hrefMatch[1];
68
+ const typeMatch = tag.match(/type\s*=\s*["']([^"']+)["']/i);
69
+ const sizesMatch = tag.match(/sizes\s*=\s*["']([^"']+)["']/i);
70
+
71
+ let resolvedUrl: string;
72
+ try {
73
+ resolvedUrl = new URL(href, baseUrl).href;
74
+ } catch {
75
+ continue;
76
+ }
77
+
78
+ entries.push({
79
+ url: resolvedUrl,
80
+ source: `HTML link[rel="${rel}"]`,
81
+ type: typeMatch ? typeMatch[1] : null,
82
+ sizes: sizesMatch ? sizesMatch[1] : null,
83
+ });
84
+ }
85
+
86
+ return entries;
87
+ }
88
+
89
+ function extractManifestIcons(manifestJson: string, baseUrl: string): FaviconEntry[] {
90
+ const entries: FaviconEntry[] = [];
91
+ try {
92
+ const manifest = JSON.parse(manifestJson);
93
+ const icons = manifest.icons as Array<{ src: string; sizes?: string; type?: string }>;
94
+ if (!Array.isArray(icons)) return entries;
95
+
96
+ for (const icon of icons) {
97
+ if (!icon.src) continue;
98
+ let resolvedUrl: string;
99
+ try {
100
+ resolvedUrl = new URL(icon.src, baseUrl).href;
101
+ } catch {
102
+ continue;
103
+ }
104
+ entries.push({
105
+ url: resolvedUrl,
106
+ source: "manifest.json",
107
+ type: icon.type || null,
108
+ sizes: icon.sizes || null,
109
+ });
110
+ }
111
+ } catch {
112
+ // Invalid JSON
113
+ }
114
+ return entries;
115
+ }
116
+
117
+ export async function findFavicons(websiteUrl: string): Promise<FaviconResult> {
118
+ const parsed = new URL(websiteUrl);
119
+ const origin = parsed.origin;
120
+ const domain = parsed.hostname;
121
+ const checkedLocations: string[] = [];
122
+ const favicons: FaviconEntry[] = [];
123
+ const seenUrls = new Set<string>();
124
+
125
+ function addFavicon(entry: FaviconEntry): void {
126
+ if (!seenUrls.has(entry.url)) {
127
+ seenUrls.add(entry.url);
128
+ favicons.push(entry);
129
+ }
130
+ }
131
+
132
+ // 1. Check /favicon.ico
133
+ const faviconIcoUrl = `${origin}/favicon.ico`;
134
+ checkedLocations.push(faviconIcoUrl);
135
+ const icoExists = await checkUrl(faviconIcoUrl);
136
+ if (icoExists) {
137
+ addFavicon({
138
+ url: faviconIcoUrl,
139
+ source: "default /favicon.ico",
140
+ type: "image/x-icon",
141
+ sizes: null,
142
+ });
143
+ }
144
+
145
+ // 2. Parse HTML for link tags
146
+ checkedLocations.push(websiteUrl);
147
+ const { text: html, ok: htmlOk } = await fetchText(websiteUrl);
148
+ if (htmlOk) {
149
+ const htmlFavicons = extractLinkTags(html, websiteUrl);
150
+ for (const f of htmlFavicons) {
151
+ addFavicon(f);
152
+ }
153
+
154
+ // 3. Check for manifest.json reference
155
+ const manifestMatch = html.match(/<link\s+[^>]*rel\s*=\s*["']manifest["'][^>]*href\s*=\s*["']([^"']+)["']/i)
156
+ || html.match(/<link\s+[^>]*href\s*=\s*["']([^"']+)["'][^>]*rel\s*=\s*["']manifest["']/i);
157
+
158
+ if (manifestMatch) {
159
+ let manifestUrl: string;
160
+ try {
161
+ manifestUrl = new URL(manifestMatch[1], websiteUrl).href;
162
+ } catch {
163
+ manifestUrl = "";
164
+ }
165
+
166
+ if (manifestUrl) {
167
+ checkedLocations.push(manifestUrl);
168
+ const { text: manifestText, ok: manifestOk } = await fetchText(manifestUrl);
169
+ if (manifestOk) {
170
+ const manifestIcons = extractManifestIcons(manifestText, manifestUrl);
171
+ for (const f of manifestIcons) {
172
+ addFavicon(f);
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ // 4. Check common manifest location
180
+ const defaultManifest = `${origin}/manifest.json`;
181
+ if (!checkedLocations.includes(defaultManifest)) {
182
+ checkedLocations.push(defaultManifest);
183
+ const { text: manifestText, ok: manifestOk } = await fetchText(defaultManifest);
184
+ if (manifestOk) {
185
+ const manifestIcons = extractManifestIcons(manifestText, defaultManifest);
186
+ for (const f of manifestIcons) {
187
+ addFavicon(f);
188
+ }
189
+ }
190
+ }
191
+
192
+ return { domain, favicons, checkedLocations };
193
+ }
@@ -0,0 +1,64 @@
1
+ export interface ImageMetadata {
2
+ url: string;
3
+ contentType: string | null;
4
+ fileSize: number | null;
5
+ fileSizeFormatted: string | null;
6
+ lastModified: string | null;
7
+ etag: string | null;
8
+ cacheControl: string | null;
9
+ acceptRanges: string | null;
10
+ server: string | null;
11
+ accessTime: string;
12
+ statusCode: number;
13
+ }
14
+
15
+ function formatBytes(bytes: number): string {
16
+ if (bytes === 0) return "0 B";
17
+ const units = ["B", "KB", "MB", "GB"];
18
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
19
+ return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
20
+ }
21
+
22
+ export async function getImageMetadata(url: string): Promise<ImageMetadata> {
23
+ const controller = new AbortController();
24
+ const timeout = setTimeout(() => controller.abort(), 15000);
25
+
26
+ try {
27
+ // Use HEAD request first to avoid downloading the entire image
28
+ let response = await fetch(url, {
29
+ method: "HEAD",
30
+ signal: controller.signal,
31
+ redirect: "follow",
32
+ });
33
+
34
+ // Some servers reject HEAD, fall back to GET with range
35
+ if (response.status === 405 || response.status === 403) {
36
+ response = await fetch(url, {
37
+ method: "GET",
38
+ headers: { Range: "bytes=0-0" },
39
+ signal: controller.signal,
40
+ redirect: "follow",
41
+ });
42
+ }
43
+
44
+ const headers = response.headers;
45
+ const contentLength = headers.get("content-length");
46
+ const fileSize = contentLength ? parseInt(contentLength, 10) : null;
47
+
48
+ return {
49
+ url,
50
+ contentType: headers.get("content-type"),
51
+ fileSize,
52
+ fileSizeFormatted: fileSize !== null ? formatBytes(fileSize) : null,
53
+ lastModified: headers.get("last-modified"),
54
+ etag: headers.get("etag"),
55
+ cacheControl: headers.get("cache-control"),
56
+ acceptRanges: headers.get("accept-ranges"),
57
+ server: headers.get("server"),
58
+ accessTime: new Date().toISOString(),
59
+ statusCode: response.status,
60
+ };
61
+ } finally {
62
+ clearTimeout(timeout);
63
+ }
64
+ }
@@ -0,0 +1,122 @@
1
+ export interface OgImageResult {
2
+ url: string;
3
+ ogImage: string | null;
4
+ ogImageAlt: string | null;
5
+ ogImageWidth: string | null;
6
+ ogImageHeight: string | null;
7
+ ogImageType: string | null;
8
+ twitterImage: string | null;
9
+ twitterCard: string | null;
10
+ appleTouchIcon: string | null;
11
+ allImages: ImageEntry[];
12
+ }
13
+
14
+ export interface ImageEntry {
15
+ url: string;
16
+ source: string;
17
+ alt: string | null;
18
+ }
19
+
20
+ function extractMetaContent(html: string, property: string): string | null {
21
+ // Match both property="..." and name="..." attributes
22
+ const patterns = [
23
+ new RegExp(`<meta\\s+[^>]*property\\s*=\\s*["']${escapeRegex(property)}["'][^>]*content\\s*=\\s*["']([^"']+)["']`, "i"),
24
+ new RegExp(`<meta\\s+[^>]*content\\s*=\\s*["']([^"']+)["'][^>]*property\\s*=\\s*["']${escapeRegex(property)}["']`, "i"),
25
+ new RegExp(`<meta\\s+[^>]*name\\s*=\\s*["']${escapeRegex(property)}["'][^>]*content\\s*=\\s*["']([^"']+)["']`, "i"),
26
+ new RegExp(`<meta\\s+[^>]*content\\s*=\\s*["']([^"']+)["'][^>]*name\\s*=\\s*["']${escapeRegex(property)}["']`, "i"),
27
+ ];
28
+
29
+ for (const pattern of patterns) {
30
+ const match = html.match(pattern);
31
+ if (match) return match[1];
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function escapeRegex(str: string): string {
37
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
38
+ }
39
+
40
+ function extractAppleTouchIcon(html: string, baseUrl: string): string | null {
41
+ const match = html.match(/<link\s+[^>]*rel\s*=\s*["']apple-touch-icon["'][^>]*href\s*=\s*["']([^"']+)["']/i)
42
+ || html.match(/<link\s+[^>]*href\s*=\s*["']([^"']+)["'][^>]*rel\s*=\s*["']apple-touch-icon["']/i);
43
+
44
+ if (match) {
45
+ try {
46
+ return new URL(match[1], baseUrl).href;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ function resolveUrl(href: string | null, baseUrl: string): string | null {
55
+ if (!href) return null;
56
+ try {
57
+ return new URL(href, baseUrl).href;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ export async function extractOgImage(url: string): Promise<OgImageResult> {
64
+ const controller = new AbortController();
65
+ const timeout = setTimeout(() => controller.abort(), 15000);
66
+
67
+ try {
68
+ const response = await fetch(url, {
69
+ signal: controller.signal,
70
+ redirect: "follow",
71
+ headers: { "User-Agent": "MCP-Image-Tools/1.0" },
72
+ });
73
+
74
+ if (!response.ok) {
75
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
76
+ }
77
+
78
+ const html = await response.text();
79
+ const allImages: ImageEntry[] = [];
80
+
81
+ // Extract OG image properties
82
+ const ogImage = resolveUrl(extractMetaContent(html, "og:image"), url);
83
+ const ogImageAlt = extractMetaContent(html, "og:image:alt");
84
+ const ogImageWidth = extractMetaContent(html, "og:image:width");
85
+ const ogImageHeight = extractMetaContent(html, "og:image:height");
86
+ const ogImageType = extractMetaContent(html, "og:image:type");
87
+
88
+ if (ogImage) {
89
+ allImages.push({ url: ogImage, source: "og:image", alt: ogImageAlt });
90
+ }
91
+
92
+ // Extract Twitter card image
93
+ const twitterImage = resolveUrl(extractMetaContent(html, "twitter:image"), url);
94
+ const twitterCard = extractMetaContent(html, "twitter:card");
95
+
96
+ if (twitterImage && twitterImage !== ogImage) {
97
+ allImages.push({ url: twitterImage, source: "twitter:image", alt: null });
98
+ }
99
+
100
+ // Extract Apple touch icon
101
+ const appleTouchIcon = extractAppleTouchIcon(html, url);
102
+
103
+ if (appleTouchIcon) {
104
+ allImages.push({ url: appleTouchIcon, source: "apple-touch-icon", alt: null });
105
+ }
106
+
107
+ return {
108
+ url,
109
+ ogImage,
110
+ ogImageAlt,
111
+ ogImageWidth,
112
+ ogImageHeight,
113
+ ogImageType,
114
+ twitterImage,
115
+ twitterCard,
116
+ appleTouchIcon,
117
+ allImages,
118
+ };
119
+ } finally {
120
+ clearTimeout(timeout);
121
+ }
122
+ }
@@ -0,0 +1,81 @@
1
+ export interface PlaceholderConfig {
2
+ width: number;
3
+ height?: number;
4
+ backgroundColor?: string;
5
+ textColor?: string;
6
+ text?: string;
7
+ format?: "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg";
8
+ font?: string;
9
+ fontSize?: number;
10
+ }
11
+
12
+ export interface PlaceholderResult {
13
+ url: string;
14
+ width: number;
15
+ height: number;
16
+ backgroundColor: string;
17
+ textColor: string;
18
+ text: string;
19
+ format: string;
20
+ htmlImg: string;
21
+ markdownImg: string;
22
+ }
23
+
24
+ export function generatePlaceholder(config: PlaceholderConfig): PlaceholderResult {
25
+ const width = Math.max(1, Math.min(config.width, 4000));
26
+ const height = config.height ? Math.max(1, Math.min(config.height, 4000)) : width;
27
+ const bgColor = (config.backgroundColor || "cccccc").replace(/^#/, "");
28
+ const txtColor = (config.textColor || "333333").replace(/^#/, "");
29
+ const format = config.format || "png";
30
+ const text = config.text || `${width}x${height}`;
31
+
32
+ // Build placehold.co URL
33
+ let url = `https://placehold.co/${width}x${height}/${bgColor}/${txtColor}`;
34
+
35
+ // Add format
36
+ url += `.${format}`;
37
+
38
+ // Add query params
39
+ const params = new URLSearchParams();
40
+ if (config.text) {
41
+ params.set("text", config.text);
42
+ }
43
+ if (config.font) {
44
+ params.set("font", config.font);
45
+ }
46
+ if (config.fontSize) {
47
+ params.set("font-size", config.fontSize.toString());
48
+ }
49
+
50
+ const queryString = params.toString();
51
+ if (queryString) {
52
+ url += `?${queryString}`;
53
+ }
54
+
55
+ const alt = `Placeholder ${width}x${height}`;
56
+
57
+ return {
58
+ url,
59
+ width,
60
+ height,
61
+ backgroundColor: `#${bgColor}`,
62
+ textColor: `#${txtColor}`,
63
+ text,
64
+ format,
65
+ htmlImg: `<img src="${url}" alt="${alt}" width="${width}" height="${height}" />`,
66
+ markdownImg: `![${alt}](${url})`,
67
+ };
68
+ }
69
+
70
+ export function generatePlaceholderSet(
71
+ sizes: Array<{ width: number; height?: number }>,
72
+ options?: Omit<PlaceholderConfig, "width" | "height">
73
+ ): PlaceholderResult[] {
74
+ return sizes.map((size) =>
75
+ generatePlaceholder({
76
+ ...options,
77
+ width: size.width,
78
+ height: size.height,
79
+ })
80
+ );
81
+ }
@@ -0,0 +1,127 @@
1
+ export interface ResponsiveConfig {
2
+ baseUrl: string;
3
+ alt?: string;
4
+ widths?: number[];
5
+ sizes?: string;
6
+ className?: string;
7
+ loading?: "lazy" | "eager";
8
+ formats?: string[];
9
+ }
10
+
11
+ export interface ResponsiveResult {
12
+ baseUrl: string;
13
+ variants: ImageVariant[];
14
+ srcsetAttr: string;
15
+ imgTag: string;
16
+ pictureTag: string;
17
+ }
18
+
19
+ export interface ImageVariant {
20
+ url: string;
21
+ width: number;
22
+ descriptor: string;
23
+ }
24
+
25
+ function inferImageFormat(url: string): string {
26
+ const pathname = new URL(url).pathname.toLowerCase();
27
+ if (pathname.endsWith(".webp")) return "webp";
28
+ if (pathname.endsWith(".avif")) return "avif";
29
+ if (pathname.endsWith(".png")) return "png";
30
+ if (pathname.endsWith(".gif")) return "gif";
31
+ if (pathname.endsWith(".svg")) return "svg";
32
+ return "jpeg";
33
+ }
34
+
35
+ function buildVariantUrl(baseUrl: string, width: number): string {
36
+ // Append width parameter - handles both existing and new query strings
37
+ const url = new URL(baseUrl);
38
+ url.searchParams.set("w", width.toString());
39
+ return url.href;
40
+ }
41
+
42
+ function escapeHtml(str: string): string {
43
+ return str
44
+ .replace(/&/g, "&amp;")
45
+ .replace(/"/g, "&quot;")
46
+ .replace(/</g, "&lt;")
47
+ .replace(/>/g, "&gt;");
48
+ }
49
+
50
+ export function generateResponsiveImages(config: ResponsiveConfig): ResponsiveResult {
51
+ const defaultWidths = [320, 640, 768, 1024, 1280, 1536, 1920];
52
+ const widths = config.widths || defaultWidths;
53
+ const alt = config.alt || "";
54
+ const sizes = config.sizes || "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw";
55
+ const loading = config.loading || "lazy";
56
+ const className = config.className || "";
57
+ const formats = config.formats || ["webp", inferImageFormat(config.baseUrl)];
58
+ const sortedWidths = [...widths].sort((a, b) => a - b);
59
+
60
+ // Generate variants
61
+ const variants: ImageVariant[] = sortedWidths.map((width) => ({
62
+ url: buildVariantUrl(config.baseUrl, width),
63
+ width,
64
+ descriptor: `${width}w`,
65
+ }));
66
+
67
+ // Build srcset attribute
68
+ const srcsetAttr = variants
69
+ .map((v) => `${v.url} ${v.descriptor}`)
70
+ .join(",\n ");
71
+
72
+ // Build simple <img> tag with srcset
73
+ const classAttr = className ? ` class="${escapeHtml(className)}"` : "";
74
+ const imgTag = [
75
+ `<img`,
76
+ ` src="${escapeHtml(config.baseUrl)}"`,
77
+ ` srcset="${srcsetAttr}"`,
78
+ ` sizes="${escapeHtml(sizes)}"`,
79
+ ` alt="${escapeHtml(alt)}"`,
80
+ ` loading="${loading}"`,
81
+ className ? ` class="${escapeHtml(className)}"` : null,
82
+ `/>`,
83
+ ]
84
+ .filter(Boolean)
85
+ .join("\n");
86
+
87
+ // Build <picture> element with multiple formats
88
+ const sourceElements = formats.map((format) => {
89
+ const formatVariants = sortedWidths.map((width) => {
90
+ const url = new URL(config.baseUrl);
91
+ url.searchParams.set("w", width.toString());
92
+ url.searchParams.set("fm", format);
93
+ return `${url.href} ${width}w`;
94
+ });
95
+ const formatSrcset = formatVariants.join(",\n ");
96
+ const mimeType =
97
+ format === "jpg" || format === "jpeg"
98
+ ? "image/jpeg"
99
+ : format === "avif"
100
+ ? "image/avif"
101
+ : format === "webp"
102
+ ? "image/webp"
103
+ : format === "png"
104
+ ? "image/png"
105
+ : `image/${format}`;
106
+ return ` <source\n type="${mimeType}"\n srcset="${formatSrcset}"\n sizes="${escapeHtml(sizes)}"\n />`;
107
+ });
108
+
109
+ const pictureTag = [
110
+ `<picture${classAttr}>`,
111
+ ...sourceElements,
112
+ ` <img`,
113
+ ` src="${escapeHtml(config.baseUrl)}"`,
114
+ ` alt="${escapeHtml(alt)}"`,
115
+ ` loading="${loading}"`,
116
+ ` />`,
117
+ `</picture>`,
118
+ ].join("\n");
119
+
120
+ return {
121
+ baseUrl: config.baseUrl,
122
+ variants,
123
+ srcsetAttr,
124
+ imgTag,
125
+ pictureTag,
126
+ };
127
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src/**/*"]
14
+ }