@stabgan/openrouter-mcp-multimodal 1.4.0 → 1.8.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +164 -147
  3. package/dist/__tests__/image-utils.test.d.ts +1 -0
  4. package/dist/__tests__/image-utils.test.js +75 -0
  5. package/dist/__tests__/integration.test.d.ts +1 -0
  6. package/dist/__tests__/integration.test.js +111 -0
  7. package/dist/__tests__/model-cache.test.d.ts +1 -0
  8. package/dist/__tests__/model-cache.test.js +96 -0
  9. package/dist/index.js +47 -46
  10. package/dist/model-cache.d.ts +18 -46
  11. package/dist/model-cache.js +28 -125
  12. package/dist/openrouter-api.d.ts +2 -22
  13. package/dist/openrouter-api.js +34 -111
  14. package/dist/tool-handlers/analyze-image.d.ts +2 -17
  15. package/dist/tool-handlers/analyze-image.js +18 -259
  16. package/dist/tool-handlers/chat-completion.d.ts +1 -0
  17. package/dist/tool-handlers/chat-completion.js +10 -143
  18. package/dist/tool-handlers/generate-image.d.ts +30 -0
  19. package/dist/tool-handlers/generate-image.js +82 -0
  20. package/dist/tool-handlers/get-model-info.d.ts +5 -5
  21. package/dist/tool-handlers/get-model-info.js +11 -41
  22. package/dist/tool-handlers/image-utils.d.ts +11 -0
  23. package/dist/tool-handlers/image-utils.js +253 -0
  24. package/dist/tool-handlers/search-models.d.ts +3 -10
  25. package/dist/tool-handlers/search-models.js +6 -37
  26. package/dist/tool-handlers/validate-model.d.ts +5 -5
  27. package/dist/tool-handlers/validate-model.js +13 -37
  28. package/dist/tool-handlers.d.ts +1 -2
  29. package/dist/tool-handlers.js +55 -234
  30. package/package.json +20 -21
  31. package/dist/index.js.map +0 -1
  32. package/dist/model-cache.js.map +0 -1
  33. package/dist/openrouter-api.js.map +0 -1
  34. package/dist/tool-handlers/analyze-image.js.map +0 -1
  35. package/dist/tool-handlers/chat-completion.js.map +0 -1
  36. package/dist/tool-handlers/get-model-info.js.map +0 -1
  37. package/dist/tool-handlers/multi-image-analysis.d.ts +0 -46
  38. package/dist/tool-handlers/multi-image-analysis.js +0 -405
  39. package/dist/tool-handlers/multi-image-analysis.js.map +0 -1
  40. package/dist/tool-handlers/search-models.js.map +0 -1
  41. package/dist/tool-handlers/validate-model.js.map +0 -1
  42. package/dist/tool-handlers.js.map +0 -1
