Initial release: MCP Image Generator
Multi-provider MCP server for AI image generation. Supports OpenRouter, Together AI, Replicate, and fal.ai. Works with Claude Code, Cursor, Claude Desktop, OpenCode, and Charm. Install via: npx -y mcp-image-generator Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
.claude/settings.local.json
Normal file
11
.claude/settings.local.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(git init:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git add:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.js.map
|
||||
.env
|
||||
generated-images/
|
||||
11
.mcp.json
Normal file
11
.mcp.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"image-gen": {
|
||||
"command": "node",
|
||||
"args": ["dist/index.js"],
|
||||
"env": {
|
||||
"OPENROUTER_API_KEY": "${OPENROUTER_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
205
README.md
Normal file
205
README.md
Normal file
@@ -0,0 +1,205 @@
|
||||
<p align="center">
|
||||
<img src="logo.png" width="200" alt="MCP Image Generator Logo">
|
||||
</p>
|
||||
|
||||
<h1 align="center">MCP Image Generator</h1>
|
||||
|
||||
<p align="center">
|
||||
MCP server for AI image generation. Works with <b>Claude Code</b>, <b>Cursor</b>, <b>Claude Desktop</b>, <b>OpenCode</b>, <b>Charm (Crush/Mods)</b>, and any MCP-compatible AI agent.
|
||||
</p>
|
||||
|
||||
**Providers supported** (image models only):
|
||||
- **OpenRouter** (default) — Gemini Flash Image, FLUX 2, Sourceful Riverflow, GPT-5 Image
|
||||
- **Together AI** — FLUX.1 Schnell/Dev/Pro, Stable Diffusion XL
|
||||
- **Replicate** — FLUX Schnell, FLUX 1.1 Pro, SDXL, Ideogram
|
||||
- **fal.ai** — FLUX Dev/Schnell/2 Pro, Recraft V3
|
||||
|
||||
## Quick Start
|
||||
|
||||
No install needed — just use `npx`:
|
||||
|
||||
```bash
|
||||
OPENROUTER_API_KEY="sk-or-v1-..." npx -y mcp-image-generator
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### `generate_image`
|
||||
Generate an image from a text prompt.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `prompt` | string (required) | Text description of the image |
|
||||
| `model` | string | Model ID (e.g. `google/gemini-2.5-flash-image`) |
|
||||
| `provider` | string | `openrouter` (default), `together`, `replicate`, `fal` |
|
||||
| `aspect_ratio` | string | `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, `2:3` |
|
||||
| `image_size` | string | `1K` (default), `2K`, `4K` |
|
||||
| `negative_prompt` | string | What to exclude (not all providers support this) |
|
||||
| `seed` | number | Random seed for reproducibility |
|
||||
| `save_to` | string | Custom file path to save the image |
|
||||
|
||||
### `list_providers`
|
||||
List all configured providers and their available image models.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `provider` | string | Filter by provider name |
|
||||
|
||||
---
|
||||
|
||||
## Setup for AI Agents / IDEs
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
claude mcp add --scope user --transport stdio image-gen \
|
||||
--env OPENROUTER_API_KEY=sk-or-v1-xxx \
|
||||
-- npx -y mcp-image-generator
|
||||
```
|
||||
|
||||
Or create `.mcp.json` in your project root:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"image-gen": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-image-generator"],
|
||||
"env": {
|
||||
"OPENROUTER_API_KEY": "sk-or-v1-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
Edit `~/.cursor/mcp.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"image-gen": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-image-generator"],
|
||||
"env": {
|
||||
"OPENROUTER_API_KEY": "sk-or-v1-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Edit the config file:
|
||||
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
||||
- **Linux:** `~/.config/Claude/claude_desktop_config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"image-gen": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-image-generator"],
|
||||
"env": {
|
||||
"OPENROUTER_API_KEY": "sk-or-v1-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### OpenCode (by SST)
|
||||
|
||||
Edit `~/.config/opencode/opencode.json` or `opencode.json` in project root:
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"image-gen": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "mcp-image-generator"],
|
||||
"environment": {
|
||||
"OPENROUTER_API_KEY": "sk-or-v1-..."
|
||||
},
|
||||
"enabled": true,
|
||||
"timeout": 120000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Charm Crush
|
||||
|
||||
Add to `crush.json` in your project root:
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"image-gen": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-image-generator"],
|
||||
"env": {
|
||||
"OPENROUTER_API_KEY": "sk-or-v1-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Charm Mods
|
||||
|
||||
Edit `mods.yml` (`mods --settings`):
|
||||
```yaml
|
||||
mcp-servers:
|
||||
image-gen:
|
||||
command: [npx]
|
||||
args:
|
||||
- -y
|
||||
- mcp-image-generator
|
||||
env:
|
||||
- OPENROUTER_API_KEY=sk-or-v1-...
|
||||
```
|
||||
|
||||
### Any MCP-compatible agent
|
||||
|
||||
This server uses **stdio transport** (JSON-RPC over stdin/stdout). Any agent that supports MCP stdio can use it:
|
||||
|
||||
```bash
|
||||
npx -y mcp-image-generator
|
||||
|
||||
# Required environment variables (at least one)
|
||||
OPENROUTER_API_KEY=... # OpenRouter (default)
|
||||
TOGETHER_API_KEY=... # Together AI
|
||||
REPLICATE_API_TOKEN=... # Replicate
|
||||
FAL_KEY=... # fal.ai
|
||||
```
|
||||
|
||||
## Using Multiple Providers
|
||||
|
||||
Set multiple API keys to use different providers:
|
||||
|
||||
```bash
|
||||
export OPENROUTER_API_KEY="sk-or-v1-..."
|
||||
export TOGETHER_API_KEY="..."
|
||||
export FAL_KEY="..."
|
||||
```
|
||||
|
||||
Then specify the provider when generating:
|
||||
```
|
||||
generate_image(prompt="a cat", provider="together", model="black-forest-labs/FLUX.1-schnell")
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Provider | Required |
|
||||
|----------|----------|----------|
|
||||
| `OPENROUTER_API_KEY` | OpenRouter | At least one |
|
||||
| `TOGETHER_API_KEY` | Together AI | provider key |
|
||||
| `REPLICATE_API_TOKEN` | Replicate | is required |
|
||||
| `FAL_KEY` | fal.ai | |
|
||||
| `IMAGE_OUTPUT_DIR` | — | Optional. Default: `~/generated-images/` |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
1175
package-lock.json
generated
Normal file
1175
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
package.json
Normal file
48
package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "mcp-image-generator",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for AI image generation via OpenRouter, Together AI, Replicate, and fal.ai",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"mcp-image-generator": "dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"logo.png"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"image-generation",
|
||||
"openrouter",
|
||||
"together-ai",
|
||||
"replicate",
|
||||
"fal-ai",
|
||||
"ai",
|
||||
"claude-code",
|
||||
"cursor",
|
||||
"claude-desktop",
|
||||
"opencode",
|
||||
"model-context-protocol"
|
||||
],
|
||||
"author": "fdciabdul",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.imtaqin.id/fdciabdul/MCP-IMAGE-GENERATOR.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
245
src/index.ts
Normal file
245
src/index.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { createProviders, getDefaultProvider } from "./providers/index.js";
|
||||
import type { ImageProvider, ImageModel } from "./types.js";
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const providers = createProviders();
|
||||
|
||||
if (providers.size === 0) {
|
||||
console.error(
|
||||
"No API keys configured. Set at least one of: OPENROUTER_API_KEY, TOGETHER_API_KEY, REPLICATE_API_TOKEN, FAL_KEY"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: "mcp-image-generator",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// ─── Tool: list_providers ───────────────────────────────────────────────────
|
||||
|
||||
server.registerTool("list_providers", {
|
||||
title: "List Image Providers",
|
||||
description: "List all configured image generation providers and their available models",
|
||||
inputSchema: {
|
||||
provider: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Filter by provider name: openrouter, together, replicate, fal"),
|
||||
},
|
||||
}, async ({ provider: providerName }) => {
|
||||
const targetProviders: ImageProvider[] = [];
|
||||
|
||||
if (providerName) {
|
||||
const p = providers.get(providerName);
|
||||
if (!p) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Provider "${providerName}" is not configured. Available: ${[...providers.keys()].join(", ")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
targetProviders.push(p);
|
||||
} else {
|
||||
targetProviders.push(...providers.values());
|
||||
}
|
||||
|
||||
const allModels: ImageModel[] = [];
|
||||
for (const p of targetProviders) {
|
||||
try {
|
||||
const models = await p.listModels();
|
||||
allModels.push(...models);
|
||||
} catch (err) {
|
||||
allModels.push({
|
||||
id: `error-${p.name}`,
|
||||
name: `Error loading ${p.name} models`,
|
||||
provider: p.name,
|
||||
description: String(err),
|
||||
inputModalities: [],
|
||||
outputModalities: [],
|
||||
modalities: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const grouped = new Map<string, ImageModel[]>();
|
||||
for (const m of allModels) {
|
||||
const list = grouped.get(m.provider) ?? [];
|
||||
list.push(m);
|
||||
grouped.set(m.provider, list);
|
||||
}
|
||||
|
||||
let text = `# Available Image Generation Models\n\n`;
|
||||
text += `**Configured providers:** ${[...providers.keys()].join(", ")}\n\n`;
|
||||
|
||||
for (const [provName, models] of grouped) {
|
||||
text += `## ${provName}\n\n`;
|
||||
for (const m of models) {
|
||||
text += `- **${m.id}** — ${m.name}`;
|
||||
if (m.description) text += ` — ${m.description}`;
|
||||
if (m.pricing) text += ` (${m.pricing})`;
|
||||
text += `\n`;
|
||||
}
|
||||
text += `\n`;
|
||||
}
|
||||
|
||||
return { content: [{ type: "text" as const, text }] };
|
||||
});
|
||||
|
||||
// ─── Tool: generate_image ───────────────────────────────────────────────────
|
||||
|
||||
const outputDir = resolve(process.env["IMAGE_OUTPUT_DIR"] ?? process.env["HOME"] ?? ".", "generated-images");
|
||||
|
||||
server.registerTool("generate_image", {
|
||||
title: "Generate Image",
|
||||
description:
|
||||
"Generate an image from a text prompt. Uses OpenRouter by default. Supports multiple providers: openrouter, together, replicate, fal.",
|
||||
inputSchema: {
|
||||
prompt: z.string().describe("Text description of the image to generate"),
|
||||
model: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Model ID (e.g. 'google/gemini-2.5-flash-image', 'black-forest-labs/flux-schnell')"),
|
||||
provider: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Provider to use: openrouter (default), together, replicate, fal"),
|
||||
aspect_ratio: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Aspect ratio: 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3"),
|
||||
image_size: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Image resolution: 1K (default), 2K, 4K"),
|
||||
negative_prompt: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("What to exclude from the image (not supported by all providers)"),
|
||||
seed: z.number().optional().describe("Random seed for reproducibility"),
|
||||
save_to: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Custom file path to save the image. Defaults to ~/generated-images/"),
|
||||
},
|
||||
}, async ({ prompt, model, provider: providerName, aspect_ratio, image_size, negative_prompt, seed, save_to }) => {
|
||||
// Select provider
|
||||
let selectedProvider: ImageProvider | undefined;
|
||||
if (providerName) {
|
||||
selectedProvider = providers.get(providerName);
|
||||
if (!selectedProvider) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Provider "${providerName}" is not configured. Available: ${[...providers.keys()].join(", ")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
selectedProvider = getDefaultProvider(providers);
|
||||
}
|
||||
|
||||
if (!selectedProvider) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "No providers configured." }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await selectedProvider.generateImage({
|
||||
prompt,
|
||||
model,
|
||||
provider: selectedProvider.name,
|
||||
aspectRatio: aspect_ratio,
|
||||
imageSize: image_size,
|
||||
negativePrompt: negative_prompt,
|
||||
seed,
|
||||
});
|
||||
|
||||
if (result.images.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Image generation completed but no images were returned. Model: ${result.model}, Provider: ${result.provider}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Save images to disk
|
||||
const saveDir = save_to ? resolve(save_to, "..") : outputDir;
|
||||
if (!existsSync(saveDir)) {
|
||||
await mkdir(saveDir, { recursive: true });
|
||||
}
|
||||
|
||||
const contentItems: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; data: string; mimeType: string }
|
||||
> = [];
|
||||
|
||||
for (let i = 0; i < result.images.length; i++) {
|
||||
const img = result.images[i]!;
|
||||
const ext = img.mimeType.includes("webp")
|
||||
? "webp"
|
||||
: img.mimeType.includes("jpeg") || img.mimeType.includes("jpg")
|
||||
? "jpg"
|
||||
: "png";
|
||||
|
||||
const filename = save_to
|
||||
? save_to
|
||||
: join(saveDir, `img_${Date.now()}_${i}.${ext}`);
|
||||
|
||||
await writeFile(filename, Buffer.from(img.base64, "base64"));
|
||||
|
||||
contentItems.push({
|
||||
type: "text" as const,
|
||||
text: `Image ${i + 1} saved to: ${filename}\nModel: ${result.model} | Provider: ${result.provider}${
|
||||
result.usage?.cost != null ? ` | Cost: $${result.usage.cost.toFixed(4)}` : ""
|
||||
}`,
|
||||
});
|
||||
|
||||
contentItems.push({
|
||||
type: "image" as const,
|
||||
data: img.base64,
|
||||
mimeType: img.mimeType,
|
||||
});
|
||||
}
|
||||
|
||||
return { content: contentItems };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Image generation failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Start Server ───────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("MCP Image Generator server started");
|
||||
console.error(`Providers: ${[...providers.keys()].join(", ")}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
124
src/providers/fal.ts
Normal file
124
src/providers/fal.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { ImageProvider, ImageModel, GenerateImageRequest, GenerateImageResult } from "../types.js";
|
||||
|
||||
const API_BASE = "https://queue.fal.run";
|
||||
|
||||
const FAL_IMAGE_MODELS: ImageModel[] = [
|
||||
{
|
||||
id: "fal-ai/flux/dev",
|
||||
name: "FLUX.1 Dev",
|
||||
provider: "fal",
|
||||
description: "FLUX.1 development model on fal.ai",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["image"],
|
||||
modalities: ["image"],
|
||||
},
|
||||
{
|
||||
id: "fal-ai/flux/schnell",
|
||||
name: "FLUX.1 Schnell",
|
||||
provider: "fal",
|
||||
description: "Fast FLUX model on fal.ai",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["image"],
|
||||
modalities: ["image"],
|
||||
},
|
||||
{
|
||||
id: "fal-ai/flux-2-pro",
|
||||
name: "FLUX 2 Pro",
|
||||
provider: "fal",
|
||||
description: "Professional FLUX 2 model",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["image"],
|
||||
modalities: ["image"],
|
||||
},
|
||||
{
|
||||
id: "fal-ai/recraft-v3",
|
||||
name: "Recraft V3",
|
||||
provider: "fal",
|
||||
description: "High quality image generation",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["image"],
|
||||
modalities: ["image"],
|
||||
},
|
||||
];
|
||||
|
||||
export class FalProvider implements ImageProvider {
|
||||
name = "fal";
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async listModels(): Promise<ImageModel[]> {
|
||||
return FAL_IMAGE_MODELS;
|
||||
}
|
||||
|
||||
private parseImageSize(aspectRatio?: string): string {
|
||||
const sizeMap: Record<string, string> = {
|
||||
"1:1": "square_hd",
|
||||
"16:9": "landscape_16_9",
|
||||
"9:16": "portrait_16_9",
|
||||
"4:3": "landscape_4_3",
|
||||
"3:4": "portrait_4_3",
|
||||
};
|
||||
return sizeMap[aspectRatio ?? "1:1"] ?? "square_hd";
|
||||
}
|
||||
|
||||
async generateImage(request: GenerateImageRequest): Promise<GenerateImageResult> {
|
||||
const model = request.model ?? "fal-ai/flux/schnell";
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
prompt: request.prompt,
|
||||
image_size: this.parseImageSize(request.aspectRatio),
|
||||
num_images: request.n ?? 1,
|
||||
output_format: "png",
|
||||
sync_mode: true,
|
||||
};
|
||||
|
||||
if (request.negativePrompt) body.negative_prompt = request.negativePrompt;
|
||||
if (request.steps) body.num_inference_steps = request.steps;
|
||||
if (request.seed != null) body.seed = request.seed;
|
||||
|
||||
const res = await fetch(`${API_BASE}/${model}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Key ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`fal.ai error: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
images: Array<{
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
content_type?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Download images and convert to base64
|
||||
const images = await Promise.all(
|
||||
data.images.map(async (img) => {
|
||||
const imgRes = await fetch(img.url);
|
||||
const arrayBuf = await imgRes.arrayBuffer();
|
||||
const base64 = Buffer.from(arrayBuf).toString("base64");
|
||||
return {
|
||||
base64,
|
||||
mimeType: img.content_type ?? "image/png",
|
||||
url: img.url,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
images,
|
||||
model,
|
||||
provider: "fal",
|
||||
};
|
||||
}
|
||||
}
|
||||
43
src/providers/index.ts
Normal file
43
src/providers/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ImageProvider } from "../types.js";
|
||||
import { OpenRouterProvider } from "./openrouter.js";
|
||||
import { TogetherProvider } from "./together.js";
|
||||
import { ReplicateProvider } from "./replicate.js";
|
||||
import { FalProvider } from "./fal.js";
|
||||
|
||||
export function createProviders(): Map<string, ImageProvider> {
|
||||
const providers = new Map<string, ImageProvider>();
|
||||
|
||||
const openrouterKey = process.env["OPENROUTER_API_KEY"];
|
||||
if (openrouterKey) {
|
||||
providers.set("openrouter", new OpenRouterProvider(openrouterKey));
|
||||
}
|
||||
|
||||
const togetherKey = process.env["TOGETHER_API_KEY"];
|
||||
if (togetherKey) {
|
||||
providers.set("together", new TogetherProvider(togetherKey));
|
||||
}
|
||||
|
||||
const replicateKey = process.env["REPLICATE_API_TOKEN"];
|
||||
if (replicateKey) {
|
||||
providers.set("replicate", new ReplicateProvider(replicateKey));
|
||||
}
|
||||
|
||||
const falKey = process.env["FAL_KEY"];
|
||||
if (falKey) {
|
||||
providers.set("fal", new FalProvider(falKey));
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
export function getDefaultProvider(providers: Map<string, ImageProvider>): ImageProvider | undefined {
|
||||
// Priority: openrouter > together > fal > replicate
|
||||
return (
|
||||
providers.get("openrouter") ??
|
||||
providers.get("together") ??
|
||||
providers.get("fal") ??
|
||||
providers.get("replicate")
|
||||
);
|
||||
}
|
||||
|
||||
export { OpenRouterProvider, TogetherProvider, ReplicateProvider, FalProvider };
|
||||
138
src/providers/openrouter.ts
Normal file
138
src/providers/openrouter.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { ImageProvider, ImageModel, GenerateImageRequest, GenerateImageResult } from "../types.js";
|
||||
|
||||
const API_BASE = "https://openrouter.ai/api/v1";
|
||||
|
||||
export class OpenRouterProvider implements ImageProvider {
|
||||
name = "openrouter";
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://github.com/mcp-image-generator",
|
||||
"X-Title": "MCP Image Generator",
|
||||
};
|
||||
}
|
||||
|
||||
async listModels(): Promise<ImageModel[]> {
|
||||
const res = await fetch(`${API_BASE}/models?output_modalities=image`, {
|
||||
headers: this.headers(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`OpenRouter models API error: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
data: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
pricing?: { prompt?: string; completion?: string; image?: string };
|
||||
architecture?: {
|
||||
input_modalities?: string[];
|
||||
output_modalities?: string[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
return data.data.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
provider: "openrouter",
|
||||
description: m.description,
|
||||
pricing: m.pricing?.image ?? m.pricing?.completion,
|
||||
inputModalities: m.architecture?.input_modalities ?? ["text"],
|
||||
outputModalities: m.architecture?.output_modalities ?? ["image"],
|
||||
modalities: this.getModalities(m.architecture?.output_modalities ?? ["image"]),
|
||||
supportedAspectRatios: [
|
||||
"1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "21:9", "9:21",
|
||||
],
|
||||
supportedSizes: ["1K", "2K", "4K"],
|
||||
}));
|
||||
}
|
||||
|
||||
private getModalities(outputModalities: string[]): string[] {
|
||||
if (outputModalities.includes("text") && outputModalities.includes("image")) {
|
||||
return ["image", "text"];
|
||||
}
|
||||
return ["image"];
|
||||
}
|
||||
|
||||
async generateImage(request: GenerateImageRequest): Promise<GenerateImageResult> {
|
||||
const model = request.model ?? "google/gemini-2.5-flash-image";
|
||||
|
||||
// Determine modalities based on model
|
||||
const isImageOnly = model.includes("flux") || model.includes("sourceful") || model.includes("riverflow");
|
||||
const modalities = isImageOnly ? ["image"] : ["image", "text"];
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
modalities,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: request.prompt,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const imageConfig: Record<string, unknown> = {};
|
||||
if (request.aspectRatio) imageConfig.aspect_ratio = request.aspectRatio;
|
||||
if (request.imageSize) imageConfig.image_size = request.imageSize;
|
||||
if (Object.keys(imageConfig).length > 0) body.image_config = imageConfig;
|
||||
|
||||
const res = await fetch(`${API_BASE}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`OpenRouter generation error: ${res.status} ${errText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
model: string;
|
||||
choices: Array<{
|
||||
message: {
|
||||
content?: string;
|
||||
images?: Array<{ image_url: { url: string } }>;
|
||||
};
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
total_tokens?: number;
|
||||
cost?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const message = data.choices[0]?.message;
|
||||
const images =
|
||||
message?.images?.map((img) => {
|
||||
const url = img.image_url.url;
|
||||
// Strip data URL prefix to get raw base64
|
||||
const base64 = url.replace(/^data:image\/\w+;base64,/, "");
|
||||
const mimeMatch = url.match(/^data:(image\/\w+);base64,/);
|
||||
const mimeType = mimeMatch?.[1] ?? "image/png";
|
||||
return { base64, mimeType, url };
|
||||
}) ?? [];
|
||||
|
||||
return {
|
||||
images,
|
||||
model: data.model,
|
||||
provider: "openrouter",
|
||||
usage: {
|
||||
promptTokens: data.usage?.prompt_tokens,
|
||||
totalTokens: data.usage?.total_tokens,
|
||||
cost: data.usage?.cost,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
126
src/providers/replicate.ts
Normal file
126
src/providers/replicate.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { ImageProvider, ImageModel, GenerateImageRequest, GenerateImageResult } from "../types.js";
|
||||
|
||||
const API_BASE = "https://api.replicate.com/v1";
|
||||
|
||||
const REPLICATE_IMAGE_MODELS: ImageModel[] = [
|
||||
{
|
||||
id: "black-forest-labs/flux-schnell",
|
||||
name: "FLUX Schnell",
|
||||
provider: "replicate",
|
||||
description: "Fast, high-quality image generation",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["image"],
|
||||
modalities: ["image"],
|
||||
},
|
||||
{
|
||||
id: "black-forest-labs/flux-1.1-pro",
|
||||
name: "FLUX 1.1 Pro",
|
||||
provider: "replicate",
|
||||
description: "Professional quality FLUX model",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["image"],
|
||||
modalities: ["image"],
|
||||
},
|
||||
{
|
||||
id: "stability-ai/sdxl",
|
||||
name: "Stable Diffusion XL",
|
||||
provider: "replicate",
|
||||
description: "Stable Diffusion XL on Replicate",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["image"],
|
||||
modalities: ["image"],
|
||||
},
|
||||
{
|
||||
id: "ideogram-ai/ideogram-v2-turbo",
|
||||
name: "Ideogram V2 Turbo",
|
||||
provider: "replicate",
|
||||
description: "Excellent text rendering in images",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["image"],
|
||||
modalities: ["image"],
|
||||
},
|
||||
];
|
||||
|
||||
export class ReplicateProvider implements ImageProvider {
|
||||
name = "replicate";
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async listModels(): Promise<ImageModel[]> {
|
||||
return REPLICATE_IMAGE_MODELS;
|
||||
}
|
||||
|
||||
async generateImage(request: GenerateImageRequest): Promise<GenerateImageResult> {
|
||||
const model = request.model ?? "black-forest-labs/flux-schnell";
|
||||
|
||||
const input: Record<string, unknown> = {
|
||||
prompt: request.prompt,
|
||||
};
|
||||
|
||||
if (request.aspectRatio) input.aspect_ratio = request.aspectRatio;
|
||||
if (request.negativePrompt) input.negative_prompt = request.negativePrompt;
|
||||
if (request.steps) input.num_inference_steps = request.steps;
|
||||
if (request.seed != null) input.seed = request.seed;
|
||||
if (request.n) input.num_outputs = request.n;
|
||||
|
||||
// Create prediction
|
||||
const createRes = await fetch(`${API_BASE}/models/${model}/predictions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "wait=60",
|
||||
},
|
||||
body: JSON.stringify({ input }),
|
||||
});
|
||||
|
||||
if (!createRes.ok) {
|
||||
throw new Error(`Replicate error: ${createRes.status} ${await createRes.text()}`);
|
||||
}
|
||||
|
||||
let prediction = (await createRes.json()) as {
|
||||
id: string;
|
||||
status: string;
|
||||
output?: string[];
|
||||
error?: string;
|
||||
urls?: { get?: string };
|
||||
};
|
||||
|
||||
// Poll if not yet complete
|
||||
if (prediction.status !== "succeeded" && prediction.status !== "failed") {
|
||||
const getUrl = prediction.urls?.get ?? `${API_BASE}/predictions/${prediction.id}`;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const pollRes = await fetch(getUrl, {
|
||||
headers: { Authorization: `Bearer ${this.apiKey}` },
|
||||
});
|
||||
prediction = (await pollRes.json()) as typeof prediction;
|
||||
if (prediction.status === "succeeded" || prediction.status === "failed") break;
|
||||
}
|
||||
}
|
||||
|
||||
if (prediction.status === "failed") {
|
||||
throw new Error(`Replicate prediction failed: ${prediction.error}`);
|
||||
}
|
||||
|
||||
// Download images and convert to base64
|
||||
const images = await Promise.all(
|
||||
(prediction.output ?? []).map(async (url) => {
|
||||
const imgRes = await fetch(url);
|
||||
const arrayBuf = await imgRes.arrayBuffer();
|
||||
const base64 = Buffer.from(arrayBuf).toString("base64");
|
||||
const contentType = imgRes.headers.get("content-type") ?? "image/webp";
|
||||
return { base64, mimeType: contentType, url };
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
images,
|
||||
model,
|
||||
provider: "replicate",
|
||||
};
|
||||
}
|
||||
}
|
||||
124
src/providers/together.ts
Normal file
124
src/providers/together.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { ImageProvider, ImageModel, GenerateImageRequest, GenerateImageResult } from "../types.js";
|
||||
|
||||
const API_BASE = "https://api.together.xyz/v1";
|
||||
|
||||
const TOGETHER_IMAGE_MODELS: ImageModel[] = [
|
||||
{
|
||||
id: "black-forest-labs/FLUX.1-schnell",
|
||||
name: "FLUX.1 Schnell",
|
||||
provider: "together",
|
||||
description: "Fast image generation (free tier available)",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["image"],
|
||||
modalities: ["image"],
|
||||
},
|
||||
{
|
||||
id: "black-forest-labs/FLUX.1-dev",
|
||||
name: "FLUX.1 Dev",
|
||||
provider: "together",
|
||||
description: "Development quality FLUX model",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["image"],
|
||||
modalities: ["image"],
|
||||
},
|
||||
{
|
||||
id: "black-forest-labs/FLUX.1.1-pro",
|
||||
name: "FLUX 1.1 Pro",
|
||||
provider: "together",
|
||||
description: "Professional quality FLUX model",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["image"],
|
||||
modalities: ["image"],
|
||||
},
|
||||
{
|
||||
id: "stabilityai/stable-diffusion-xl-base-1.0",
|
||||
name: "Stable Diffusion XL",
|
||||
provider: "together",
|
||||
description: "Stable Diffusion XL base model",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["image"],
|
||||
modalities: ["image"],
|
||||
},
|
||||
];
|
||||
|
||||
export class TogetherProvider implements ImageProvider {
|
||||
name = "together";
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async listModels(): Promise<ImageModel[]> {
|
||||
return TOGETHER_IMAGE_MODELS;
|
||||
}
|
||||
|
||||
private parseSize(aspectRatio?: string, imageSize?: string): { width: number; height: number } {
|
||||
const baseSize = imageSize === "2K" ? 2048 : imageSize === "4K" ? 2048 : 1024;
|
||||
|
||||
if (!aspectRatio || aspectRatio === "1:1") {
|
||||
return { width: baseSize, height: baseSize };
|
||||
}
|
||||
|
||||
const ratioMap: Record<string, [number, number]> = {
|
||||
"16:9": [1344, 768],
|
||||
"9:16": [768, 1344],
|
||||
"4:3": [1152, 896],
|
||||
"3:4": [896, 1152],
|
||||
"3:2": [1216, 832],
|
||||
"2:3": [832, 1216],
|
||||
};
|
||||
|
||||
const dims = ratioMap[aspectRatio];
|
||||
if (dims) return { width: dims[0], height: dims[1] };
|
||||
return { width: baseSize, height: baseSize };
|
||||
}
|
||||
|
||||
async generateImage(request: GenerateImageRequest): Promise<GenerateImageResult> {
|
||||
const model = request.model ?? "black-forest-labs/FLUX.1-schnell";
|
||||
const { width, height } = this.parseSize(request.aspectRatio, request.imageSize);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
prompt: request.prompt,
|
||||
width,
|
||||
height,
|
||||
n: request.n ?? 1,
|
||||
response_format: "b64_json",
|
||||
};
|
||||
|
||||
if (request.negativePrompt) body.negative_prompt = request.negativePrompt;
|
||||
if (request.steps) body.steps = request.steps;
|
||||
if (request.seed != null) body.seed = request.seed;
|
||||
|
||||
const res = await fetch(`${API_BASE}/images/generations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Together AI error: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
model: string;
|
||||
data: Array<{ b64_json?: string; url?: string }>;
|
||||
};
|
||||
|
||||
const images = data.data.map((d) => ({
|
||||
base64: d.b64_json ?? "",
|
||||
mimeType: "image/png" as const,
|
||||
url: d.url,
|
||||
}));
|
||||
|
||||
return {
|
||||
images,
|
||||
model: data.model ?? model,
|
||||
provider: "together",
|
||||
};
|
||||
}
|
||||
}
|
||||
47
src/types.ts
Normal file
47
src/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export interface ImageModel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
description?: string;
|
||||
pricing?: string;
|
||||
inputModalities: string[];
|
||||
outputModalities: string[];
|
||||
modalities: string[];
|
||||
supportedAspectRatios?: string[];
|
||||
supportedSizes?: string[];
|
||||
}
|
||||
|
||||
export interface GenerateImageRequest {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
negativePrompt?: string;
|
||||
steps?: number;
|
||||
seed?: number;
|
||||
n?: number;
|
||||
}
|
||||
|
||||
export interface GenerateImageResult {
|
||||
images: GeneratedImage[];
|
||||
model: string;
|
||||
provider: string;
|
||||
usage?: {
|
||||
promptTokens?: number;
|
||||
totalTokens?: number;
|
||||
cost?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GeneratedImage {
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface ImageProvider {
|
||||
name: string;
|
||||
listModels(): Promise<ImageModel[]>;
|
||||
generateImage(request: GenerateImageRequest): Promise<GenerateImageResult>;
|
||||
}
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"module": "nodenext",
|
||||
"target": "es2022",
|
||||
"types": ["node"],
|
||||
"lib": ["es2022"],
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "nodenext",
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user