apimodels-mcp 0.2.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.
- package/README.md +115 -0
- package/dist/index.js +367 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# apimodels-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [apimodels.app](https://apimodels.app) — call **image, video, LLM chat and text-to-speech** models with one API key, from Claude Desktop, Cursor, or any MCP client.
|
|
4
|
+
|
|
5
|
+
One key unlocks GPT-5.5, Claude, Gemini, GLM, DeepSeek, Qwen, Seedance, Veo, Kling, gpt-image-2, Gemini Image, MiniMax speech and more — billed in USD, you only pay for successful generations.
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | What it does |
|
|
10
|
+
|------|--------------|
|
|
11
|
+
| `list_models` | List available model ids (chat / image / video / audio). |
|
|
12
|
+
| `chat` | Chat / text completion with any LLM (`gpt-5-5`, `claude-opus-4-8`, `gemini-3-pro-preview`, …). |
|
|
13
|
+
| `generate_image` | Text-to-image or image edit; returns the image URL(s) plus a downscaled preview the model can look at. |
|
|
14
|
+
| `review_image` | A vision model critiques an image against your brief and proposes a revised prompt. |
|
|
15
|
+
| `generate_video` | Text-to-video (optional reference image); returns the video URL(s). |
|
|
16
|
+
| `text_to_speech` | Text-to-speech (MiniMax voices); returns the audio URL. ElevenLabs TTS is not exposed here — it streams raw bytes from `POST /v1/tts/stream` rather than returning a URL. |
|
|
17
|
+
|
|
18
|
+
### The model can check its own work
|
|
19
|
+
|
|
20
|
+
Ask for an image and let the assistant iterate until it is right — "make a 16:9 banner that says SAVE 10%, check the spelling, fix it if needed":
|
|
21
|
+
|
|
22
|
+
1. `generate_image` returns the URL **and a preview of the image itself** (max 1024px JPEG). Clients that pass tool-result images to the model — Claude Desktop, Claude Code, Cursor — let it see what it made. Pass `return_image: false` to skip the preview.
|
|
23
|
+
2. `review_image` works everywhere, including clients that show tool-result images to you but not to the model (Cherry Studio is one). It sends the image and your brief to a vision model and returns what matches, what is wrong (garbled text, composition, aspect ratio, artifacts) and a revised prompt. One review costs well under $0.01 on the default `gpt-5.6-luna`.
|
|
24
|
+
|
|
25
|
+
The assistant picks `aspect_ratio` and `resolution` itself from what you ask for, so "make it 16:9" in plain words is enough.
|
|
26
|
+
|
|
27
|
+
### Local images just work
|
|
28
|
+
|
|
29
|
+
`image_url` on `generate_image` and `generate_video` takes any of these:
|
|
30
|
+
|
|
31
|
+
- a public `https://…` URL — passed through untouched
|
|
32
|
+
- **a local file path** — `/Users/me/photo.png`, `./ref.jpg`, `~/Pictures/x.webp`
|
|
33
|
+
- **a URL on your own machine** — `http://127.0.0.1:8000/photo.png`, `http://localhost:3000/…`
|
|
34
|
+
- a `data:image/png;base64,…` URI
|
|
35
|
+
|
|
36
|
+
The last three are uploaded for you first, and the resulting public URL is what gets
|
|
37
|
+
generated from. This has to happen here rather than server-side: the file exists only on
|
|
38
|
+
your machine, and `127.0.0.1` means *our* server when our server resolves it — which is why
|
|
39
|
+
passing one to the REST API directly fails with `private/reserved IP addresses not allowed`.
|
|
40
|
+
This MCP server runs next to your files, so it can do what our servers cannot.
|
|
41
|
+
|
|
42
|
+
Uploads land in your account's R2 space and are auto-deleted after 7 days.
|
|
43
|
+
|
|
44
|
+
## Setup
|
|
45
|
+
|
|
46
|
+
1. Get an API key at <https://apimodels.app/console/api-keys> (it looks like `sk_…`).
|
|
47
|
+
2. Add the server to your MCP client.
|
|
48
|
+
|
|
49
|
+
### Claude Desktop
|
|
50
|
+
|
|
51
|
+
Edit `claude_desktop_config.json` (Settings → Developer → Edit Config):
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"apimodels": {
|
|
57
|
+
"command": "npx",
|
|
58
|
+
"args": ["-y", "apimodels-mcp"],
|
|
59
|
+
"env": {
|
|
60
|
+
"APIMODELS_API_KEY": "sk_your_key_here"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Restart Claude Desktop. You can now ask it to "generate an image of …" or "make a 5-second video of …".
|
|
68
|
+
|
|
69
|
+
### Cursor
|
|
70
|
+
|
|
71
|
+
`Settings → MCP → Add new MCP server`, or add to `~/.cursor/mcp.json`:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"mcpServers": {
|
|
76
|
+
"apimodels": {
|
|
77
|
+
"command": "npx",
|
|
78
|
+
"args": ["-y", "apimodels-mcp"],
|
|
79
|
+
"env": { "APIMODELS_API_KEY": "sk_your_key_here" }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Cherry Studio
|
|
86
|
+
|
|
87
|
+
In `Settings → MCP Servers`, add a new server of type **stdio**:
|
|
88
|
+
|
|
89
|
+
- Command: `npx`
|
|
90
|
+
- Arguments: `-y apimodels-mcp`
|
|
91
|
+
- Environment variables: `APIMODELS_API_KEY=sk_your_key_here`
|
|
92
|
+
|
|
93
|
+
Enable the server, then select it for your conversation from the MCP control under the chat box. Use a chat model that supports tool calls (Claude, GPT, Gemini …) as the conversation model — it calls the image model for you. Cherry Studio needs Node.js installed for `npx`; on Windows install it from <https://nodejs.org>.
|
|
94
|
+
|
|
95
|
+
Any other MCP client works the same way — run `npx -y apimodels-mcp` over stdio with `APIMODELS_API_KEY` in the environment.
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
| Env var | Default | Description |
|
|
100
|
+
|---------|---------|-------------|
|
|
101
|
+
| `APIMODELS_API_KEY` | — (required) | Your `sk_…` key. |
|
|
102
|
+
| `APIMODELS_BASE_URL` | `https://api.apimodels.app/v1` | API base URL. |
|
|
103
|
+
| `APIMODELS_TIMEOUT_MS` | `300000` | Max time to poll an async (image/video/audio) task. |
|
|
104
|
+
|
|
105
|
+
## Local development
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pnpm install
|
|
109
|
+
pnpm build
|
|
110
|
+
APIMODELS_API_KEY=sk_... node dist/index.js # runs over stdio
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* apimodels-mcp — Model Context Protocol server for apimodels.app
|
|
4
|
+
*
|
|
5
|
+
* Exposes image / video / chat / text-to-speech generation as MCP tools so any
|
|
6
|
+
* MCP client (Claude Desktop, Cursor, …) can call every apimodels model with a
|
|
7
|
+
* single API key.
|
|
8
|
+
*
|
|
9
|
+
* Config (environment variables):
|
|
10
|
+
* APIMODELS_API_KEY (required) your sk_… key from https://apimodels.app/console/api-keys
|
|
11
|
+
* APIMODELS_BASE_URL (optional) default https://api.apimodels.app/v1
|
|
12
|
+
* APIMODELS_TIMEOUT_MS(optional) max ms to poll an async (image/video/audio) task, default 300000
|
|
13
|
+
*/
|
|
14
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
15
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { readFile } from 'node:fs/promises';
|
|
18
|
+
import { basename, join } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
const API_KEY = process.env.APIMODELS_API_KEY;
|
|
21
|
+
const BASE_URL = (process.env.APIMODELS_BASE_URL || 'https://api.apimodels.app/v1').replace(/\/$/, '');
|
|
22
|
+
const POLL_TIMEOUT_MS = Number(process.env.APIMODELS_TIMEOUT_MS) || 300_000;
|
|
23
|
+
const POLL_INTERVAL_MS = 3_000;
|
|
24
|
+
if (!API_KEY) {
|
|
25
|
+
console.error('[apimodels-mcp] APIMODELS_API_KEY is not set. Get one at https://apimodels.app/console/api-keys');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
29
|
+
async function apiFetch(path, init) {
|
|
30
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
31
|
+
...init,
|
|
32
|
+
headers: {
|
|
33
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
...(init?.headers || {}),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const text = await res.text();
|
|
39
|
+
let json;
|
|
40
|
+
try {
|
|
41
|
+
json = text ? JSON.parse(text) : {};
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
json = { raw: text };
|
|
45
|
+
}
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const msg = json?.msg || json?.error?.message || json?.error || text || `HTTP ${res.status}`;
|
|
48
|
+
throw new Error(`apimodels API error (HTTP ${res.status}): ${msg}`);
|
|
49
|
+
}
|
|
50
|
+
return json;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Turn whatever the caller gave us into a URL our servers can actually fetch.
|
|
54
|
+
*
|
|
55
|
+
* The problem this exists for: agents keep passing a local path, or a URL on the
|
|
56
|
+
* user's own machine like `http://127.0.0.1:8000/photo.png`. Our generation
|
|
57
|
+
* servers cannot reach either — `127.0.0.1` there means *our* box, not theirs —
|
|
58
|
+
* so the job dies with an upstream "private/reserved IP addresses not allowed"
|
|
59
|
+
* that tells the agent nothing about what to do instead.
|
|
60
|
+
*
|
|
61
|
+
* It cannot be fixed server-side: the file only exists on the user's machine.
|
|
62
|
+
* But this MCP server *runs* on that machine, so it can read the path, or fetch
|
|
63
|
+
* that localhost URL, and upload the bytes to `/v1/files` — which hands back a
|
|
64
|
+
* public URL. From the caller's point of view a local file just works.
|
|
65
|
+
*
|
|
66
|
+
* Passes public http(s) URLs straight through untouched.
|
|
67
|
+
*/
|
|
68
|
+
async function resolveImageInput(input) {
|
|
69
|
+
const raw = input.trim();
|
|
70
|
+
// data: URI — already bytes, just upload them.
|
|
71
|
+
if (raw.startsWith('data:')) {
|
|
72
|
+
const m = raw.match(/^data:([^;,]+)(;base64)?,(.*)$/s);
|
|
73
|
+
if (!m)
|
|
74
|
+
throw new Error('Malformed data: URI');
|
|
75
|
+
const [, mime, isB64, payload] = m;
|
|
76
|
+
const buf = Buffer.from(isB64 ? payload : decodeURIComponent(payload), isB64 ? 'base64' : 'utf8');
|
|
77
|
+
return uploadBytes(buf, `input.${(mime.split('/')[1] || 'png').replace(/[^\w]/g, '')}`, mime);
|
|
78
|
+
}
|
|
79
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
80
|
+
const host = new URL(raw).hostname;
|
|
81
|
+
const isLocal = host === 'localhost' ||
|
|
82
|
+
host === '::1' ||
|
|
83
|
+
/^127\./.test(host) ||
|
|
84
|
+
/^10\./.test(host) ||
|
|
85
|
+
/^192\.168\./.test(host) ||
|
|
86
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
|
87
|
+
host.endsWith('.local');
|
|
88
|
+
if (!isLocal)
|
|
89
|
+
return raw; // public URL — our servers can fetch it themselves
|
|
90
|
+
// Reachable from here (this process is on the user's machine), not from ours.
|
|
91
|
+
const res = await fetch(raw);
|
|
92
|
+
if (!res.ok)
|
|
93
|
+
throw new Error(`Could not read ${raw} from this machine (HTTP ${res.status})`);
|
|
94
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
95
|
+
const name = decodeURIComponent(new URL(raw).pathname.split('/').pop() || 'input.png');
|
|
96
|
+
return uploadBytes(buf, name, res.headers.get('content-type') || guessMime(name));
|
|
97
|
+
}
|
|
98
|
+
// Anything else is treated as a filesystem path (absolute, relative, or ~).
|
|
99
|
+
const path = raw.startsWith('~') ? join(homedir(), raw.slice(1)) : raw;
|
|
100
|
+
let buf;
|
|
101
|
+
try {
|
|
102
|
+
buf = await readFile(path);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new Error(`Not a URL, and no readable file at "${raw}". Pass a public https:// URL, a local file path, or a data: URI.`);
|
|
106
|
+
}
|
|
107
|
+
return uploadBytes(buf, basename(path), guessMime(path));
|
|
108
|
+
}
|
|
109
|
+
function guessMime(name) {
|
|
110
|
+
const ext = name.toLowerCase().split('.').pop() || '';
|
|
111
|
+
return { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp',
|
|
112
|
+
gif: 'image/gif', bmp: 'image/bmp', heic: 'image/heic' }[ext] || 'image/png';
|
|
113
|
+
}
|
|
114
|
+
/** Upload bytes to /v1/files and return the public URL it mints. */
|
|
115
|
+
async function uploadBytes(buf, filename, contentType) {
|
|
116
|
+
const form = new FormData();
|
|
117
|
+
form.append('file', new Blob([new Uint8Array(buf)], { type: contentType }), filename);
|
|
118
|
+
// No Content-Type header here on purpose — fetch must set the multipart boundary.
|
|
119
|
+
const res = await fetch(`${BASE_URL}/files`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { Authorization: `Bearer ${API_KEY}` },
|
|
122
|
+
body: form,
|
|
123
|
+
});
|
|
124
|
+
const json = await res.json().catch(() => ({}));
|
|
125
|
+
const url = json?.data?.publicUrl;
|
|
126
|
+
if (!res.ok || !url) {
|
|
127
|
+
throw new Error(`Upload of ${filename} failed: ${json?.msg || `HTTP ${res.status}`}`);
|
|
128
|
+
}
|
|
129
|
+
return url;
|
|
130
|
+
}
|
|
131
|
+
/** Submit an async generation (image/video/audio), then poll until it finishes. */
|
|
132
|
+
async function runAsyncTask(kind, body) {
|
|
133
|
+
const created = await apiFetch(`/${kind}/generations`, { method: 'POST', body: JSON.stringify(body) });
|
|
134
|
+
const taskId = created?.data?.taskId;
|
|
135
|
+
if (!taskId)
|
|
136
|
+
throw new Error(`No taskId returned: ${JSON.stringify(created)}`);
|
|
137
|
+
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
138
|
+
while (Date.now() < deadline) {
|
|
139
|
+
await sleep(POLL_INTERVAL_MS);
|
|
140
|
+
const polled = await apiFetch(`/${kind}/generations?task_id=${encodeURIComponent(taskId)}`);
|
|
141
|
+
const state = polled?.data?.state;
|
|
142
|
+
if (state === 'completed') {
|
|
143
|
+
const urls = polled?.data?.resultUrls || [];
|
|
144
|
+
if (!urls.length)
|
|
145
|
+
throw new Error('Task completed but returned no result URLs');
|
|
146
|
+
return urls;
|
|
147
|
+
}
|
|
148
|
+
if (state === 'failed') {
|
|
149
|
+
throw new Error(`Generation failed: ${polled?.data?.failMsg || polled?.data?.failCode || 'unknown error'}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
throw new Error(`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s (task ${taskId} still running). Results stay retrievable via the dashboard.`);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* A downscaled JPEG of a generated image, so a client that forwards tool-result
|
|
156
|
+
* images to the model (Claude Desktop, Claude Code, Cursor) lets the model SEE
|
|
157
|
+
* what it made and iterate on it. Full-size results run 1–8 MB; resent on every
|
|
158
|
+
* turn that would swamp the context, so we cap the long edge at 1024px.
|
|
159
|
+
*
|
|
160
|
+
* sharp is an optionalDependency: if it failed to install on this platform we
|
|
161
|
+
* fall back to the original bytes when they are small enough, else no preview.
|
|
162
|
+
* Never throws — a missing preview must not fail a generation that succeeded
|
|
163
|
+
* (and was billed).
|
|
164
|
+
*/
|
|
165
|
+
const PREVIEW_MAX_EDGE = 1024;
|
|
166
|
+
const PREVIEW_RAW_LIMIT = 1_000_000;
|
|
167
|
+
async function imagePreview(url) {
|
|
168
|
+
try {
|
|
169
|
+
const res = await fetch(url);
|
|
170
|
+
if (!res.ok)
|
|
171
|
+
return null;
|
|
172
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
173
|
+
try {
|
|
174
|
+
const mod = 'sharp'; // indirect so tsc does not require the optional package
|
|
175
|
+
const sharp = (await import(mod)).default;
|
|
176
|
+
const out = await sharp(buf)
|
|
177
|
+
.rotate()
|
|
178
|
+
.resize({ width: PREVIEW_MAX_EDGE, height: PREVIEW_MAX_EDGE, fit: 'inside', withoutEnlargement: true })
|
|
179
|
+
.jpeg({ quality: 80 })
|
|
180
|
+
.toBuffer();
|
|
181
|
+
return { data: out.toString('base64'), mimeType: 'image/jpeg' };
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
const mime = (res.headers.get('content-type') || guessMime(url)).split(';')[0].trim();
|
|
185
|
+
if (buf.length <= PREVIEW_RAW_LIMIT && /^image\/(png|jpeg|webp|gif)$/.test(mime)) {
|
|
186
|
+
return { data: buf.toString('base64'), mimeType: mime };
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const REVIEW_SYSTEM = [
|
|
196
|
+
'You are reviewing an AI-generated image against the brief it was generated from.',
|
|
197
|
+
'Look at the image carefully and answer in three short parts:',
|
|
198
|
+
'1. MATCHES — what the image gets right.',
|
|
199
|
+
'2. PROBLEMS — what is wrong or missing. Be concrete: misspelled or garbled text (quote it), wrong counts, composition, colors, anatomy, artifacts, aspect ratio.',
|
|
200
|
+
'3. REVISED PROMPT — one complete prompt that would fix the problems. If the image already satisfies the brief, say "No changes needed" instead.',
|
|
201
|
+
].join('\n');
|
|
202
|
+
const text = (s) => ({ content: [{ type: 'text', text: s }] });
|
|
203
|
+
const fail = (e) => ({ content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true });
|
|
204
|
+
const server = new McpServer({ name: 'apimodels-mcp', version: '0.2.0' });
|
|
205
|
+
server.tool('list_models', 'List the model ids available on apimodels.app (chat, image, video, audio). Use the returned ids with the other tools. Caveat: a handful of entries are internal names that the generation endpoints reject (e.g. seedance-2-fast, seedance-2, motion-control) — the public alias is the dotted form, e.g. seedance-2.0-fast. If an id comes back "Invalid model", try the dotted variant before giving up.', {}, async () => {
|
|
206
|
+
try {
|
|
207
|
+
const res = await apiFetch('/models');
|
|
208
|
+
const ids = (res?.data || []).map((m) => m.id).filter(Boolean);
|
|
209
|
+
return text(ids.length ? ids.join('\n') : JSON.stringify(res));
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
return fail(e);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
server.tool('chat', 'Chat / text completion with any LLM on apimodels.app (GPT-5.5, Claude, Gemini, GLM, DeepSeek, Qwen, …). Returns the assistant reply text.', {
|
|
216
|
+
prompt: z.string().describe('The user message / prompt.'),
|
|
217
|
+
model: z.string().default('gpt-5-5').describe('Model id, e.g. gpt-5-5, claude-opus-4-8, claude-sonnet-4-6, gemini-3-pro-preview, deepseek-v4-pro.'),
|
|
218
|
+
system: z.string().optional().describe('Optional system prompt.'),
|
|
219
|
+
max_tokens: z.number().int().positive().optional().describe('Optional max output tokens.'),
|
|
220
|
+
}, async ({ prompt, model, system, max_tokens }) => {
|
|
221
|
+
try {
|
|
222
|
+
const messages = [
|
|
223
|
+
...(system ? [{ role: 'system', content: system }] : []),
|
|
224
|
+
{ role: 'user', content: prompt },
|
|
225
|
+
];
|
|
226
|
+
const res = await apiFetch('/chat/completions', {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
body: JSON.stringify({ model, messages, ...(max_tokens ? { max_tokens } : {}) }),
|
|
229
|
+
});
|
|
230
|
+
const reply = res?.choices?.[0]?.message?.content;
|
|
231
|
+
return text(typeof reply === 'string' ? reply : JSON.stringify(res));
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
return fail(e);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
server.tool('generate_image', 'Generate an image from a text prompt (or edit an input image). Returns the URL(s) of the generated image, valid 7 days, plus a downscaled preview of the image itself when the client can show tool-result images to you. If you cannot see the image in the result, call review_image with the returned URL to get a written critique and a revised prompt, then generate again. Roughly $0.025 per image on the default model; gpt-image-2-lite is $0.008.', {
|
|
238
|
+
prompt: z.string().describe('Text description of the image to generate.'),
|
|
239
|
+
model: z.string().default('gpt-image-2').describe('Image model id, e.g. gpt-image-2, gpt-image-2-lite (cheapest), gemini-3-pro-image, gemini-2.5-flash-image, doubao-seedream-4-5-251128.'),
|
|
240
|
+
aspect_ratio: z.string().optional().describe('Optional aspect ratio, e.g. 1:1, 16:9, 9:16.'),
|
|
241
|
+
resolution: z.string().optional().describe('Optional resolution, e.g. 1K, 2K, 4K.'),
|
|
242
|
+
image_url: z.string().optional().describe('Optional input image for image-to-image edits. Accepts a public https:// URL, a LOCAL FILE PATH, a localhost URL, or a data: URI — local sources are uploaded for you automatically.'),
|
|
243
|
+
return_image: z.boolean().default(true).describe('Attach a downscaled preview (max 1024px JPEG) of the result so you can look at it. Set false to save context when you only need the URL.'),
|
|
244
|
+
}, async ({ prompt, model, aspect_ratio, resolution, image_url, return_image }) => {
|
|
245
|
+
try {
|
|
246
|
+
const urls = await runAsyncTask('images', {
|
|
247
|
+
model, prompt,
|
|
248
|
+
...(aspect_ratio ? { aspect_ratio } : {}),
|
|
249
|
+
...(resolution ? { resolution } : {}),
|
|
250
|
+
...(image_url ? { image_url: await resolveImageInput(image_url) } : {}),
|
|
251
|
+
});
|
|
252
|
+
const previews = return_image ? await Promise.all(urls.slice(0, 2).map(imagePreview)) : [];
|
|
253
|
+
return {
|
|
254
|
+
content: [
|
|
255
|
+
{ type: 'text', text: urls.join('\n') },
|
|
256
|
+
...previews.flatMap((p) => (p ? [{ type: 'image', data: p.data, mimeType: p.mimeType }] : [])),
|
|
257
|
+
],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
return fail(e);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
server.tool('review_image', 'Have a vision model look at an image and critique it against a brief. Returns what matches, what is wrong (garbled text, composition, colors, artifacts) and a revised prompt. Use it after generate_image to check the result and decide whether to regenerate — this works in every MCP client, including ones that do not pass tool-result images to you. Costs one small vision chat call (well under $0.01 on the default model).', {
|
|
265
|
+
image_url: z.string().describe('The image to review: a URL returned by generate_image, any public https:// URL, a LOCAL FILE PATH, a localhost URL, or a data: URI.'),
|
|
266
|
+
brief: z.string().describe('What the image is supposed to show — usually the prompt it was generated from, plus any requirements the user stated (exact text, aspect ratio, style).'),
|
|
267
|
+
model: z.string().default('gpt-5.6-luna').describe('Vision-capable chat model that does the looking. gpt-5.6-luna (default, cheapest) or claude-sonnet-5 for a more careful read.'),
|
|
268
|
+
}, async ({ image_url, brief, model }) => {
|
|
269
|
+
try {
|
|
270
|
+
const url = await resolveImageInput(image_url);
|
|
271
|
+
const res = await apiFetch('/chat/completions', {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
body: JSON.stringify({
|
|
274
|
+
model,
|
|
275
|
+
max_tokens: 900,
|
|
276
|
+
messages: [
|
|
277
|
+
{ role: 'system', content: REVIEW_SYSTEM },
|
|
278
|
+
{ role: 'user', content: [
|
|
279
|
+
{ type: 'text', text: `BRIEF:\n${brief}` },
|
|
280
|
+
{ type: 'image_url', image_url: { url } },
|
|
281
|
+
] },
|
|
282
|
+
],
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
const reply = res?.choices?.[0]?.message?.content;
|
|
286
|
+
return text(typeof reply === 'string' && reply.trim() ? reply : JSON.stringify(res));
|
|
287
|
+
}
|
|
288
|
+
catch (e) {
|
|
289
|
+
return fail(e);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
server.tool('generate_video', 'Generate a video from a text prompt (and optional reference image). Polls until done and returns the video URL(s), valid 7 days. May take a few minutes. Video is the most expensive modality here — the default model costs roughly $0.30-$0.50 per clip; pass model:"veo-3.1-fast-fhd" for the cheapest option at $0.07 flat.', {
|
|
293
|
+
prompt: z.string().describe('Text description of the video.'),
|
|
294
|
+
model: z.string().default('seedance-2.0-fast').describe('Video model id. Use the dotted public names: seedance-2.0-fast, seedance-2.0, seedance-2.5, veo-3.1-fast-fhd ($0.07 flat, cheapest), veo-3.1, grok-video-3, kling-v2-6, minimax-h3. The bare forms seedance-2-fast / seedance-2 are internal names and will 400.'),
|
|
295
|
+
aspect_ratio: z.string().optional().describe('Optional aspect ratio, e.g. 16:9, 9:16, 1:1.'),
|
|
296
|
+
resolution: z.string().optional().describe('Optional resolution, e.g. 480p, 720p, 1080p.'),
|
|
297
|
+
duration: z.union([z.number(), z.string()]).optional().describe('Optional duration in seconds, e.g. 5 or 10.'),
|
|
298
|
+
image_url: z.string().optional().describe('Optional first-frame / reference image for image-to-video. Accepts a public https:// URL, a LOCAL FILE PATH, a localhost URL, or a data: URI — local sources are uploaded for you automatically.'),
|
|
299
|
+
}, async ({ prompt, model, aspect_ratio, resolution, duration, image_url }) => {
|
|
300
|
+
try {
|
|
301
|
+
const urls = await runAsyncTask('video', {
|
|
302
|
+
model, prompt,
|
|
303
|
+
...(aspect_ratio ? { aspect_ratio } : {}),
|
|
304
|
+
...(resolution ? { resolution } : {}),
|
|
305
|
+
...(duration != null ? { duration } : {}),
|
|
306
|
+
...(image_url ? { images: [await resolveImageInput(image_url)] } : {}),
|
|
307
|
+
});
|
|
308
|
+
return text(urls.join('\n'));
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
return fail(e);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
/**
|
|
315
|
+
* Text to speech.
|
|
316
|
+
*
|
|
317
|
+
* This tool used to default to `eleven-tts-v3` against `/audio/generations`, which
|
|
318
|
+
* cannot work: the ElevenLabs TTS models are served by `POST /v1/tts/stream`, and
|
|
319
|
+
* `/audio/generations` rejects every `eleven-tts-*` id outright
|
|
320
|
+
* ("Invalid model: eleven-tts-v3. Supported: kling-…, eleven-dialogue, …"). Every
|
|
321
|
+
* call 400'd.
|
|
322
|
+
*
|
|
323
|
+
* Of the two ways out, this tool stays on `/audio/generations` and moves to a model
|
|
324
|
+
* that endpoint actually serves. `/v1/tts/stream` returns raw audio BYTES, so an MCP
|
|
325
|
+
* server pointed at it has no URL to hand back — it would have to write a file and
|
|
326
|
+
* change what this tool returns, diverging from generate_image / generate_video.
|
|
327
|
+
* `/audio/generations` keeps the async-task-to-URL shape the rest of the server uses
|
|
328
|
+
* and takes exactly the parameters declared below.
|
|
329
|
+
*
|
|
330
|
+
* MiniMax requires an explicit voice_id (there is no server-side default), so one is
|
|
331
|
+
* baked in here — without it the "default path" would still fail, just with a
|
|
332
|
+
* different message. Voice ids come from GET /v1/minimax/voices.
|
|
333
|
+
*/
|
|
334
|
+
server.tool('text_to_speech', 'Convert text to speech (MiniMax voices). Returns the audio file URL (valid 7 days). Costs about $0.004 for a short line; billed at $0.04 per 1000 characters.', {
|
|
335
|
+
text: z.string().describe('The text to speak.'),
|
|
336
|
+
model: z
|
|
337
|
+
.string()
|
|
338
|
+
.default('minimax-speech-02-turbo')
|
|
339
|
+
.describe('TTS model id, e.g. minimax-speech-02-turbo (fast), minimax-speech-02-hd / minimax-speech-2.8-hd (higher quality). Note: eleven-tts-* models are NOT available here — they stream from POST /v1/tts/stream instead.'),
|
|
340
|
+
voice_id: z
|
|
341
|
+
.string()
|
|
342
|
+
.default('English_Trustworthy_Man')
|
|
343
|
+
.describe('Voice id. English: English_Trustworthy_Man, English_Graceful_Lady, Serene_Woman. Chinese: male-qn-qingse, female-tianmei. Full list: GET /v1/minimax/voices.'),
|
|
344
|
+
speed: z.number().optional().describe('Optional speaking rate, 0.5-2 (1 = normal).'),
|
|
345
|
+
}, async ({ text: tts, model, voice_id, speed }) => {
|
|
346
|
+
try {
|
|
347
|
+
const urls = await runAsyncTask('audio', {
|
|
348
|
+
model,
|
|
349
|
+
text: tts,
|
|
350
|
+
voice_id,
|
|
351
|
+
...(speed != null ? { voice_setting: { voice_id, speed } } : {}),
|
|
352
|
+
});
|
|
353
|
+
return text(urls.join('\n'));
|
|
354
|
+
}
|
|
355
|
+
catch (e) {
|
|
356
|
+
return fail(e);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
async function main() {
|
|
360
|
+
const transport = new StdioServerTransport();
|
|
361
|
+
await server.connect(transport);
|
|
362
|
+
console.error('[apimodels-mcp] ready (stdio). Base URL:', BASE_URL);
|
|
363
|
+
}
|
|
364
|
+
main().catch((e) => {
|
|
365
|
+
console.error('[apimodels-mcp] fatal:', e);
|
|
366
|
+
process.exit(1);
|
|
367
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "apimodels-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server for apimodels.app — call image, video, LLM chat and text-to-speech models with one API key, from Claude Desktop, Cursor and any MCP client.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://apimodels.app",
|
|
8
|
+
"bin": {
|
|
9
|
+
"apimodels-mcp": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mcp",
|
|
16
|
+
"model-context-protocol",
|
|
17
|
+
"apimodels",
|
|
18
|
+
"ai",
|
|
19
|
+
"llm",
|
|
20
|
+
"image-generation",
|
|
21
|
+
"video-generation",
|
|
22
|
+
"text-to-speech",
|
|
23
|
+
"claude",
|
|
24
|
+
"cursor"
|
|
25
|
+
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
28
|
+
"zod": "^3.23.8"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^22.0.0",
|
|
32
|
+
"typescript": "^5.6.0"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"optionalDependencies": {
|
|
38
|
+
"sharp": "^0.34.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc",
|
|
42
|
+
"start": "node dist/index.js",
|
|
43
|
+
"dev": "tsc --watch"
|
|
44
|
+
}
|
|
45
|
+
}
|