@@ -0,0 +1,253 @@
1
+ import path from 'path';
2
+ import { promises as fs } from 'fs';
3
+ import dns from 'node:dns/promises';
4
+ const DEFAULT_MAX_DIMENSION = 800;
5
+ const DEFAULT_JPEG_QUALITY = 80;
6
+ const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
7
+ const DEFAULT_MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
8
+ const DEFAULT_MAX_REDIRECTS = 8;
9
+ const DEFAULT_MAX_DATA_URL_BYTES = 20 * 1024 * 1024;
10
+ function readEnvInt(name, fallback, min = 1) {
11
+ const raw = process.env[name];
12
+ if (raw === undefined || raw === '')
13
+ return fallback;
14
+ const n = parseInt(raw, 10);
15
+ return Number.isFinite(n) && n >= min ? n : fallback;
16
+ }
17
+ export function getMaxImageDimension() {
18
+ return readEnvInt('OPENROUTER_IMAGE_MAX_DIMENSION', DEFAULT_MAX_DIMENSION, 64);
19
+ }
20
+ export function getImageJpegQuality() {
21
+ return readEnvInt('OPENROUTER_IMAGE_JPEG_QUALITY', DEFAULT_JPEG_QUALITY, 1);
22
+ }
23
+ function getFetchTimeoutMs() {
24
+ return readEnvInt('OPENROUTER_IMAGE_FETCH_TIMEOUT_MS', DEFAULT_FETCH_TIMEOUT_MS, 1000);
25
+ }
26
+ function getMaxDownloadBytes() {
27
+ return readEnvInt('OPENROUTER_IMAGE_MAX_DOWNLOAD_BYTES', DEFAULT_MAX_DOWNLOAD_BYTES, 1024);
28
+ }
29
+ function getMaxRedirects() {
30
+ return readEnvInt('OPENROUTER_IMAGE_MAX_REDIRECTS', DEFAULT_MAX_REDIRECTS, 0);
31
+ }
32
+ function getMaxDataUrlBytes() {
33
+ return readEnvInt('OPENROUTER_IMAGE_MAX_DATA_URL_BYTES', DEFAULT_MAX_DATA_URL_BYTES, 1024);
34
+ }
35
+ let sharpFn = null;
36
+ let loaded = false;
37
+ async function loadSharp() {
38
+ if (!loaded) {
39
+ loaded = true;
40
+ try {
41
+ const mod = await import('sharp');
42
+ const fn = mod.default;
43
+ sharpFn = fn ?? mod;
44
+ }
45
+ catch {
46
+ console.error('sharp not available, images will be sent unprocessed');
47
+ }
48
+ }
49
+ return sharpFn;
50
+ }
51
+ export function getMimeType(filePath) {
52
+ const ext = path.extname(filePath).toLowerCase();
53
+ const map = {
54
+ '.png': 'image/png',
55
+ '.jpg': 'image/jpeg',
56
+ '.jpeg': 'image/jpeg',
57
+ '.webp': 'image/webp',
58
+ '.gif': 'image/gif',
59
+ '.bmp': 'image/bmp',
60
+ };
61
+ return map[ext] || 'image/jpeg';
62
+ }
63
+ function ipv4ToUint(ip) {
64
+ const parts = ip.split('.').map((p) => parseInt(p, 10));
65
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) {
66
+ throw new Error('Invalid IPv4');
67
+ }
68
+ return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
69
+ }
70
+ /** Blocks RFC1918, loopback, link-local, CGNAT, metadata (e.g. 169.254.169.254). */
71
+ export function isBlockedIPv4(ip) {
72
+ const n = ipv4ToUint(ip);
73
+ if (n >>> 24 === 127)
74
+ return true;
75
+ if (n >>> 24 === 10)
76
+ return true;
77
+ if (n >>> 20 === 0xac1)
78
+ return true;
79
+ if (n >>> 16 === 0xc0a8)
80
+ return true;
81
+ if (n >>> 16 === 0xa9fe)
82
+ return true;
83
+ if (n >>> 24 === 0)
84
+ return true;
85
+ if (n >= 0x64400000 && n <= 0x647fffff)
86
+ return true;
87
+ return false;
88
+ }
89
+ function isBlockedIPv6(ip) {
90
+ const raw = ip.includes('%') ? ip.split('%')[0] : ip;
91
+ const x = raw.toLowerCase();
92
+ if (x === '::1')
93
+ return true;
94
+ if (x.startsWith('fe80:') || x.startsWith('fec0:'))
95
+ return true;
96
+ const first = x.split(':').find((p) => p.length > 0);
97
+ if (first) {
98
+ const v = parseInt(first, 16);
99
+ if (!Number.isNaN(v) && v >= 0xfc00 && v <= 0xfdff)
100
+ return true;
101
+ }
102
+ return false;
103
+ }
104
+ function isIPv4Literal(host) {
105
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
106
+ }
107
+ /** Resolve hostname and ensure the resolved address is not private/link-local. */
108
+ export async function assertUrlSafeForFetch(urlString) {
109
+ let url;
110
+ try {
111
+ url = new URL(urlString);
112
+ }
113
+ catch {
114
+ throw new Error('Invalid URL');
115
+ }
116
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
117
+ throw new Error('Only HTTP(S) image URLs are allowed');
118
+ }
119
+ if (url.username || url.password) {
120
+ throw new Error('URL with credentials is not allowed');
121
+ }
122
+ const host = url.hostname.toLowerCase();
123
+ if (host === 'localhost' || host.endsWith('.localhost')) {
124
+ throw new Error('Blocked host');
125
+ }
126
+ if (isIPv4Literal(host)) {
127
+ if (isBlockedIPv4(host))
128
+ throw new Error('Blocked host');
129
+ return url;
130
+ }
131
+ if (host.includes(':') && !host.startsWith('[')) {
132
+ if (isBlockedIPv6(host))
133
+ throw new Error('Blocked host');
134
+ return url;
135
+ }
136
+ let lookupHost = host;
137
+ if (host.startsWith('[') && host.endsWith(']')) {
138
+ lookupHost = host.slice(1, -1);
139
+ if (isBlockedIPv6(lookupHost))
140
+ throw new Error('Blocked host');
141
+ return url;
142
+ }
143
+ const records = await dns.lookup(lookupHost, { all: true, verbatim: true });
144
+ if (!records.length)
145
+ throw new Error('Could not resolve host');
146
+ for (const r of records) {
147
+ const { address, family } = r;
148
+ if (family === 4) {
149
+ if (isBlockedIPv4(address))
150
+ throw new Error('Blocked host');
151
+ }
152
+ else if (family === 6) {
153
+ if (isBlockedIPv6(address))
154
+ throw new Error('Blocked host');
155
+ }
156
+ }
157
+ return url;
158
+ }
159
+ async function readResponseBodyWithLimit(res, maxBytes) {
160
+ const reader = res.body?.getReader();
161
+ if (!reader) {
162
+ const buf = Buffer.from(await res.arrayBuffer());
163
+ if (buf.length > maxBytes)
164
+ throw new Error('Response too large');
165
+ return buf;
166
+ }
167
+ const chunks = [];
168
+ let total = 0;
169
+ for (;;) {
170
+ const { done, value } = await reader.read();
171
+ if (done)
172
+ break;
173
+ total += value.byteLength;
174
+ if (total > maxBytes)
175
+ throw new Error('Response too large');
176
+ chunks.push(Buffer.from(value));
177
+ }
178
+ return Buffer.concat(chunks);
179
+ }
180
+ export async function fetchHttpImage(urlString) {
181
+ const maxBytes = getMaxDownloadBytes();
182
+ const timeoutMs = getFetchTimeoutMs();
183
+ const maxRedirects = getMaxRedirects();
184
+ let current = urlString;
185
+ for (let hop = 0; hop <= maxRedirects; hop++) {
186
+ const validated = await assertUrlSafeForFetch(current);
187
+ const target = validated.href;
188
+ const controller = new AbortController();
189
+ const t = setTimeout(() => controller.abort(), timeoutMs);
190
+ let res;
191
+ try {
192
+ res = await fetch(target, { redirect: 'manual', signal: controller.signal });
193
+ }
194
+ finally {
195
+ clearTimeout(t);
196
+ }
197
+ if (res.status >= 300 && res.status < 400) {
198
+ const loc = res.headers.get('location');
199
+ if (!loc)
200
+ throw new Error('Redirect without Location header');
201
+ current = new URL(loc, target).href;
202
+ continue;
203
+ }
204
+ if (!res.ok)
205
+ throw new Error(`HTTP ${res.status}`);
206
+ return readResponseBodyWithLimit(res, maxBytes);
207
+ }
208
+ throw new Error('Too many redirects');
209
+ }
210
+ export async function fetchImage(source) {
211
+ if (source.startsWith('data:')) {
212
+ const match = source.match(/^data:[^;]+;base64,(.+)$/);
213
+ if (!match)
214
+ throw new Error('Invalid data URL');
215
+ const b64 = match[1];
216
+ const approxBytes = Math.ceil((b64.length * 3) / 4);
217
+ if (approxBytes > getMaxDataUrlBytes())
218
+ throw new Error('Data URL too large');
219
+ return Buffer.from(b64, 'base64');
220
+ }
221
+ if (source.startsWith('http://') || source.startsWith('https://')) {
222
+ return fetchHttpImage(source);
223
+ }
224
+ return fs.readFile(source);
225
+ }
226
+ export async function optimizeImage(buffer) {
227
+ const sharp = await loadSharp();
228
+ if (!sharp)
229
+ return buffer.toString('base64');
230
+ const maxDim = getMaxImageDimension();
231
+ const quality = getImageJpegQuality();
232
+ try {
233
+ const meta = await sharp(buffer).metadata();
234
+ let pipeline = sharp(buffer);
235
+ if (meta.width && meta.height && Math.max(meta.width, meta.height) > maxDim) {
236
+ const opts = meta.width > meta.height ? { width: maxDim } : { height: maxDim };
237
+ pipeline = pipeline.resize(opts);
238
+ }
239
+ const out = await pipeline.jpeg({ quality }).toBuffer();
240
+ return out.toString('base64');
241
+ }
242
+ catch {
243
+ return buffer.toString('base64');
244
+ }
245
+ }
246
+ export async function prepareImageUrl(source) {
247
+ if (source.startsWith('data:'))
248
+ return source;
249
+ const buffer = await fetchImage(source);
250
+ const base64 = await optimizeImage(buffer);
251
+ const mime = source.startsWith('http') ? 'image/jpeg' : getMimeType(source);
252
+ return `data:${mime};base64,${base64}`;
253
+ }
@@ -1,23 +1,16 @@
1
1
  import { ModelCache } from '../model-cache.js';
