<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Xinru Tang]]></title><description><![CDATA[Xinru Tang]]></description><link>https://xinrutang.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:07:44 GMT</lastBuildDate><atom:link href="https://xinrutang.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I put an MCP server inside a desktop app so Claude can make videos on my machine]]></title><description><![CDATA[How I mounted an MCP server on a local FastAPI app so Claude Code can drive a video pipeline, plus three gotchas that cost me the most time.]]></description><link>https://xinrutang.hashnode.dev/i-put-an-mcp-server-inside-a-desktop-app-so-claude-can-make-videos-on-my-machine</link><guid isPermaLink="true">https://xinrutang.hashnode.dev/i-put-an-mcp-server-inside-a-desktop-app-so-claude-can-make-videos-on-my-machine</guid><category><![CDATA[mcp]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[Python]]></category><category><![CDATA[claude]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[Xinru Tang]]></dc:creator><pubDate>Sun, 25 Oct 2026 10:24:28 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6addd73d018bf08f1a79f911/f270de7b-cd15-4d91-9563-932fb401b972.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/h68vf3lf3rrmpbkbv2od.png" alt="ViralMint screenshot" style="display:block;margin:0 auto" />

<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/mmkltu5i0dpxqedv0jna.png" alt="ViralMint screenshot" style="display:block;margin:0 auto" />

<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/p72aq73zwlywicyt5m9y.png" alt="ViralMint screenshot" style="display:block;margin:0 auto" />

<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/fvqkj2syo95ms3asd0o1.png" alt="ViralMint screenshot" style="display:block;margin:0 auto" />

<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ooako0poivg4mqojln35.png" alt="ViralMint screenshot" style="display:block;margin:0 auto" />

<p>I've been building <strong>ViralMint</strong> — an open-source desktop app that scouts trending videos, clips long videos into shorts, and generates AI videos locally. FastAPI backend, React frontend, the heavy lifting (Whisper transcription, yt-dlp, FFmpeg) all runs on your machine.</p>
<p>A few months in, I realized the most interesting feature wasn't any single tool — it was making the <em>whole app</em> drivable by an AI agent. So I mounted an MCP (Model Context Protocol) server inside it. Now Claude Code / Cursor can scout a niche, download a competitor's video, extract the viral clips, caption them, and export platform-ready mp4s — by calling my app's tools directly on your machine.</p>
<p>This post covers the architecture and the three gotchas that cost me the most time.</p>
<h2>Why MCP-in-the-app instead of a separate server</h2>
<p>The usual MCP setup is a standalone process you configure per client. But my backend is already a local FastAPI server — so I mounted the MCP server <em>on it</em> (fastmcp, HTTP Streamable transport, <code>/mcp</code> route). One process, one port, ships with the app.</p>
<p>The tools don't import my services directly. They call the app's own REST API over loopback:</p>
<ul>
<li><strong>Stable contract.</strong> Internal service signatures change; the public API doesn't.</li>
<li><strong>Free middleware.</strong> Auth, rate limits, and every future request guard apply to MCP calls automatically — the agent can't accidentally bypass a check the UI enforces.</li>
<li><strong>Cheap to extend.</strong> New <code>/api/x</code> endpoint → thin <code>@mcp.tool()</code> wrapper. That's the whole recipe, 102 tools later.</li>
</ul>
<p>The cost is ~1ms of TCP loopback per call. Fine.</p>
<h2>Gotcha 1: <code>app.mount()</code> doesn't start your sub-app</h2>
<p>FastAPI's <code>mount()</code> does <strong>not</strong> forward lifespan startup to a mounted ASGI app. The MCP session manager never starts, and every <code>initialize</code> fails with a cryptic <code>Session terminated</code>.</p>
<p>Fix: enter the sub-app's lifespan from the parent's lifespan:</p>
<pre><code class="language-python">@asynccontextmanager
async def lifespan(app: FastAPI):
    async with AsyncExitStack() as stack:
        await stack.enter_async_context(
            mcp_asgi.router.lifespan_context(mcp_asgi)
        )
        yield
</code></pre>
<h2>Gotcha 2: the <code>/mcp/mcp</code> path trap</h2>
<p><code>mcp.http_app()</code> defaults its internal route to <code>/mcp</code>. Mount that at <code>/mcp</code> and your endpoint is <code>/mcp/mcp</code>. Pass <code>path="/"</code> to <code>http_app()</code> so the route sits at the sub-app root, then mount at <code>/mcp</code>.</p>
<h2>Gotcha 3: CSRF middleware vs. non-browser clients</h2>
<p>My CSRF middleware rejects cross-origin browser requests. MCP clients send no <code>Origin</code>/<code>Referer</code> at all — which my middleware already treated as "non-browser client, allow." So MCP flowed through with zero special-casing. The real boundary is a bearer token (generated on first run, <code>0600</code> on disk, constant-time compare) plus binding to <code>127.0.0.1</code>.</p>
<p>If your CSRF check <em>blocks</em> missing-Origin requests, you'll need that exception — and a token like this to make it safe.</p>
<h2>Design choices that made agents actually good at it</h2>
<ul>
<li><strong>Macros before primitives.</strong> Tools register with workflow shortcuts first (<code>analyze_competitor</code>, <code>find_breakouts</code>); LLMs reach for whatever they see first, so make the first thing the 1-call version of a 7-call chain.</li>
<li><code>wait_for_job</code> <strong>is the single most important tool.</strong> Everything long-running returns <code>{job_id}</code> immediately; one poller tool turns the whole surface into blocking calls an agent can reason about. It also returns the output file path, so the agent doesn't need a download step.</li>
<li><strong>Structured errors, not tracebacks.</strong> Every failure returns an envelope with a hint (<code>402 → "credit exhausted"</code>, <code>"chromium_not_installed"</code> + a settings deep-link). Agents recover well when errors say what to do next.</li>
<li><strong>Descriptions are for the LLM.</strong> One sentence of purpose, every arg with constraints, return shape at the end — so the model can chain tools without guessing.</li>
</ul>
<h2>What it looks like in practice</h2>
<p>Me, in Claude Code: <em>"find what's breaking out in the woodworking niche, grab the top video, extract 3 clips, caption them, give me the files."</em> Claude calls <code>scout_trending</code> → <code>download_video</code> → <code>extract_viral_clips</code> → <code>tool_captions</code> → <code>bulk_export_videos</code>, and the mp4s land in a folder. The app's UI shows the agent's activity live over a WebSocket, so you can watch it work.</p>
<h2>Code</h2>
<p>The pipeline is open source (AGPL-3.0): <a href="https://github.com/openclaw-easy/ViralMint"><strong>https://github.com/openclaw-easy/ViralMint</strong></a> — desktop app with docs at <a href="https://viralmint.net">https://viralmint.net</a>. The MCP patterns above (loopback-to-self, macros-first registration, the job poller) transfer to any local app you want to make agent-drivable.</p>
<p>Happy to answer questions about fastmcp mounting, the tool design, or the video pipeline itself.</p>
]]></content:encoded></item></channel></rss>