UNPKG

@extenshi/mcp

Version:

Extenshi MCP server — bring the extension catalog (search, security, market research) and pre-publish scanning into your AI tools

174 lines • 6.9 kB
/** * Scan an extension artifact against the Extenshi scan backend and RETURN the * report (no printing) — the MCP-friendly counterpart to the CLI's `runScan`. * * It replicates the exact request `@extenshi/cli` makes * (`POST {scanUrl}/api/v1/scan`, multipart `apiKey` + `artifact` + optional * `extensionId`, SSE progress, 240s timeout, server-side credit * consume→commit→refund) so the MCP `scan_extension` tool and the CLI bill and * behave identically. The network + SSE logic is vendored (not imported) to * keep this package independently publishable — npm rejects `workspace:*`. * * Errors surface as `ScanError` carrying the HTTP status + machine `errorCode` * so the caller can render the right next-step instruction. */ import fs from 'node:fs'; import path from 'node:path'; import { FormData, fetch } from 'undici'; import { parseSseFrames } from './sse.js'; import { assertValidArtifact } from './validate-artifact.js'; const SCAN_TIMEOUT_MS = 240_000; const MAX_ARTIFACT_BYTES = 50 * 1024 * 1024; /** A scan failure that carries the HTTP status and the backend's machine code. */ export class ScanError extends Error { status; errorCode; retryAfterSec; constructor(message, opts = {}) { super(message); this.name = 'ScanError'; this.status = opts.status; this.errorCode = opts.errorCode; this.retryAfterSec = opts.retryAfterSec; } } /** * Why a parsed scan response is not a usable report, or null when it is. The * backend has already committed the credit by the time a result arrives, so an * unusable body must surface as an error naming the job — never as an empty * success the caller cannot tell apart from a clean scan. */ export function scanReportProblem(report) { if (!report || typeof report !== 'object' || Array.isArray(report)) return 'the scan returned no report object'; const r = report; if (!Array.isArray(r.scanners)) { const job = typeof r.jobId === 'string' ? ` (job ${r.jobId})` : ''; return `the scan result${job} has no scanner results`; } return null; } function assertScanReport(report) { const problem = scanReportProblem(report); if (problem) { throw new ScanError(`Scan finished but ${problem}. If a scan credit was charged, keep the job id and re-run with ` + '`npx @extenshi/cli@latest scan <artifact> --format json`, which reads the same backend.'); } return report; } export async function scanArtifact(opts) { const { artifactPath, apiKey, scanUrl, extensionId, onProgress } = opts; if (!fs.existsSync(artifactPath)) { throw new ScanError(`Artifact file not found: ${artifactPath}`); } const stats = fs.statSync(artifactPath); if (!stats.isFile()) { throw new ScanError(`Artifact path is not a file: ${artifactPath}`); } if (stats.size > MAX_ARTIFACT_BYTES) { throw new ScanError(`Artifact exceeds 50 MB limit (${(stats.size / 1024 / 1024).toFixed(1)} MB)`); } // Refuse non-extension files (executables, scripts, …) before uploading. try { assertValidArtifact(artifactPath); } catch (err) { throw new ScanError(err instanceof Error ? err.message : String(err)); } const form = new FormData(); form.append('apiKey', apiKey); form.append('artifact', new File([fs.readFileSync(artifactPath)], path.basename(artifactPath))); if (extensionId) form.append('extensionId', extensionId); let response; try { response = await fetch(`${scanUrl}/api/v1/scan`, { method: 'POST', body: form, headers: { accept: 'text/event-stream' }, signal: AbortSignal.timeout(SCAN_TIMEOUT_MS), }); } catch (err) { if (err instanceof Error && err.name === 'TimeoutError') { throw new ScanError(`Scan timed out after ${SCAN_TIMEOUT_MS / 1000}s. If a credit was charged it auto-refunds within 1 minute — do NOT retry immediately.`); } throw err; } // Auth/billing/gateway errors are always JSON and arrive before any stream. if (!response.ok) { const body = await parseJsonBody(response); throw errorForStatus(response.status, body); } const contentType = response.headers.get('content-type') ?? ''; if (contentType.includes('text/event-stream')) { return assertScanReport(await consumeSseStream(response, onProgress)); } // Legacy single-JSON mode (server ignored the Accept header). return assertScanReport(await parseJsonBody(response)); } async function consumeSseStream(response, onProgress) { if (!response.body) throw new ScanError('Scan stream had no response body'); const decoder = new TextDecoder(); let buffer = ''; let report = null; let errorMessage = null; const handle = (ev) => { if (ev.event === 'progress') { const p = safeJson(ev.data); if (p && onProgress) onProgress(p); } else if (ev.event === 'result') { report = safeJson(ev.data); } else if (ev.event === 'error') { const e = safeJson(ev.data); errorMessage = e && typeof e.error === 'string' ? e.error : 'scan failed'; } }; for await (const chunk of response.body) { buffer += decoder.decode(chunk, { stream: true }); const { events, rest } = parseSseFrames(buffer); buffer = rest; for (const ev of events) handle(ev); } // Flush a trailing frame not terminated by a blank line. const { events } = parseSseFrames(`${buffer}\n\n`); for (const ev of events) handle(ev); if (errorMessage) throw new ScanError(`Scan failed: ${errorMessage}`); if (!report) throw new ScanError('Scan stream ended before returning a result'); return report; } function safeJson(data) { try { return JSON.parse(data); } catch { return null; } } async function parseJsonBody(response) { const raw = await response.text(); try { return JSON.parse(raw); } catch { const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, 200); throw new ScanError(`Scan backend returned a non-JSON response (HTTP ${response.status}). ${snippet ? `Response: ${snippet}` : ''}`.trim(), { status: response.status }); } } function errorForStatus(status, body) { const message = typeof body.error === 'string' ? body.error : `HTTP ${status}`; const errorCode = typeof body.errorCode === 'string' ? body.errorCode : undefined; const retryAfterSec = typeof body.retryAfterSec === 'number' ? body.retryAfterSec : undefined; return new ScanError(message, { status, errorCode, retryAfterSec }); } //# sourceMappingURL=scan.js.map