@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.
Files changed (34) hide show
  1. package/README.md +380 -242
  2. package/dist/index.js +22 -6
  3. package/dist/model-cache.d.ts +35 -12
  4. package/dist/model-cache.js +79 -22
  5. package/dist/tool-descriptions.d.ts +19 -0
  6. package/dist/tool-descriptions.js +423 -0
  7. package/dist/tool-handlers/analyze-audio.js +5 -1
  8. package/dist/tool-handlers/analyze-image.js +6 -5
  9. package/dist/tool-handlers/analyze-video.js +6 -5
  10. package/dist/tool-handlers/audio-utils.js +4 -2
  11. package/dist/tool-handlers/chat-completion.js +1 -1
  12. package/dist/tool-handlers/fetch-utils.js +16 -2
  13. package/dist/tool-handlers/generate-audio.js +2 -4
  14. package/dist/tool-handlers/generate-image-input.d.ts +3 -0
  15. package/dist/tool-handlers/generate-image-input.js +38 -0
  16. package/dist/tool-handlers/generate-image.d.ts +13 -51
  17. package/dist/tool-handlers/generate-image.js +32 -119
  18. package/dist/tool-handlers/generate-video.js +28 -24
  19. package/dist/tool-handlers/health-check.js +4 -1
  20. package/dist/tool-handlers/image-utils.d.ts +1 -0
  21. package/dist/tool-handlers/image-utils.js +26 -16
  22. package/dist/tool-handlers/openrouter-errors.d.ts +5 -1
  23. package/dist/tool-handlers/openrouter-errors.js +78 -13
  24. package/dist/tool-handlers/provider-routing.js +7 -2
  25. package/dist/tool-handlers/rerank.js +2 -5
  26. package/dist/tool-handlers/search-models.d.ts +2 -2
  27. package/dist/tool-handlers/search-models.js +2 -6
  28. package/dist/tool-handlers/structured-output.d.ts +8 -0
  29. package/dist/tool-handlers/structured-output.js +11 -0
  30. package/dist/tool-handlers/video-utils.js +6 -9
  31. package/dist/tool-handlers.js +43 -123
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. 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
- console.error('[Fatal]', err);
27
+ logFatal('uncaughtException', err);
12
28
  process.exit(1);
13
29
  });
14
30
  process.on('unhandledRejection', (err) => {
15
- console.error('[Fatal]', err);
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: '4.0.1' }, { capabilities: { tools: {} } });
25
- server.onerror = (error) => console.error('[MCP Error]', 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();
@@ -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
- search(params: {
31
- query?: string;
32
- provider?: string;
33
- capabilities?: {
34
- vision?: boolean;
35
- audio?: boolean;
36
- video?: boolean;
37
- };
38
- limit?: number;
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
  }
@@ -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
- return Object.keys(this.models).length > 0 && Date.now() - this.fetchedAt < getCacheTtlMs();
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
- search(params) {
59
- let results = this.getAll();
60
- if (params.query) {
61
- const q = params.query.toLowerCase();
62
- results = results.filter((m) => m.id.toLowerCase().includes(q) || m.name?.toLowerCase().includes(q));
63
- }
64
- if (params.provider) {
65
- const p = params.provider.toLowerCase();
66
- results = results.filter((m) => m.id.toLowerCase().startsWith(p + '/'));
67
- }
68
- if (params.capabilities?.vision) {
69
- results = results.filter((m) => m.architecture?.input_modalities?.includes('image'));
70
- }
71
- if (params.capabilities?.audio) {
72
- results = results.filter((m) => m.architecture?.input_modalities?.includes('audio'));
73
- }
74
- if (params.capabilities?.video) {
75
- results = results.filter((m) => m.architecture?.input_modalities?.includes('video'));
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
- if (params.all)
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 results.slice(0, limit);
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>;