Sign In

@offlinecreator/mcp

Package Overview
Dependencies
Maintainers
1
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@offlinecreator/mcp - npm Package Compare versions

Comparing version
0.1.0
to
0.1.1
+22
dist/oauth-bridge.d.ts
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,

@@ -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,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)`);

@@ -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;
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);

{
"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