@extenshi/mcp
Version:
Extenshi MCP server — bring the extension catalog (search, security, market research) and pre-publish scanning into your AI tools
72 lines • 3.67 kB
JavaScript
/**
* Client-side half of `upload_project_media`: turn a tool call into the BFF's
* `{ mime, dataBase64 }` payload.
*
* The BFF is the real gate (magic bytes, metadata stripping, dimension and
* quota limits). What only the client can decide is WHICH local file may be
* read at all. The stdio server runs with the developer's filesystem, so a
* `filePath` is confined to the workspace (the server's working directory):
* a prompt-injected "upload ~/Pictures/passport.jpg" must not be able to
* publish an arbitrary personal image to a public bucket. Files elsewhere go
* through the CLI (`extenshi media upload`), where the path is on the command
* line in front of the developer.
*/
import { readFile, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
export const PROJECT_MEDIA_MAX_BYTES = 2 * 1024 * 1024;
export const PROJECT_MEDIA_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
export class ProjectMediaInputError extends Error {
}
/** Same signatures the BFF accepts. Anything else (SVG, HTML, GIF) → null. */
export function sniffMediaMime(bytes) {
const b = bytes;
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47)
return 'image/png';
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
return 'image/jpeg';
if (b.length >= 12 &&
String.fromCharCode(b[0], b[1], b[2], b[3]) === 'RIFF' &&
String.fromCharCode(b[8], b[9], b[10], b[11]) === 'WEBP')
return 'image/webp';
return null;
}
const NOT_AN_IMAGE = 'is not a PNG, JPEG or WebP image (checked by content). SVG is not accepted: rasterize it first ' +
'(npx @extenshi/cli@latest icon store-assets <file.svg>) or pass an inline logo as logoSvg to publish_landing_page.';
/** Read a workspace-local image for upload. `root` is the workspace (default: cwd). */
export async function readWorkspaceImage(filePath, root = process.cwd()) {
let resolved;
let rootReal;
try {
resolved = await realpath(path.resolve(root, filePath));
rootReal = await realpath(root);
}
catch {
throw new ProjectMediaInputError(`File not found: ${filePath}`);
}
const relative = path.relative(rootReal, resolved);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new ProjectMediaInputError(`"${filePath}" is outside the workspace (${rootReal}). upload_project_media only reads files inside it; ` +
'for other files run `npx @extenshi/cli@latest media upload <file> --project <id>` or send dataBase64.');
}
const info = await stat(resolved);
if (!info.isFile())
throw new ProjectMediaInputError(`"${filePath}" is not a file.`);
if (info.size > PROJECT_MEDIA_MAX_BYTES) {
throw new ProjectMediaInputError(`"${filePath}" is ${(info.size / 1024 / 1024).toFixed(1)} MB; the limit is 2 MB per image.`);
}
const bytes = await readFile(resolved);
const mime = sniffMediaMime(bytes);
if (!mime)
throw new ProjectMediaInputError(`"${filePath}" ${NOT_AN_IMAGE}`);
return { mime, dataBase64: bytes.toString('base64'), bytes: bytes.length };
}
/** Validate an inline payload the same way before it is sent. */
export function checkInlineImage(dataBase64, declared) {
const bytes = Buffer.from(dataBase64.slice(0, 64), 'base64');
const mime = sniffMediaMime(bytes);
if (!mime)
throw new ProjectMediaInputError(`dataBase64 ${NOT_AN_IMAGE}`);
if (mime !== declared)
throw new ProjectMediaInputError(`dataBase64 contains ${mime} but mime says ${declared}.`);
}
//# sourceMappingURL=project-media.js.map