2
2
  import { OpenRouterAPIClient } from '../openrouter-api.js';
3
- export interface SearchModelsToolRequest {
3
+ export interface SearchModelsArgs {
4
4
  query?: string;
5
5
  provider?: string;
6
- minContextLength?: number | string;
7
- maxContextLength?: number | string;
8
- maxPromptPrice?: number | string;
9
- maxCompletionPrice?: number | string;
10
6
  capabilities?: {
11
- functions?: boolean;
12
- tools?: boolean;
13
7
  vision?: boolean;
14
- json_mode?: boolean;
15
8
  };
16
- limit?: number | string;
9
+ limit?: number;
17
10
  }
18
11
  export declare function handleSearchModels(request: {
19
12
  params: {
20
- arguments: SearchModelsToolRequest;
13
+ arguments: SearchModelsArgs;
21
14
  };
22
15
  }, apiClient: OpenRouterAPIClient, modelCache: ModelCache): Promise<{
23
16
  content: {
@@ -1,44 +1,13 @@
1
1
  export async function handleSearchModels(request, apiClient, modelCache) {
2
- const args = request.params.arguments;
3
2
  try {
4
- // Refresh the cache if needed
5
- if (!modelCache.isCacheValid()) {
6
- const models = await apiClient.getModels();
7
- modelCache.setModels(models);
3
+ if (!modelCache.isValid()) {
4
+ modelCache.setModels(await apiClient.getModels());
8
5
  }
9
- // Search models based on criteria
10
- const results = modelCache.searchModels({
11
- query: args.query,
12
- provider: args.provider,
13
- minContextLength: args.minContextLength,
14
- maxContextLength: args.maxContextLength,
15
- maxPromptPrice: args.maxPromptPrice,
16
- maxCompletionPrice: args.maxCompletionPrice,
17
- capabilities: args.capabilities,
18
- limit: args.limit || 10,
19
- });
20
- return {
21
- content: [
22
- {
23
- type: 'text',
24
- text: JSON.stringify(results, null, 2),
25
- },
26
- ],
27
- };
6
+ const results = modelCache.search(request.params.arguments);
7
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
28
8
  }
29
9
  catch (error) {
30
- if (error instanceof Error) {
31
- return {
32
- content: [
33
- {
34
- type: 'text',
35
- text: `Error searching models: ${error.message}`,
36
- },
37
- ],
38
- isError: true,
39
- };
40
- }
41
- throw error;
10
+ const msg = error instanceof Error ? error.message : String(error);
11
+ return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
42
12
  }
43
13
  }
44
- //# sourceMappingURL=search-models.js.map
@@ -1,12 +1,12 @@
1
1
  import { ModelCache } from '../model-cache.js';
2
- export interface ValidateModelToolRequest {
3
- model: string;
4
- }
2
+ import { OpenRouterAPIClient } from '../openrouter-api.js';
5
3
  export declare function handleValidateModel(request: {
6
4
  params: {
7
- arguments: ValidateModelToolRequest;
5
+ arguments: {
6
+ model: string;
7
+ };
8
8
  };
9
- }, modelCache: ModelCache): Promise<{
9
+ }, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<{
10
10
  content: {
11
11
  type: string;
12
12
  text: string;
@@ -1,40 +1,16 @@
1
- export async function handleValidateModel(request, modelCache) {
2
- const args = request.params.arguments;
3
- try {
4
- if (!modelCache.isCacheValid()) {
5
- return {
6
- content: [
7
- {
8
- type: 'text',
9
- text: 'Model cache is empty or expired. Please call search_models first to populate the cache.',
10
- },
11
- ],
12
- isError: true,
13
- };
14
- }
15
- const isValid = modelCache.hasModel(args.model);
16
- return {
17
- content: [
18
- {
19
- type: 'text',
20
- text: JSON.stringify({ valid: isValid }),
21
- },
22
- ],
23
- };
1
+ export async function handleValidateModel(request, modelCache, apiClient) {
2
+ if (!modelCache.isValid() && apiClient) {
3
+ modelCache.setModels(await apiClient.getModels());
24
4
  }
25
- catch (error) {
26
- if (error instanceof Error) {
27
- return {
28
- content: [
29
- {
30
- type: 'text',
31
- text: `Error validating model: ${error.message}`,
32
- },
33
- ],
34
- isError: true,
35
- };
36
- }
37
- throw error;
5
+ if (!modelCache.isValid()) {
6
+ return { content: [{ type: 'text', text: 'No model data available.' }], isError: true };
38
7
  }
8
+ return {
9
+ content: [
10
+ {
11
+ type: 'text',
12
+ text: JSON.stringify({ valid: modelCache.has(request.params.arguments.model) }),
13
+ },
14
+ ],
15
+ };
39
16
  }
40
- //# sourceMappingURL=validate-model.js.map
@@ -1,10 +1,9 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  export declare class ToolHandlers {
3
- private server;
4
3
  private openai;
5
4
  private modelCache;
6
5
  private apiClient;
7
6
  private defaultModel?;
8
7
  constructor(server: Server, apiKey: string, defaultModel?: string);
9
- private setupToolHandlers;
8
+ private register;
10
9
  }