@offlinecreator/mcp
Advanced tools
| export type OAuthBridgeIdentity = { | ||
| userId: string; | ||
| scopes: string[]; | ||
| }; | ||
| export declare function signOAuthBridgeRequest(input: { | ||
| request: Request; | ||
| secret: string; | ||
| identity: OAuthBridgeIdentity; | ||
| nowSeconds?: number; | ||
| }): Promise<{ | ||
| "x-oc-oauth-user": string; | ||
| "x-oc-oauth-scopes": string; | ||
| "x-oc-oauth-timestamp": string; | ||
| "x-oc-oauth-signature": string; | ||
| }>; | ||
| export declare function verifyOAuthBridgeRequest(input: { | ||
| request: Request; | ||
| secret: string; | ||
| nowSeconds?: number; | ||
| maxClockSkewSeconds?: number; | ||
| }): Promise<OAuthBridgeIdentity | null>; | ||
| export declare function hasOAuthBridgeHeaders(request: Request): boolean; |
| const OAUTH_USER_HEADER = "x-oc-oauth-user"; | ||
| const OAUTH_SCOPES_HEADER = "x-oc-oauth-scopes"; | ||
| const OAUTH_TIMESTAMP_HEADER = "x-oc-oauth-timestamp"; | ||
| const OAUTH_SIGNATURE_HEADER = "x-oc-oauth-signature"; | ||
| const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; | ||
| const ALLOWED_SCOPES = new Set(["models", "read", "generate"]); | ||
| function base64Url(bytes) { | ||
| let binary = ""; | ||
| for (const byte of bytes) | ||
| binary += String.fromCharCode(byte); | ||
| return btoa(binary) | ||
| .replace(/\+/g, "-") | ||
| .replace(/\//g, "_") | ||
| .replace(/=+$/, ""); | ||
| } | ||
| function fromBase64Url(value) { | ||
| try { | ||
| const padded = value.replace(/-/g, "+").replace(/_/g, "/") | ||
| + "=".repeat((4 - (value.length % 4)) % 4); | ||
| const binary = atob(padded); | ||
| return Uint8Array.from(binary, (character) => character.charCodeAt(0)); | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| } | ||
| async function importHmacKey(secret) { | ||
| if (new TextEncoder().encode(secret).byteLength < 32) { | ||
| throw new Error("MCP_OAUTH_SERVICE_SECRET must contain at least 32 bytes."); | ||
| } | ||
| return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); | ||
| } | ||
| async function bodyDigest(request) { | ||
| const bytes = await request.clone().arrayBuffer(); | ||
| return base64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))); | ||
| } | ||
| function normalizeScopes(scopes) { | ||
| return [...new Set(scopes)] | ||
| .filter((scope) => ALLOWED_SCOPES.has(scope)) | ||
| .sort(); | ||
| } | ||
| async function canonicalRequest(request, identity, timestamp) { | ||
| const url = new URL(request.url); | ||
| return [ | ||
| request.method.toUpperCase(), | ||
| `${url.pathname}${url.search}`, | ||
| identity.userId, | ||
| normalizeScopes(identity.scopes).join(" "), | ||
| timestamp, | ||
| await bodyDigest(request), | ||
| ].join("\n"); | ||
| } | ||
| export async function signOAuthBridgeRequest(input) { | ||
| if (!UUID_RE.test(input.identity.userId)) { | ||
| throw new Error("OAuth bridge userId must be a UUID."); | ||
| } | ||
| const identity = { | ||
| userId: input.identity.userId, | ||
| scopes: normalizeScopes(input.identity.scopes), | ||
| }; | ||
| const timestamp = String(Math.floor(input.nowSeconds ?? Date.now() / 1000)); | ||
| const key = await importHmacKey(input.secret); | ||
| const canonical = await canonicalRequest(input.request, identity, timestamp); | ||
| const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(canonical)); | ||
| return { | ||
| [OAUTH_USER_HEADER]: identity.userId, | ||
| [OAUTH_SCOPES_HEADER]: identity.scopes.join(" "), | ||
| [OAUTH_TIMESTAMP_HEADER]: timestamp, | ||
| [OAUTH_SIGNATURE_HEADER]: base64Url(new Uint8Array(signature)), | ||
| }; | ||
| } | ||
| export async function verifyOAuthBridgeRequest(input) { | ||
| const userId = input.request.headers.get(OAUTH_USER_HEADER)?.trim() ?? ""; | ||
| const scopeValue = input.request.headers.get(OAUTH_SCOPES_HEADER)?.trim() ?? ""; | ||
| const timestamp = input.request.headers.get(OAUTH_TIMESTAMP_HEADER)?.trim() ?? ""; | ||
| const signatureValue = input.request.headers.get(OAUTH_SIGNATURE_HEADER)?.trim() ?? ""; | ||
| if (!UUID_RE.test(userId) || !timestamp || !signatureValue) | ||
| return null; | ||
| const issuedAt = Number(timestamp); | ||
| const now = Math.floor(input.nowSeconds ?? Date.now() / 1000); | ||
| const skew = input.maxClockSkewSeconds ?? 60; | ||
| if (!Number.isInteger(issuedAt) | ||
| || Math.abs(now - issuedAt) > skew) { | ||
| return null; | ||
| } | ||
| const requestedScopes = scopeValue ? scopeValue.split(/\s+/) : []; | ||
| const scopes = normalizeScopes(requestedScopes); | ||
| if (scopes.length !== requestedScopes.length | ||
| || scopes.join(" ") !== requestedScopes.join(" ")) { | ||
| return null; | ||
| } | ||
| const signature = fromBase64Url(signatureValue); | ||
| if (!signature) | ||
| return null; | ||
| const key = await importHmacKey(input.secret); | ||
| const canonical = await canonicalRequest(input.request, { userId, scopes }, timestamp); | ||
| const valid = await crypto.subtle.verify("HMAC", key, signature, new TextEncoder().encode(canonical)); | ||
| return valid ? { userId, scopes } : null; | ||
| } | ||
| export function hasOAuthBridgeHeaders(request) { | ||
| return request.headers.has(OAUTH_USER_HEADER) | ||
| || request.headers.has(OAUTH_SIGNATURE_HEADER); | ||
| } |
| export declare const MCP_MAX_INPUT_BYTES: number; | ||
| export type ImageContentType = "image/png" | "image/jpeg" | "image/webp" | "image/gif"; | ||
| export type SafeImageData = { | ||
| bytes: Buffer; | ||
| contentType: ImageContentType; | ||
| resolvedPath: string; | ||
| }; | ||
| export declare function decodeSafeImageBase64(input: { | ||
| imageBase64: string; | ||
| maxBytes?: number; | ||
| contentType?: ImageContentType; | ||
| }): SafeImageData; | ||
| export declare function sniffImageType(bytes: Buffer): ImageContentType | null; |
| export const MCP_MAX_INPUT_BYTES = 10 * 1024 * 1024; | ||
| const IMAGE_SIGNATURES = [ | ||
| { | ||
| contentType: "image/png", | ||
| match: (header) => header.length >= 8 && | ||
| header[0] === 0x89 && | ||
| header[1] === 0x50 && | ||
| header[2] === 0x4e && | ||
| header[3] === 0x47, | ||
| }, | ||
| { | ||
| contentType: "image/jpeg", | ||
| match: (header) => header.length >= 3 && | ||
| header[0] === 0xff && | ||
| header[1] === 0xd8 && | ||
| header[2] === 0xff, | ||
| }, | ||
| { | ||
| contentType: "image/gif", | ||
| match: (header) => { | ||
| if (header.length < 6) | ||
| return false; | ||
| const tag = header.subarray(0, 6).toString("ascii"); | ||
| return tag === "GIF87a" || tag === "GIF89a"; | ||
| }, | ||
| }, | ||
| { | ||
| contentType: "image/webp", | ||
| match: (header) => header.length >= 12 && | ||
| header.subarray(0, 4).toString("ascii") === "RIFF" && | ||
| header.subarray(8, 12).toString("ascii") === "WEBP", | ||
| }, | ||
| ]; | ||
| export function decodeSafeImageBase64(input) { | ||
| const maxBytes = input.maxBytes ?? MCP_MAX_INPUT_BYTES; | ||
| const raw = input.imageBase64.includes(",") | ||
| ? input.imageBase64.split(",")[1] | ||
| : input.imageBase64; | ||
| if (raw.length > Math.ceil(maxBytes * 1.4) + 64) { | ||
| throw new Error("imageBase64 exceeds the upload size limit."); | ||
| } | ||
| const bytes = Buffer.from(raw, "base64"); | ||
| if (bytes.byteLength <= 0) { | ||
| throw new Error("imageBase64 decoded to an empty buffer."); | ||
| } | ||
| if (bytes.byteLength > maxBytes) { | ||
| throw new Error(`Image exceeds the ${maxBytes} byte upload limit.`); | ||
| } | ||
| const sniffed = sniffImageType(bytes); | ||
| if (!sniffed) { | ||
| throw new Error("imageBase64 is not a recognized image."); | ||
| } | ||
| if (input.contentType && input.contentType !== sniffed) { | ||
| throw new Error("contentType does not match image bytes."); | ||
| } | ||
| return { | ||
| bytes, | ||
| contentType: sniffed, | ||
| resolvedPath: "(base64)", | ||
| }; | ||
| } | ||
| export function sniffImageType(bytes) { | ||
| const header = bytes.subarray(0, 16); | ||
| for (const signature of IMAGE_SIGNATURES) { | ||
| if (signature.match(header)) | ||
| return signature.contentType; | ||
| } | ||
| return null; | ||
| } |
+15
-5
@@ -1,2 +0,2 @@ | ||
| import { decodeSafeImageBase64, MCP_MAX_INPUT_BYTES, readSafeLocalImage, } from "./safe-fs.js"; | ||
| import { decodeSafeImageBase64, MCP_MAX_INPUT_BYTES, } from "./safe-image.js"; | ||
| const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; | ||
@@ -80,3 +80,6 @@ export class StudioApiError extends Error { | ||
| if (input.filePath) { | ||
| image = await readSafeLocalImage({ | ||
| if (!this.config.readLocalImage) { | ||
| throw new Error("Local filePath uploads are disabled for remote MCP. Send imageBase64 instead."); | ||
| } | ||
| image = await this.config.readLocalImage({ | ||
| filePath: input.filePath, | ||
@@ -138,3 +141,4 @@ maxBytes: MCP_MAX_INPUT_BYTES, | ||
| catch { | ||
| const { outputUrl: _omitted, ...rest } = payload; | ||
| const rest = { ...payload }; | ||
| delete rest.outputUrl; | ||
| return { | ||
@@ -151,5 +155,11 @@ ...rest, | ||
| const headers = new Headers(init.headers); | ||
| headers.set("authorization", `Bearer ${this.config.apiKey}`); | ||
| if (this.config.apiKey) { | ||
| headers.set("authorization", `Bearer ${this.config.apiKey}`); | ||
| } | ||
| headers.set("accept", "application/json"); | ||
| const response = await fetch(`${this.config.apiBase}${path}`, { | ||
| if (!this.config.apiKey && !this.config.fetcher) { | ||
| throw new Error("Studio API authentication is not configured."); | ||
| } | ||
| const fetcher = this.config.fetcher ?? fetch; | ||
| const response = await fetcher(`${this.config.apiBase}${path}`, { | ||
| ...init, | ||
@@ -156,0 +166,0 @@ headers, |
+10
-1
@@ -0,8 +1,17 @@ | ||
| import type { SafeImageData } from "./safe-image.js"; | ||
| export type McpConfig = { | ||
| apiKey: string; | ||
| apiKey?: string; | ||
| apiBase: string; | ||
| /** Optional transport override for trusted Worker service bindings. */ | ||
| fetcher?: typeof fetch; | ||
| /** Local filesystem root for upload_input filePath (stdio only). */ | ||
| uploadRoot?: string; | ||
| /** Injected only by the stdio entrypoint; omitted by remote Workers. */ | ||
| readLocalImage?: (input: { | ||
| filePath: string; | ||
| maxBytes?: number; | ||
| rootDir?: string; | ||
| }) => Promise<SafeImageData>; | ||
| }; | ||
| export declare function loadConfig(env?: NodeJS.ProcessEnv): McpConfig; | ||
| export declare function assertTrustedApiBase(apiBase: string, env?: NodeJS.ProcessEnv): string; |
+5
-1
@@ -5,2 +5,3 @@ #!/usr/bin/env node | ||
| import { loadConfig } from "./config.js"; | ||
| import { readSafeLocalImage } from "./safe-fs.js"; | ||
| import { createOfflineCreatorServer } from "./server.js"; | ||
@@ -23,3 +24,6 @@ async function main() { | ||
| } | ||
| const config = loadConfig(); | ||
| const config = { | ||
| ...loadConfig(), | ||
| readLocalImage: readSafeLocalImage, | ||
| }; | ||
| // MCP clients speak over stdin/stdout — keep logs off stdout. | ||
@@ -26,0 +30,0 @@ console.error(`offlinecreator-mcp: connected to ${config.apiBase} (stdio MCP)`); |
+3
-12
@@ -1,7 +0,4 @@ | ||
| export declare const MCP_MAX_INPUT_BYTES: number; | ||
| export type SafeImageRead = { | ||
| bytes: Buffer; | ||
| contentType: "image/png" | "image/jpeg" | "image/webp" | "image/gif"; | ||
| resolvedPath: string; | ||
| }; | ||
| import { type SafeImageData } from "./safe-image.js"; | ||
| export { decodeSafeImageBase64, MCP_MAX_INPUT_BYTES, sniffImageType, } from "./safe-image.js"; | ||
| export type SafeImageRead = SafeImageData; | ||
| /** | ||
@@ -19,8 +16,2 @@ * Read a local image for MCP upload_input with hard limits: | ||
| }): Promise<SafeImageRead>; | ||
| export declare function decodeSafeImageBase64(input: { | ||
| imageBase64: string; | ||
| maxBytes?: number; | ||
| contentType?: "image/png" | "image/jpeg" | "image/webp" | "image/gif"; | ||
| }): SafeImageRead; | ||
| export declare function sniffImageType(bytes: Buffer): "image/png" | "image/jpeg" | "image/webp" | "image/gif" | null; | ||
| export declare function isPathInsideRoot(resolvedPath: string, rootDir: string): boolean; |
+2
-71
| import { open } from "node:fs/promises"; | ||
| import path from "node:path"; | ||
| import { MCP_MAX_INPUT_BYTES, sniffImageType, } from "./safe-image.js"; | ||
| export { decodeSafeImageBase64, MCP_MAX_INPUT_BYTES, sniffImageType, } from "./safe-image.js"; | ||
| const ALLOWED_EXTENSIONS = new Set([ | ||
@@ -10,36 +12,2 @@ ".png", | ||
| ]); | ||
| export const MCP_MAX_INPUT_BYTES = 10 * 1024 * 1024; | ||
| /** Magic-byte sniffers for allowed image types. */ | ||
| const IMAGE_SIGNATURES = [ | ||
| { | ||
| contentType: "image/png", | ||
| match: (header) => header.length >= 8 && | ||
| header[0] === 0x89 && | ||
| header[1] === 0x50 && | ||
| header[2] === 0x4e && | ||
| header[3] === 0x47, | ||
| }, | ||
| { | ||
| contentType: "image/jpeg", | ||
| match: (header) => header.length >= 3 && | ||
| header[0] === 0xff && | ||
| header[1] === 0xd8 && | ||
| header[2] === 0xff, | ||
| }, | ||
| { | ||
| contentType: "image/gif", | ||
| match: (header) => { | ||
| if (header.length < 6) | ||
| return false; | ||
| const tag = header.subarray(0, 6).toString("ascii"); | ||
| return tag === "GIF87a" || tag === "GIF89a"; | ||
| }, | ||
| }, | ||
| { | ||
| contentType: "image/webp", | ||
| match: (header) => header.length >= 12 && | ||
| header.subarray(0, 4).toString("ascii") === "RIFF" && | ||
| header.subarray(8, 12).toString("ascii") === "WEBP", | ||
| }, | ||
| ]; | ||
| /** | ||
@@ -93,39 +61,2 @@ * Read a local image for MCP upload_input with hard limits: | ||
| } | ||
| export function decodeSafeImageBase64(input) { | ||
| const maxBytes = input.maxBytes ?? MCP_MAX_INPUT_BYTES; | ||
| // Rough base64 expansion guard before allocation. | ||
| const raw = input.imageBase64.includes(",") | ||
| ? input.imageBase64.split(",")[1] | ||
| : input.imageBase64; | ||
| if (raw.length > Math.ceil(maxBytes * 1.4) + 64) { | ||
| throw new Error("imageBase64 exceeds the upload size limit."); | ||
| } | ||
| const bytes = Buffer.from(raw, "base64"); | ||
| if (bytes.byteLength <= 0) { | ||
| throw new Error("imageBase64 decoded to an empty buffer."); | ||
| } | ||
| if (bytes.byteLength > maxBytes) { | ||
| throw new Error(`Image exceeds the ${maxBytes} byte upload limit.`); | ||
| } | ||
| const sniffed = sniffImageType(bytes); | ||
| if (!sniffed) { | ||
| throw new Error("imageBase64 is not a recognized image."); | ||
| } | ||
| if (input.contentType && input.contentType !== sniffed) { | ||
| throw new Error("contentType does not match image bytes."); | ||
| } | ||
| return { | ||
| bytes, | ||
| contentType: sniffed, | ||
| resolvedPath: "(base64)", | ||
| }; | ||
| } | ||
| export function sniffImageType(bytes) { | ||
| const header = bytes.subarray(0, 16); | ||
| for (const signature of IMAGE_SIGNATURES) { | ||
| if (signature.match(header)) | ||
| return signature.contentType; | ||
| } | ||
| return null; | ||
| } | ||
| export function isPathInsideRoot(resolvedPath, rootDir) { | ||
@@ -132,0 +63,0 @@ const root = path.resolve(rootDir); |
+7
-3
| { | ||
| "name": "@offlinecreator/mcp", | ||
| "version": "0.1.0", | ||
| "version": "0.1.1", | ||
| "description": "OfflineCreator Studio MCP server for Cursor, Claude, Hermes, and other MCP clients", | ||
@@ -23,2 +23,6 @@ "type": "module", | ||
| "import": "./dist/config.js" | ||
| }, | ||
| "./oauth-bridge": { | ||
| "types": "./dist/oauth-bridge.d.ts", | ||
| "import": "./dist/oauth-bridge.js" | ||
| } | ||
@@ -36,3 +40,3 @@ }, | ||
| "start": "node dist/index.js", | ||
| "test": "npm run build && node --test dist/config.test.js dist/safe-fs.test.js", | ||
| "test": "npm run build && node --test dist/config.test.js dist/oauth-bridge.test.js dist/safe-fs.test.js", | ||
| "prepublishOnly": "npm run build && npm test" | ||
@@ -52,3 +56,3 @@ }, | ||
| "license": "MIT", | ||
| "homepage": "https://offlinecreatorstudio.com/b", | ||
| "homepage": "https://offlinecreatorstudio.com/mcp-cli", | ||
| "publishConfig": { | ||
@@ -55,0 +59,0 @@ "access": "public" |
+104
-15
@@ -5,10 +5,18 @@ # @offlinecreator/mcp | ||
| Gives Cursor, Claude Desktop, Claude Code, Hermes, Windsurf, and other MCP clients tools to list models, check credits, generate images/video, and wait for results — using your Studio API key. | ||
| Gives Cursor, Claude Desktop, Claude Code, Hermes, Windsurf, and other MCP clients tools to list models, check credits, generate images/video, and wait for results. | ||
| **Current release:** [`@offlinecreator/mcp@0.1.1`](https://www.npmjs.com/package/@offlinecreator/mcp) | ||
| **Recommended remote MCP:** `https://mcp.offlinecreatorstudio.com/mcp` (Streamable HTTP + OAuth 2.1) | ||
| **API-key fallback:** local stdio or `https://offlinecreatorstudio.com/mcp` | ||
| ## Setup | ||
| 1. Sign in to Studio and open **Settings** (`/settings`). | ||
| 2. Create an API key and copy the secret once (`oc_live_…` or `oc_test_…`). | ||
| 3. Add the server to your MCP client (snippets below). | ||
| Add the recommended URL to your MCP client, then complete Studio browser | ||
| sign-in and consent. No API key is stored in the client configuration. | ||
| For local stdio or legacy remote fallback, create a scoped key in **Settings** | ||
| and copy the secret once (`oc_live_…` or `oc_test_…`). | ||
| ### Environment | ||
@@ -34,3 +42,3 @@ | ||
| Add to MCP settings (stdio): | ||
| Recommended remote OAuth: | ||
@@ -41,2 +49,14 @@ ```json | ||
| "offlinecreator": { | ||
| "url": "https://mcp.offlinecreatorstudio.com/mcp" | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| API-key stdio fallback: | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "offlinecreator": { | ||
| "command": "npx", | ||
@@ -52,4 +72,6 @@ "args": ["-y", "@offlinecreator/mcp"], | ||
| Local monorepo (before npm publish): | ||
| Never put the key in the URL or use `?api_key=`. | ||
| Local monorepo development: | ||
| ```json | ||
@@ -60,5 +82,5 @@ { | ||
| "command": "node", | ||
| "args": ["C:/Users/YOU/Desktop/mcp_tool/packages/mcp/dist/index.js"], | ||
| "args": ["C:/path/to/mcp_tool/packages/mcp/dist/index.js"], | ||
| "env": { | ||
| "OFFLINECREATOR_API_KEY": "oc_live_…", | ||
| "OFFLINECREATOR_API_KEY": "oc_test_…", | ||
| "OFFLINECREATOR_API_BASE": "http://localhost:3000" | ||
@@ -73,14 +95,79 @@ } | ||
| Same JSON under Claude → Settings → Developer → MCP servers. | ||
| Add `https://mcp.offlinecreatorstudio.com/mcp` in **Settings → Connectors → | ||
| Add custom connector**. Use the stdio JSON above in | ||
| `%APPDATA%\Claude\claude_desktop_config.json` as the API-key fallback. | ||
| ### Claude Code | ||
| Remote OAuth: | ||
| ```bash | ||
| claude mcp add offlinecreator --transport http https://mcp.offlinecreatorstudio.com/mcp | ||
| ``` | ||
| Local stdio: | ||
| ```bash | ||
| claude mcp add offlinecreator --env OFFLINECREATOR_API_KEY=oc_live_… -- npx -y @offlinecreator/mcp | ||
| ``` | ||
| ### Hermes / Windsurf / VS Code | ||
| ### Hermes | ||
| Use the same stdio `command` / `args` / `env` shape as Cursor. Prefer Streamable HTTP remote MCP once Phase 2 is live (`https://mcp.offlinecreatorstudio.com/mcp`). | ||
| Add to `~/.hermes/config.yaml`, then run `/reload-mcp`: | ||
| ```yaml | ||
| mcp_servers: | ||
| offlinecreator: | ||
| url: "https://mcp.offlinecreatorstudio.com/mcp" | ||
| ``` | ||
| ### Windsurf | ||
| Add to `%USERPROFILE%\.codeium\windsurf\mcp_config.json`: | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "offlinecreator": { | ||
| "serverUrl": "https://mcp.offlinecreatorstudio.com/mcp" | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| ### VS Code / Copilot | ||
| VS Code uses `servers` rather than `mcpServers`. Add to `.vscode/mcp.json` or | ||
| the MCP user configuration: | ||
| ```json | ||
| { | ||
| "servers": { | ||
| "offlinecreator": { | ||
| "type": "http", | ||
| "url": "https://mcp.offlinecreatorstudio.com/mcp" | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| ### Legacy remote API-key fallback | ||
| Clients that support fixed headers can still use: | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "offlinecreator": { | ||
| "url": "https://offlinecreatorstudio.com/mcp", | ||
| "headers": { | ||
| "Authorization": "Bearer ${env:OFFLINECREATOR_API_KEY}" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| Never put a key in the URL or use `?api_key=`. | ||
| ## Tools | ||
@@ -92,12 +179,14 @@ | ||
| | `get_credits` | Current balance | | ||
| | `list_topups` | Credit packs and USD prices | | ||
| | `create_topup_checkout` | Stripe Checkout URL for a top-up (human pays in browser) | | ||
| | `generate` | Start generation (`wait: true` to poll) | | ||
| | `upload_input` | Attach image for image-to-video, then submit | | ||
| | `get_generation` | Status / output URL | | ||
| | `get_generation` | Current status | | ||
| | `wait_generation` | Poll until done | | ||
| | `download_output` | Short-lived signed URL for a completed output | | ||
| | `cancel_generation` | Cancel reserved job + refund | | ||
| | `list_generations` | Recent jobs | | ||
| Top-ups never charge through the agent. `create_topup_checkout` returns a Stripe URL; credits land after payment via webhook. | ||
| Tools are filtered by key scope (`models`, `read`, `generate`). Top-up | ||
| discovery and Checkout creation are CLI/API features, not MCP tools. They | ||
| always return a URL for a human browser action; agents never charge a payment | ||
| method directly. | ||
@@ -104,0 +193,0 @@ ## CLI sugar |
44121
21.87%18
28.57%997
17.99%207
75.42%2
100%