mcp-video-analyzer — development guide
Project-internal. This is the single source of truth for contributing to this
repository; AGENTS.md points here rather than repeating it.
Project
MCP server for video analysis — extracts transcripts, key frames, metadata, OCR text, and annotated timelines from video URLs (Loom, YouTube and other yt-dlp platforms, direct links) and local video files. The same engine is also exposed as a one-shot CLI (mcp-video-analyzer analyze <url>) and as the portable /video agent skill (skills/video/SKILL.md + Claude Code plugin).
Commands
npm run check— run ALL checks (format, lint, typecheck, knip, tests). Always run before committing.npm run build— compile TypeScript to dist/npm run test— run unit tests (vitest)npm run test:watch— run tests in watch modenpm run test:smoke— build + verify MCP server starts and responds to initializenpm run test:formats— just the video-format matrix (test/e2e/video-formats.e2e.test.ts);15s on a warm cache. Clips are all generated locally, but7MB) once on a colddetail: 'standard'always runs OCR, so tesseract.js fetches eng+por traineddata (cachePath.npm run security—npm auditon shipped deps only (--omit=dev, fails at moderate+). The blocking tier.npm run security:all—npm auditon the whole tree (fails at high+); dev-only moderates deliberately don't gate.npm run verify-package— build + pack tarball + install in temp dir + verify startup (pre-publish)npm run lint:fix— auto-fix lint issues (securityis NOT part ofcheck—checkis whatprepublishOnlyruns and must stay offline and deterministic.npm auditreads a live advisory database, so folding it in means an advisory published overnight failsnpm publishfor code that never changed. It runs as its own CI job, plus a weekly cron so a new advisory turns CI red without waiting for a PR.)npm run format— auto-format with Prettiernpm run inspect— open FastMCP inspector for manual testingnode dist/index.js analyze <url> [flags]— run the one-shot CLI against the local build (afternpm run build)npx tsx examples/generate.ts— regenerate example outputs (run after changing tool output format, processors, or adapters)
Architecture
- Adapters (
src/adapters/) — platform-specific logic (Loom GraphQL, yt-dlp platforms [YouTube/Vimeo/TikTok/Instagram/X/Twitch/Dailymotion/Facebook], direct URL download, TwelveLabs, local files). Each implementsIVideoAdapter. Registered most-specific-first inserver.ts: Loom → LocalFile → YtDlp → TwelveLabs → Direct. - Processors (
src/processors/) — shared processing: frame extraction (ffmpeg + browser fallback), image optimization + OCR preprocessing (sharp), frame dedup (dHash, visual + OCR-text-aware), OCR (tesseract.js), audio transcription (whisper), annotated timeline. - Tools (
src/tools/) — MCP tool definitions registered on the FastMCP server.analyze-core.tsholds the shared cache + pipeline (getAnalysis) + content builder reused by bothanalyze_videoand the batchanalyze_videos. - Utils (
src/utils/) — URL detection, VTT parsing, temp files, in-memory + on-disk cache (cache.ts,analysis-sidecar.ts), bounded concurrency (concurrency.ts), env-flag parsing (env.ts). - CLI (
src/cli.ts) — one-shotanalyzesubcommand (mcp-video-analyzer analyze <url>) reusing the samegetAnalysispipeline: single JSON document on stdout, progress/errors on stderr, frame JPEGs copied to--out(default<user-cache>/mcp-video-analyzer/<url-hash>/viapersistentCacheDir()) beforehandle.cleanup().src/index.tsdispatches onargv[2]— no args = MCP stdio server (Docker/smithery/MCP configs rely on this). Adapter registration is shared viaregisterAllAdapters()inserver.ts. Version literal lives insrc/version.ts. - Skill + plugin (
skills/video/SKILL.md,.claude-plugin/, root.mcp.json) — the/videoagent skill (Route A: MCP tools; Route B: the CLI via npx) and Claude Code plugin/marketplace manifests; the root.mcp.jsonis the plugin's bundled server config (auto-registered on/plugin install). Installed from GitHub, never shipped in the npm tarball (files: ["dist"]).
Conventions
- TypeScript strict mode. No
anyunless explicitly necessary (use// eslint-disable-next-line). - All exports must be used — knip enforces zero unused exports.
- Unit tests live next to source files:
foo.ts→foo.test.ts. - Shared test infrastructure lives in
test/: helpers (test/helpers/), fixtures (test/fixtures/), smoke tests (test/smoke/), e2e tests (test/e2e/). - Use
createTestImage()fromtest/helpers/images.tsandFIXTURES_DIRfromtest/helpers/fixtures.ts— don't redefine in each test file. - Use
vitestwithpool: 'forks'(required on Windows). - Graceful degradation: never throw when partial results are available. Use
warnings[]array. This includes the frame-only tools (get_frames/get_frame_at/get_frame_burst) andanalyze_moment: a zero-frame outcome — extraction failure, or every extracted frame filtered out as black — returnsframeCount: 0with the accumulated warnings, never a thrownUserError(issue #26). (Dedup can't empty a non-empty set — it always keepsframe[0]— so a filtered-to-zero result is always black-frame filtering.) Keep the input-validation throws —getAdapter, an invalid timestamp, orto <= fromall still throw aUserError; validate timestamps up front, outside the extractiontry, so both the download and browser strategies see an already-valid value and only the extraction outcome degrades. Wrap only theextractFrameAt/extractFrameBurstcall intry/catch(they throw a raw ffmpegErrorthat leaks the command line, unlikeextractKeyFrameswhich degrades to[]); surface a fixed, path-free reason, never the caughte.message. Both tools' success and degraded paths emit the same JSON{ frameCount, ..., warnings }text block so a client can parse either uniformly. warnings[]is public output, and every entry goes throughwarningReason()(src/utils/warnings.ts). It is returned to the MCP client, read into the agent's context, and written next to the user's video underMCP_WRITE_SIDECARS=1, so an entry must be one line, human-readable, and free of filesystem paths and command lines. A spawned binary'se.messageis none of those — forexecFileit isCommand failed: <full argv>plus the entire stderr — which is how issue #46 answered "this clip has no audio" with ~300 lines of ffmpeg banner and absolute temp paths.ffmpegCrashReason()(frames, from #26) andextractYtDlpError()(yt-dlp, which also redacts the cookie path) remain the specific translators on their own paths;warningReason()covers everything else and returns an already-clean single-line message untouched, so a crafted hint is never truncated.src/utils/warning-sources.test.tsscans all ofsrc/at authoring time and fails the build on the next raw${e.message}— it is what found 7 of the 25 sites, the multi-line calls a line-based grep walks straight past.- Three-strategy video download: yt-dlp (primary) → direct HTTP via Loom CDN API (fallback) → headless Chrome screenshots (last resort).
downloadViaYtDlp()insrc/utils/ytdlp.tsis the only yt-dlp download implementation — bothYtDlpAdapter.downloadVideoandLoomAdapterstrategy 1 call it, and it must stay that way (issue #24 was a second, divergent copy that hardcoded.mp4). Adapters are siblings behindIVideoAdapterand must not depend on each other; shared yt-dlp behaviour belongs in the util. - yt-dlp
-otemplates must never hardcode a container extension. Always-o <name>.%(ext)splus areaddirglob for<name>.*; when yt-dlp merges separate DASH video+audio streams it appends the REAL container to whatever template you gave it, so-o x.mp4writesx.mp4.webmand everyexistsSync('x.mp4')after it silently fails on a download that actually succeeded. Pair it with--ffmpeg-location ffmpegPath— without that, yt-dlp can't merge at all in our published image (no system ffmpeg) and leaves the streams separate, so the glob can return the audio-only file. The%(ext)srule is enforced repo-wide bysrc/adapters/ytdlp-output-template.test.ts(which also unit-tests its own detector against the original #24 code); the--ffmpeg-locationargument is asserted insrc/adapters/ytdlp.adapter.test.ts. - yt-dlp platform URLs (single-video pages only; playlists/channels rejected) route through
YtDlpAdapter.src/utils/ytdlp.tsowns everything yt-dlp:findYtDlp()(positive probe cached per process),runYtDlp(),ytdlpCookieArgs(),commonArgs(),extractYtDlpError(),YTDLP_MISSINGanddownloadViaYtDlp()— always spawn throughrunYtDlpso the bin/prefix pairing can't be forgotten, and add shared flags tocommonArgs()rather than inlining them at one call site. ThefindYtDlp()probe usesYTDLP_PROBE_TIMEOUT(20s, not 5s): the standalone binary cold-starts in >7s under load, and a shorter timeout made a slow-but-present binary look absent (issue #26). Raising it is nearly free — a genuinely-missing binary rejects via ENOENT in a few ms, never waiting for the timeout. Missing yt-dlp surfaces as install-hint warnings: adaptergetTranscript/getMetadatathrowYTDLP_MISSINGand every tool handler catches adapter rejections intowarnings[];downloadVideomust returnnull, never reject (the pipeline calls it without catch) and reports its failure reason via the optionalonWarningsink. Native captions preferred (uploaded > auto-generated with rolling-window collapse);[]fromgetTranscriptstrictly means "no captions exist" (fetch failures throw) → Whisper fallback. - Standard-detail
maxFramesdefault is duration-adaptive viaresolveMaxFrames()indetail-levels.ts(~12 for ≤30s up to 60 for >10min). An explicitmaxFramesalways wins and keys the cache separately (undefineddrops out of the cache key).get_frameskeeps its fixed default of 20. - Silent-audio gate:
transcribeAudio()probes the track with ffmpegvolumedetect(first 2 min) before any Whisper strategy; mean volume ≤ −55dB skips transcription with a warning — an empty transcript on a mute track is content, not a bug. - Frame extraction uses bundled
ffmpeg-static— no system ffmpeg needed. - The bundled
ffmpeg-static7.0.2 LINUX build segfaults on the MPEG-TS demuxer. Any.mts/.m2tsinput (the standard AVCHD camcorder format) kills ffmpeg on probe, extract, and even remux — in the published Docker image and for every Linux user. The byte-identical file parses fine on the Windows build, so it is the binary, not the file;ffmpeg-static@5.3.0is the latest release, so there is no version to upgrade to. Found bytest/e2e/video-formats.e2e.test.ts, which is exactly the blind spot it was written for. A signal-terminated ffmpeg is NOT "no frames found":ffmpegCrashReason()inframe-extractor.tsturns it into one actionable, path-free warning (nevere.message, which is the full argv + banner), and every rewrap site in that file resolves the reason while the original error still carries.signal. The matrix probes the binary's actual capability rather than branching onprocess.platform— a platform check is a guess about a binary, not a fact about it — and asserts the degraded contract where the demuxer is broken, so a silently-empty result stays a failure on every platform. - Black frame detection filters out DRM-protected/blank frames automatically.
- Scene detection threshold default: 0.1 (optimized for screencasts/demos). Use
extractKeyFrames()(not rawextractSceneFrames) so static clips with no scene cuts fall back to uniform temporal sampling — critical for talking-head Reels/Stories. - OCR runs on every frame before dedup; when OCR is enabled, dedup uses
dedupeKeepingTextChanges()(visual + on-screen-text aware) so frames whose only change is the text overlay survive. PlaindeduplicateFrames()(visual only) is used when OCR is off. - OCR frames are preprocessed (grayscale + 2× upscale + contrast normalization + sharpen) by default;
MCP_OCR_PREPROCESS=0disables. - Transcription strategy order: HF transformers (opt-in) → whisper CLI → OpenAI API. HF only runs when
WHISPER_HF_MODELis set, so otherwise the CLI wins and itsWHISPER_MODEL/WHISPER_LANGUAGEsettings are never silently overridden.model/language/initialPromptare overridable per call onanalyze_video/analyze_videos/get_transcript. - The whisper CLI is run directly (no
--helpprobe — it double-imports torch and crashes on Windows on non-ASCII help text);ENOENTdistinguishes "not installed" (try next candidate) from "installed but crashed" (warn). Spawned withPYTHONUTF8=1/PYTHONIOENCODING=utf-8so multilingual transcripts don't crash the Python stdout codec. When NO backend is configured at all,transcribeAudioemits an actionable "No speech-to-text backend available" warning instead of a bare[]. - yt-dlp errors that look auth-related (login/cookies/private/age-restricted/empty-media/rate-limit) get a cookie hint appended by
extractYtDlpErrornaming this server's env vars (YTDLP_COOKIES/YTDLP_COOKIES_FROM_BROWSER), not yt-dlp's raw CLI flags. - Persistent sidecars (
MCP_WRITE_SIDECARS=1) write<stem>.vtt(Whisper transcripts only, never clobbering an existing one) +<stem>.analysis.json+<stem>.frames/next to local videos for resumable bulk processing; reads validatemtime:size+ params. - CLI mode: stdout is reserved for the single JSON result document — progress, warnings-in-flight, and errors go to stderr. CLI flags validate through the shared
AnalyzeOptionsSchema(no hand-rolled validation). Partial failures ride inwarnings[]with exit 0; only hard failures exit 1. skills/video/SKILL.mdis a public contract: any change to MCP tool names, CLI flags, or the CLI JSON shape must updateskills/video/SKILL.md+README.md+ the "Using this project as a tool" section ofAGENTS.mdin the same PR.- Tesseract
.traineddatadownloads are cached in<user-cache>/mcp-video-analyzer/tessdataviacachePath(frame-ocr.ts) — never let them land in the process cwd (pollutes the agent's project dir under npx). - No persistent on-disk location may use a fixed name under
os.tmpdir().persistentCacheDir()(temp-files.ts) resolves the OS per-user cache dir (MCP_CACHE_DIRoverrides it; CI pins that so itsactions/cachepaths match what the code writes), and the golden-clip cache (test/helpers/golden-clips.ts) lives in the gitignored repo-local.cache/golden-clips— notnode_modules/.cache, whichnpm ciwipes after the CI cache step restores it. Both were<tmp>/mcp-video-analyzer/...and both are gone on purpose. The one remainingtmpdir()fallback (no usable home, no override) is uid-keyed so two users cannot collide, and is enforced bysrc/utils/tmpdir-usage.test.ts, which scanssrc/andtest/for any other fixed-name use. The shared temp dir is world-traversable, so a predictable path there is pre-creatable by any other local user: they read the frames the CLI copies out and can plant a.traineddatafor our OCR to load. CodeQL flagged the test-side one (js/insecure-temporary-file), following the taint from the golden clips through the e2e tests into the sidecarwriteFiles — the production one it could not see, because those sinks arecopyFileand tesseract's own write. Per-call scratch space is different and stays in tmp:createTempDir()usesmkdtemp, which is randomized and mode-0700. detectPlatform(url-detector.ts) is the network trust boundary, andassertPublicUrl(ssrf-guard.ts) is the only gate at the sinks. All 8 MCP tools and the CLI validate through.refine(isVideoSource), which isdetectPlatform(...) !== null— so a destination refused there is refused at every entry point at once. That is why GHSA-hpmc-4g74-v53v was fixed there rather than at each caller.detectPlatformmust stay sync (adapters call it fromcanHandle, and it keys the analysis cache), so it can only do literal checks: scheme, IP literals,localhost/.local, UNC. Anything needing DNS belongs inassertPublicUrl, which every outbound sink must call — todaydownloadDirectVideoandextractBrowserFrames. Adding a new sink that takes a client URL means adding that call; the browser fallback was an unreported second SSRF sink precisely because it was never treated as one, and it is the worse of the two (it renders the response and returns it as a JPEG). Inside it, the pre-flight call covers the first hop only — Chrome follows redirects itself and every subresource is its own request — so thepage.on('request')interceptor is a second sink in its own right and callsassertPublicUrltoo, asynchronously (puppeteer holds the request until continue/abort settles). A literal-only check there is not a cheaper version of the gate, it is a hole: it cannot see a hostname that merely resolves internal. Two things that path must keep:data:/blob:/about:continue unvalidated (an MSE player hands<video>ablob:URL — abort it and the fallback screenshots nothing), and bothcontinue()/abort()are.catched, because they reject on an already-handled request and an unhandled rejection out of an event listener kills the process. ffmpeg and yt-dlp are deliberately not gated: ffmpeg only ever receives an already-downloadedvideoPath, andYTDLP_PATTERNSis a real host allowlist — if either ever receives a raw URL, that changes.- The blocked-range table only grows. RFC1918 + loopback + link-local is not enough, and shortening it to that is itself the vulnerability: Gitea's GHSA-2r5c-gw76-rh3w is CVSS 9.6 for exactly that list, because it misses CGNAT (
100.64/10), Azure's WireServer (168.63.129.16), and the IPv6 transition ranges —64:ff9b::a9fe:a9fereaches the AWS IMDS through NAT64.0.0.0.0and IPv4-mapped IPv6 (::ffff:127.0.0.1) are mcp-searxng's CVE-2026-54689. Matching is numeric, never string-wise, so an alternate spelling cannot slip past the metadata verdict — and that has to mean actually numeric: the first version of this collapsed IPv4-mapped IPv6 with a regex on the dotted spelling, whichnew URL()never produces (it normalizes[::ffff:169.254.169.254]to[::ffff:a9fe:a9fe]), so every real request walked past the entire table.parseIpnow collapses::ffff:0:0/96and::/96on the bits. A prefix that merely embeds v4 without being it — NAT6464:ff9b::/96, 6to42002::/16— is separately re-checked against the metadata table byembeddedIpv4()before the range verdict: the ranges refuse those prefixes only asprivate, which is exactly the verdict the opt-in unlocks, so without that stepMCP_ALLOW_PRIVATE_URLS=1handed out the IMDS. The regression tests must assert the spellingnew URL()emits, not the one a human would type. Redirects are re-checked per hop (redirect: 'manual'+ a loop) — a first-hop-only check is the same CVE.MCP_ALLOW_PRIVATE_URLS=1unlocks private ranges but never metadata, and never the scheme check. Known, documented ceiling: DNS rebinding, since the check is at resolution and not at connection; closing it needs anundiciAgent({ connect: { lookup } })and a direct dependency on undici. - Never pair
existsSync(p)with a later write topwhere losing the race would clobber someone else's data. Use an atomic exclusive create —writeFile(p, data, { flag: 'wx' })— and treatEEXISTas the no-op. Where the race is benign because both writers produce equivalent bytes, check-then-write is fine and must say so at the call site:test/helpers/golden-clips.ts(first rename wins, clips are byte-equivalent by construction) andloom.adapter.ts:302(a did-the-download-land check) are the two deliberate instances and were correctly left alone. The sidecar.vtthad the check-then-write form (js/file-system-race) while its own header documents an external GPU-Whisper pipeline writing into the same directory, so the clobber window was real, not theoretical.
Testing conventions
These three exist because each was violated in the issue #24 fix and caught only in review. They are cheap to follow and expensive to skip.
- A regression guard must be proven against the real pre-fix code, pulled from git — never against a hand-written example. The first source guard for #24 blacklisted literal extensions near
-oand was "verified" by reintroducing the bug inline. The bug had actually been written asconst outputPath = join(destDir, \${videoId}.mp4`)+-o outputPath, which the guard passed. Retrieve the real thing (git show ^:`), run the detector against it, and pin that snippet as a test case so the proof lives in the suite instead of in a PR description. Prefer positive assertions ("prove this is safe") over blacklists — "can't prove it" must fail, not pass. - A test that cannot fail is worse than no test.
test/e2e/analyze-loom.e2e.test.tsasserteddownloadVideo(...) === nulland called it "(no auth)"; it passed whether the code worked or not, which is why a 44MB download being silently discarded went unnoticed for months. Watch for: asserting a null/empty result as the expected outcome, acatch → skipbroad enough to swallow real failures, and any scan-style guard that asserts nothing when it matches nothing (always assert it scanned something). Guards live insrc/adapters/ytdlp-output-template.test.tsandtest/e2e/download-destinations.e2e.test.ts; both unit-test their own detector. - When fixing a bug, grep the whole repo for siblings of the pattern before calling it fixed. The same inverted assertion existed in
test/e2e/partial-results.e2e.test.tsand shipped untouched in the first pass. One guard in the shared function beats a guard in every caller — and the sibling you don't look for is the one that stays broken. - Every container in
VIDEO_EXTENSIONSmust be decoded by a real test.src/utils/url-detector.tsgates 14 extensions that all reach ffmpeg in production, but before v0.9.0 only mp4/h264 was genuinely exercised (plus one webm/vp9 probe from #24) — the rest were string assertions inurl-detector.test.tsthat never opened a file. A codec the bundledffmpeg-staticbuild could not decode would have returned zero frames and zero failures, since "0 frames" is valid graceful degradation everywhere else.test/e2e/video-formats.e2e.test.tsgenerates a clip per container/codec fromFORMAT_MATRIX(test/helpers/golden-clips.ts) using a MOVINGtestsrcsource plus a silentanullsrctrack, then asserts routing + duration + frames>0 + the silence-gate warning (which is positive proof the audio demuxed for that container). Its drift guard fails if an extension is added toVIDEO_EXTENSIONSwith neither a matrix row nor a documented exclusion. It stubsMCP_WRITE_SIDECARSempty — clips are cached across runs, so a sidecar would replay a stored result and the whole matrix would pass without ffmpeg running once. - Every core outcome must be asserted against ground-truth fixture content somewhere. When graceful degradation makes an empty result valid (OCR on a blank clip, transcript on a silent track, dedup on identical frames), at least one test must exist where empty = FAIL: a fixture with known text/speech/cuts and an assertion that the pipeline recovered that content. The #28 OCR-downscale bug survived a 500-test suite for months because every fixture was content-free (solid colors,
testsrc, a black clip) — 0 OCR results was simultaneously "working as designed" and "completely broken". Golden fixtures live intest/helpers/golden-clips.ts(ffmpeg drawtext + the OFL font intest/fixtures/fonts/); outcome tests intest/e2e/golden-ocr.e2e.test.tsandsrc/processors/frame-extractor.test.ts. The transcript half istest/fixtures/speech.wav(committed TTS speech; ground truth inSPEECH_WORDS) +test/e2e/golden-transcription.e2e.test.ts, gated byWHISPER_E2E=1because whisper is an external dependency: flag unset = suite visibly skipped (explicit operator opt-out), flag set + whisper missing = FAIL, never a probe-and-skip — CI always runs it withwhisper-ctranslate2viaWHISPER_BIN(the CLI candidate list is only[WHISPER_BIN, 'whisper']). A cousin of this rule guards the analysis cache: everyAnalyzeParamsleaf must be classified result-defining or excluded (ExcludedFromCacheKeytype guard inanalyze-core.ts+ theit.eachkey table inanalyze-core.test.ts) — #28's second bug was a result-changing param that silently missed the cache key.
Verifying a change
npm run check is necessary, not sufficient — it never spawns yt-dlp, never downloads, and never installs the package. Run npm run verify-all (check → e2e → smoke → verify-package) before claiming a change works, and report the actual output rather than the list of commands you intended to run. Set WHISPER_E2E=1 for the e2e leg when a whisper CLI is installed locally — that's the only way the transcription outcome test runs outside CI. The first #24 PR listed npm run test:e2e in its validation section without the full suite ever having been run; the individual files had been run instead.
For anything touching adapters or downloads, also exercise it in a container — npm run check passes on a machine that happens to have a system ffmpeg, while the published image has none. The #24 fix needed --ffmpeg-location precisely because that difference was invisible on the host.
Environment Variables
- Transcription:
WHISPER_MODEL,WHISPER_LANGUAGE,WHISPER_PROMPT(glossary →--initial_prompt),WHISPER_BIN,WHISPER_DEVICE/WHISPER_COMPUTE/WHISPER_BEAM_SIZE/WHISPER_WORD_TIMESTAMPS(env-gated — only passed to the CLI when set, soopenai-whisperisn't broken bywhisper-ctranslate2-only flags),WHISPER_HF_MODEL(opt-in),OPENAI_API_KEY. - OCR:
MCP_OCR_PREPROCESS(default on;0to disable preprocessing). OCR always reads the pre-optimization frame — recognition needs the pixels the emitted copy gives up. - Frame size:
MCP_FRAME_MAX_WIDTH(default800;0/native/full/originalkeeps source resolution) andMCP_FRAME_JPEG_QUALITY(default70, no per-call override — env only). The per-callmaxWidthtool parameter (six frame-emitting tools) and the CLI's--max-widthwin overMCP_FRAME_MAX_WIDTH, and are the right knob for dense UI captures since the server starts once per session. A set-but-invalid value for either is rejected with a one-time stderr warning rather than silently falling back. The effective width (per-call → env → default) keys the analysis cache and sidecar, so the same URL at two widths can't serve one result for both — seekeyedFrameMaxWidth(). - yt-dlp cookies:
YTDLP_COOKIES(Netscape cookie file, wins when both set) /YTDLP_COOKIES_FROM_BROWSER(e.g.chrome,edge) — needed for Instagram and age-restricted videos. Browser extraction requires the browser to be closed on Windows. - Sidecars:
MCP_WRITE_SIDECARS(default off;1to persist resumable sidecars next to local videos). - Cache root:
MCP_CACHE_DIR(absolute paths only) overrides the per-user cache dir that backs the tessdata cache and the CLI's default--out. Nothing reaps that location on Windows or Linux, so frames there persist until deleted; the dirs are created 0700 so the pile is at least private. - Network destinations:
MCP_ALLOW_PRIVATE_URLS(default off;1allows loopback/private/LAN/.local/UNC destinations for operators who genuinely serve video off their own network). It never unlocks cloud metadata endpoints and never relaxes the http(s)-only scheme check — see the trust-boundary convention above. - TwelveLabs:
TWELVELABS_API_KEY(opt-in Pegasus transcript/summary for direct URLs).
Publishing
Release Process
- Bump version in
package.json,src/version.tsAND.claude-plugin/plugin.json(must match). - Run checks:
npm run check(format, lint, typecheck, knip, tests). - Run smoke test:
npm run test:smoke(verifies MCP server starts and responds). - Run package verification:
npm run verify-package(packs tarball, installs in temp dir, verifies startup). - Docker image validation — the
docker-imageCI job runs it on every PR (build from clean clone + ffmpeg present + MCPinitializeanswered), replicating what Glama CI does on each release;dist/is gitignored, so the image must compile itself. Confirm the job is green before releasing. Manual fallback:git archive HEAD -o sim.tar→ extract to an empty dir →docker buildthere → pipe an MCPinitializeintodocker run -i. A build that only works with a locally pre-builtdist/WILL fail on Glama and email the maintainer. - Commit & push: commit version bump to main. Confirm
Security / audit (shipped deps),Security / audit (full tree)andCodeQLare green too — they are separate workflows fromCI, so a greenCIcheck alone is not the whole gate. - Publish to npm:
npm publish. - Create GitHub release:
gh release create vX.Y.Z --title "vX.Y.Z" --generate-notes. - Update local MCP config: pin the new version in your machine's own MCP client config (NOT the repo-root
.mcp.json, which is the plugin's bundled server config and stays on@latest). - Verify on npm:
npm view mcp-video-analyzer version.
Notes
- Source maps are disabled in tsconfig to reduce package size.
npm publishrunsprepublishOnlywhich executesnpm run check && npm run buildautomatically.- Never publish without testing as consumer —
npm run checkpassing does NOT mean the package works for end users. Always runnpm run verify-package. - sharp needs no build step in the image. Since 0.35 it has no install script at all, and its prebuilt
@img/*optionalDependencies declare none either, so the libvips binary comes straight out of the lockfile undernpm ci --omit=dev --ignore-scripts. (Pre-0.35 its install script was already being skipped by that flag and sharp worked anyway — that is the proof.)--ignore-scriptsnow exists purely to skip the localpreparescript;npm rebuild ffmpeg-staticis still load-bearing. Thedocker-imageCI job asserts the libvips binary actually loads and returns pixels, because a missing prebuilt is invisible on a dev host and fatal in the image. - The Dockerfile is multi-stage and self-building (compiles
src/in a build stage) — never make it depend on a pre-builtdist/, and keepsrc/+tsconfig.jsonout of.dockerignore. The runtime stage usesnpm ci --omit=dev --ignore-scripts(thepreparescript would run tsc without dev deps) followed bynpm rebuild ffmpeg-static(its postinstall downloads the ffmpeg binary; skipping it ships an image with no frame extraction). Smithery is unaffected (smithery.yamllaunches via npx).
Dependencies
fastmcp— MCP server framework- Node >=22.12 is the floor (
package.jsonengines, both Dockerfile stages, README/AGENTS/SKILL.md — drift-guarded bysrc/version.test.ts). Set bypuppeteer-core@25, which is the ONLY escape from GHSA-jmr9-qjv8-65gv inextract-zip(no fixed version ofextract-zipexists; v25 drops the dependency).sharp@0.35independently needs >=20.9 andvite@8needs >=22.12. Node 18 EOL'd 2025-04 and Node 20 EOL'd 2026-04, so the floor is also just current. sharp— image processing (resize, compress, dHash computation)ffmpeg-static— bundled ffmpeg binary for frame extractionpuppeteer-core— browser-based frame extraction fallback (no bundled browser)tesseract.js— OCR text extraction from framescheerio— HTML parsing for adapter scraping