@stabgan/openrouter-mcp-multimodal 4.5.0 → 4.5.3
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/README.md +380 -242
- package/dist/index.js +22 -6
- package/dist/model-cache.d.ts +35 -12
- package/dist/model-cache.js +79 -22
- package/dist/tool-descriptions.d.ts +19 -0
- package/dist/tool-descriptions.js +423 -0
- package/dist/tool-handlers/analyze-audio.js +5 -1
- package/dist/tool-handlers/analyze-image.js +6 -5
- package/dist/tool-handlers/analyze-video.js +6 -5
- package/dist/tool-handlers/audio-utils.js +4 -2
- package/dist/tool-handlers/chat-completion.js +1 -1
- package/dist/tool-handlers/fetch-utils.js +16 -2
- package/dist/tool-handlers/generate-audio.js +2 -4
- package/dist/tool-handlers/generate-image-input.d.ts +3 -0
- package/dist/tool-handlers/generate-image-input.js +38 -0
- package/dist/tool-handlers/generate-image.d.ts +13 -51
- package/dist/tool-handlers/generate-image.js +32 -119
- package/dist/tool-handlers/generate-video.js +28 -24
- package/dist/tool-handlers/health-check.js +4 -1
- package/dist/tool-handlers/image-utils.d.ts +1 -0
- package/dist/tool-handlers/image-utils.js +26 -16
- package/dist/tool-handlers/openrouter-errors.d.ts +5 -1
- package/dist/tool-handlers/openrouter-errors.js +78 -13
- package/dist/tool-handlers/provider-routing.js +7 -2
- package/dist/tool-handlers/rerank.js +2 -5
- package/dist/tool-handlers/search-models.d.ts +2 -2
- package/dist/tool-handlers/search-models.js +2 -6
- package/dist/tool-handlers/structured-output.d.ts +8 -0
- package/dist/tool-handlers/structured-output.js +11 -0
- package/dist/tool-handlers/video-utils.js +6 -9
- package/dist/tool-handlers.js +43 -123
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +26 -14
package/dist/index.js
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Readable } from 'node:stream';
|
|
3
3
|
import { config } from 'dotenv';
|
|
4
|
-
config(); // Load .env file if present
|
|
4
|
+
config({ quiet: true }); // Load .env file if present (quiet — stdio transport owns stdout)
|
|
5
5
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
6
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
7
|
import { ToolHandlers } from './tool-handlers.js';
|
|
8
|
+
import { logger } from './logger.js';
|
|
9
|
+
import { SERVER_VERSION } from './version.js';
|
|
8
10
|
const DEFAULT_MODEL = 'nvidia/nemotron-nano-12b-v2-vl:free';
|
|
9
|
-
// Exit on fatal errors to prevent silent zombie processes (issue #5)
|
|
11
|
+
// Exit on fatal errors to prevent silent zombie processes (issue #5).
|
|
12
|
+
// We log an explicit whitelist of fields rather than the raw error object
|
|
13
|
+
// to avoid ever echoing sensitive SDK internals (request bodies, auth
|
|
14
|
+
// headers) in a future version. Defense-in-depth against a changed
|
|
15
|
+
// APIError.toString() in openai-node.
|
|
16
|
+
function logFatal(kind, err) {
|
|
17
|
+
const e = err;
|
|
18
|
+
logger.error('fatal', {
|
|
19
|
+
kind,
|
|
20
|
+
name: e?.name ?? 'unknown',
|
|
21
|
+
msg: e?.message ?? String(err),
|
|
22
|
+
// Stack traces are developer-only — trim to avoid unbounded log lines.
|
|
23
|
+
stack: e?.stack?.split('\n').slice(0, 10).join('\n'),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
10
26
|
process.on('uncaughtException', (err) => {
|
|
11
|
-
|
|
27
|
+
logFatal('uncaughtException', err);
|
|
12
28
|
process.exit(1);
|
|
13
29
|
});
|
|
14
30
|
process.on('unhandledRejection', (err) => {
|
|
15
|
-
|
|
31
|
+
logFatal('unhandledRejection', err);
|
|
16
32
|
process.exit(1);
|
|
17
33
|
});
|
|
18
34
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
@@ -21,8 +37,8 @@ if (!apiKey) {
|
|
|
21
37
|
process.exit(1);
|
|
22
38
|
}
|
|
23
39
|
const defaultModel = process.env.OPENROUTER_DEFAULT_MODEL || process.env.DEFAULT_MODEL || DEFAULT_MODEL;
|
|
24
|
-
const server = new Server({ name: 'openrouter-multimodal-server', version:
|
|
25
|
-
server.onerror = (error) =>
|
|
40
|
+
const server = new Server({ name: 'openrouter-multimodal-server', version: SERVER_VERSION }, { capabilities: { tools: {} } });
|
|
41
|
+
server.onerror = (error) => logFatal('mcpError', error);
|
|
26
42
|
new ToolHandlers(server, apiKey, defaultModel);
|
|
27
43
|
process.on('SIGINT', async () => {
|
|
28
44
|
await server.close();
|
package/dist/model-cache.d.ts
CHANGED
|
@@ -8,14 +8,40 @@ export interface OpenRouterModelRecord {
|
|
|
8
8
|
context_length?: number;
|
|
9
9
|
[key: string]: unknown;
|
|
10
10
|
}
|
|
11
|
+
export interface ModelSearchParams {
|
|
12
|
+
query?: string;
|
|
13
|
+
provider?: string;
|
|
14
|
+
capabilities?: {
|
|
15
|
+
vision?: boolean;
|
|
16
|
+
audio?: boolean;
|
|
17
|
+
video?: boolean;
|
|
18
|
+
};
|
|
19
|
+
limit?: number;
|
|
20
|
+
/** When true, return the full filtered set and ignore `limit`. Used by pagination. */
|
|
21
|
+
all?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare const MAX_SEARCH_LIMIT = 50;
|
|
11
24
|
export declare class ModelCache {
|
|
12
25
|
private static instance;
|
|
13
26
|
private models;
|
|
14
27
|
private fetchedAt;
|
|
28
|
+
/**
|
|
29
|
+
* Separate from `fetchedAt`: set whenever we successfully CALL the
|
|
30
|
+
* fetcher (even if the response happens to be empty). Used by
|
|
31
|
+
* `isValid()` so a successful-but-empty fetch still counts as "fresh"
|
|
32
|
+
* and we don't hot-loop re-fetching the upstream.
|
|
33
|
+
*/
|
|
34
|
+
private populatedAt;
|
|
15
35
|
private inflight;
|
|
16
36
|
static getInstance(): ModelCache;
|
|
17
37
|
isValid(): boolean;
|
|
18
38
|
setModels(models: OpenRouterModelRecord[]): void;
|
|
39
|
+
/**
|
|
40
|
+
* Force the cache back into an uninitialized state. Used by tests that
|
|
41
|
+
* need to assert `ensureFresh()` actually calls the fetcher. Also useful
|
|
42
|
+
* for ops (`health_check --reset`) if we ever expose such a knob.
|
|
43
|
+
*/
|
|
44
|
+
reset(): void;
|
|
19
45
|
/**
|
|
20
46
|
* Populate the cache using `fetcher` if stale, coalescing concurrent callers
|
|
21
47
|
* so only one request hits the upstream API per stale window. Callers that
|
|
@@ -27,16 +53,13 @@ export declare class ModelCache {
|
|
|
27
53
|
size(): number;
|
|
28
54
|
get(id: string): OpenRouterModelRecord | null;
|
|
29
55
|
has(id: string): boolean;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
/** When true, return the full filtered set and ignore `limit`. Used by pagination. */
|
|
40
|
-
all?: boolean;
|
|
41
|
-
}): OpenRouterModelRecord[];
|
|
56
|
+
/**
|
|
57
|
+
* Single-pass paginated search: O(n) time, O(limit) extra space for the page.
|
|
58
|
+
* Avoids materializing the full filtered array when only one page is needed.
|
|
59
|
+
*/
|
|
60
|
+
searchPaginated(params: ModelSearchParams, offset: number, limit: number): {
|
|
61
|
+
page: OpenRouterModelRecord[];
|
|
62
|
+
total: number;
|
|
63
|
+
};
|
|
64
|
+
search(params: ModelSearchParams): OpenRouterModelRecord[];
|
|
42
65
|
}
|
package/dist/model-cache.js
CHANGED
|
@@ -5,21 +5,67 @@ function getCacheTtlMs() {
|
|
|
5
5
|
const n = parseInt(raw, 10);
|
|
6
6
|
return Number.isFinite(n) && n > 0 ? n : 3600000;
|
|
7
7
|
}
|
|
8
|
-
const MAX_SEARCH_LIMIT = 50;
|
|
8
|
+
export const MAX_SEARCH_LIMIT = 50;
|
|
9
|
+
function buildMatcher(params) {
|
|
10
|
+
const q = params.query?.toLowerCase();
|
|
11
|
+
const providerPrefix = params.provider?.toLowerCase();
|
|
12
|
+
const needVision = params.capabilities?.vision === true;
|
|
13
|
+
const needAudio = params.capabilities?.audio === true;
|
|
14
|
+
const needVideo = params.capabilities?.video === true;
|
|
15
|
+
return (m) => {
|
|
16
|
+
if (q) {
|
|
17
|
+
const id = m.id.toLowerCase();
|
|
18
|
+
const name = m.name?.toLowerCase() ?? '';
|
|
19
|
+
if (!id.includes(q) && !name.includes(q))
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
if (providerPrefix && !m.id.toLowerCase().startsWith(`${providerPrefix}/`)) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const mods = m.architecture?.input_modalities;
|
|
26
|
+
if (needVision && !mods?.includes('image'))
|
|
27
|
+
return false;
|
|
28
|
+
if (needAudio && !mods?.includes('audio'))
|
|
29
|
+
return false;
|
|
30
|
+
if (needVideo && !mods?.includes('video'))
|
|
31
|
+
return false;
|
|
32
|
+
return true;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
9
35
|
export class ModelCache {
|
|
10
36
|
static instance;
|
|
11
37
|
models = {};
|
|
12
38
|
fetchedAt = 0;
|
|
39
|
+
/**
|
|
40
|
+
* Separate from `fetchedAt`: set whenever we successfully CALL the
|
|
41
|
+
* fetcher (even if the response happens to be empty). Used by
|
|
42
|
+
* `isValid()` so a successful-but-empty fetch still counts as "fresh"
|
|
43
|
+
* and we don't hot-loop re-fetching the upstream.
|
|
44
|
+
*/
|
|
45
|
+
populatedAt = 0;
|
|
13
46
|
inflight = null;
|
|
14
47
|
static getInstance() {
|
|
15
48
|
return (ModelCache.instance ??= new ModelCache());
|
|
16
49
|
}
|
|
17
50
|
isValid() {
|
|
18
|
-
|
|
51
|
+
const fresh = Date.now() - this.populatedAt < getCacheTtlMs();
|
|
52
|
+
return this.populatedAt > 0 && fresh;
|
|
19
53
|
}
|
|
20
54
|
setModels(models) {
|
|
21
55
|
this.models = Object.fromEntries(models.map((m) => [m.id, m]));
|
|
22
56
|
this.fetchedAt = Date.now();
|
|
57
|
+
this.populatedAt = this.fetchedAt;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Force the cache back into an uninitialized state. Used by tests that
|
|
61
|
+
* need to assert `ensureFresh()` actually calls the fetcher. Also useful
|
|
62
|
+
* for ops (`health_check --reset`) if we ever expose such a knob.
|
|
63
|
+
*/
|
|
64
|
+
reset() {
|
|
65
|
+
this.models = {};
|
|
66
|
+
this.fetchedAt = 0;
|
|
67
|
+
this.populatedAt = 0;
|
|
68
|
+
this.inflight = null;
|
|
23
69
|
}
|
|
24
70
|
/**
|
|
25
71
|
* Populate the cache using `fetcher` if stale, coalescing concurrent callers
|
|
@@ -55,28 +101,39 @@ export class ModelCache {
|
|
|
55
101
|
has(id) {
|
|
56
102
|
return id in this.models;
|
|
57
103
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Single-pass paginated search: O(n) time, O(limit) extra space for the page.
|
|
106
|
+
* Avoids materializing the full filtered array when only one page is needed.
|
|
107
|
+
*/
|
|
108
|
+
searchPaginated(params, offset, limit) {
|
|
109
|
+
const matches = buildMatcher(params);
|
|
110
|
+
const safeOffset = Math.max(0, offset);
|
|
111
|
+
const safeLimit = Math.min(Math.max(1, limit), MAX_SEARCH_LIMIT);
|
|
112
|
+
const page = [];
|
|
113
|
+
let total = 0;
|
|
114
|
+
let matchIndex = 0;
|
|
115
|
+
for (const model of Object.values(this.models)) {
|
|
116
|
+
if (!matches(model))
|
|
117
|
+
continue;
|
|
118
|
+
if (matchIndex >= safeOffset && page.length < safeLimit) {
|
|
119
|
+
page.push(model);
|
|
120
|
+
}
|
|
121
|
+
matchIndex++;
|
|
76
122
|
}
|
|
77
|
-
|
|
123
|
+
total = matchIndex;
|
|
124
|
+
return { page, total };
|
|
125
|
+
}
|
|
126
|
+
search(params) {
|
|
127
|
+
if (params.all) {
|
|
128
|
+
const matches = buildMatcher(params);
|
|
129
|
+
const results = [];
|
|
130
|
+
for (const model of Object.values(this.models)) {
|
|
131
|
+
if (matches(model))
|
|
132
|
+
results.push(model);
|
|
133
|
+
}
|
|
78
134
|
return results;
|
|
135
|
+
}
|
|
79
136
|
const limit = Math.min(Math.max(1, params.limit ?? 10), MAX_SEARCH_LIMIT);
|
|
80
|
-
return
|
|
137
|
+
return this.searchPaginated(params, 0, limit).page;
|
|
81
138
|
}
|
|
82
139
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool descriptions with explicit routing, examples, and failure modes.
|
|
3
|
+
* See docs/plans/tool-description-improvement.md for the authoring guide.
|
|
4
|
+
*/
|
|
5
|
+
export interface ToolDescriptionParts {
|
|
6
|
+
summary: string;
|
|
7
|
+
useWhen: string[];
|
|
8
|
+
notWhen: string[];
|
|
9
|
+
goodExamples: string[];
|
|
10
|
+
badExamples: string[];
|
|
11
|
+
failsWhen: string[];
|
|
12
|
+
worksWith: string[];
|
|
13
|
+
}
|
|
14
|
+
export declare function buildToolDescription(parts: ToolDescriptionParts): string;
|
|
15
|
+
/** Required sections every tool description must contain (regression-tested). */
|
|
16
|
+
export declare const REQUIRED_DESCRIPTION_SECTIONS: readonly ["Use when:", "Do NOT use when:", "Good examples:", "Bad examples:", "Fails when:", "Works with:"];
|
|
17
|
+
export declare const TOOL_NAMES: readonly ["chat_completion", "analyze_image", "analyze_audio", "analyze_video", "search_models", "get_model_info", "validate_model", "generate_image", "generate_audio", "generate_video", "generate_video_from_image", "get_video_status", "rerank_documents", "health_check"];
|
|
18
|
+
export type ToolName = (typeof TOOL_NAMES)[number];
|
|
19
|
+
export declare const TOOL_DESCRIPTIONS: Record<ToolName, string>;
|