An MCP Server Gamma Should Have Built
Five tools. Three transport rewrites. One dead Slack link that started it all.
Gamma has a REST API for generating presentations. They have a connector for Claude Desktop, but not an MCP server. Their docs mention MCP, point you to a Slack channel to request access, and the invite link is dead. This is baffling — wrapping a REST API in MCP is about as low-hanging as fruit gets. Five tools, a polling loop, and a thin client. A day of work. They just haven’t done it. So I did it.
The result is an MCP server that lets Claude Code generate Gamma presentations, documents, and social posts without leaving the terminal. It is alpha-quality — I have not systematically verified whether the presentations it produces are actually good. But the server works, and the process of building it taught me more about MCP than any tutorial would have.
What follows is the build, not a tutorial. Three stages, each triggered by a real limitation I hit. Most of the development happened through a tight loop: try something, hit an error, read the error, fix it, try again. Claude Code was writing the server while also being the test client for it — which meant every failure was immediate and every fix was verified in the same session.
Stage 1
The simplest thing: stdio
MCP’s default transport is stdio. Claude Code spawns the server as a child process and communicates over stdin/stdout using JSON-RPC. No port binding, no HTTP, no CORS. The server’s lifecycle is tied to the Claude Code session — it starts when you start, dies when you quit.
The initial version looked roughly like this:
const transport = new StdioServerTransport();
const server = new McpServer({ name: "gamma", version: "1.0.0" });
await server.connect(transport);
Three lines and you have a working MCP server. Claude Code launches it, sends JSON-RPC over stdin, reads responses from stdout. No configuration files, no ports, no service discovery. It just works.
Until it doesn't. One Claude Code session means one server process. Open a second terminal window and it has no idea the server exists. Close the original session and the server dies with it. If you are iterating on a presentation across multiple conversations, you are restarting the server every time. The stdio model assumes a 1:1 relationship between client and server, and that stopped being useful quickly.
Stage 2
HTTP and the session problem
The fix was switching from StdioServerTransport to StreamableHTTPServerTransport. The server now runs as an HTTP process on port 24748, independent of any Claude Code session. Multiple clients can connect simultaneously. The server survives terminal closures.
The interesting part is session management. Each client connection gets its own MCP session, identified by a UUID in the mcp-session-id header. The routing logic is straightforward:
const sessionId = req.headers["mcp-session-id"];
if (sessionId && sessions.has(sessionId)) {
const transport = sessions.get(sessionId)!;
await transport.handleRequest(req, res);
return;
}
if (sessionId) {
res.writeHead(404);
res.end("Session not found");
return;
}
// New session...
Known session ID? Route to the existing transport. Unknown session ID? 404 — the session expired or never existed. No session ID at all? Create a new one. Each new session gets its own StreamableHTTPServerTransport instance with a fresh UUID, and a new McpServer is wired up to it.
The session map is just a Map<string, StreamableHTTPServerTransport>. When a session closes, either by the client disconnecting or the transport firing onclose, the entry gets cleaned up. Nothing exotic.
Most of this session logic was not designed upfront. I would connect from Claude Code, hit an error, read the stack trace, and fix the routing. Then reconnect and hit the next error. The MCP SDK’s error messages are specific enough that each failure pointed directly at the fix. The server evolved through use, not through planning.
In stdio mode, stdout is reserved for the protocol — that is why MCP servers use console.error() for logging. HTTP eliminates this constraint.
Stage 3
Making it stick
An HTTP server only helps if it is running before Claude Code tries to connect. I did not want to remember to start it manually every time I opened my laptop.
The solution is a macOS launchd agent. A small plist file tells launchd to start the server on login and restart it if it crashes. The server runs in the background, always available, surviving terminal sessions and reboots.
This is the same mechanism that runs things like Postgres or cloud-sql-proxy on macOS. Nothing exotic about it — launchd is the system’s process supervisor and it handles the lifecycle cleanly.
Five tools
- generate-gamma
- The primary tool. Takes text content and produces presentations, documents, webpages, or social posts.
- generate-from-template
- Reworks an existing Gamma template with new content and a prompt describing the changes.
- list-themes
- Discovers available visual themes. Useful before generating — pick the right look first.
- list-folders
- Discovers workspace folders. Routes generated content to the right place.
- check-generation-status
- The escape hatch. If polling times out, use the generation ID to check manually.
The interesting bit is the polling. Gamma’s API is asynchronous. You POST a generation request and get back a generation ID. Then you poll every 10 seconds until the status flips to completed or failed, with a 5-minute timeout.
async pollGeneration(generationId: string) {
const startTime = Date.now();
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
const status = await this.getGenerationStatus(generationId);
if (status.status === "completed" || status.status === "failed") {
return status;
}
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
}
throw new Error(`Timed out. Check with ID: ${generationId}`);
}
If the 5-minute timeout hits, the error includes the generation ID so you can check manually later using check-generation-status. Most generations complete well within the window, but large presentations with AI-generated images can take a while.
Alpha means alpha
- Output quality: I have not systematically tested whether the generated presentations are good. The server delivers whatever Gamma produces.
- No tests. The server works, but there is no automated test suite verifying it.
- Hardcoded port. It is 24748. If something else is using that port, you are editing source code.
- No auth beyond the API key. The server binds to 127.0.0.1, so it is not exposed to the network, but there is no additional authentication layer.
- Error messages are functional but not polished. You will get the information you need, but the formatting is utilitarian.
This is a working connection between Claude Code and Gamma's API. Whether the presentations it generates are worth using is a separate question I have not answered yet.
Around 400 lines of TypeScript across eight files, most of them written and debugged in the same Claude Code session that was testing them. The server works. The next step is finding out if the output is worth anything.