<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="/feed.xml" rel="self" type="application/atom+xml" /><link href="/" rel="alternate" type="text/html" /><updated>2026-05-29T17:40:25+00:00</updated><id>/feed.xml</id><title type="html">Tiago Melo</title><subtitle>Technical articles, tricks and general tips regarding software development</subtitle><entry><title type="html">Building a streaming LLM API in Go with Ollama — and watching it run from a SwiftUI iOS app</title><link href="/golang/ollama/llm/streaming/ios/2026/05/11/go-llm-api-streaming.html" rel="alternate" type="text/html" title="Building a streaming LLM API in Go with Ollama — and watching it run from a SwiftUI iOS app" /><published>2026-05-11T09:30:00+00:00</published><updated>2026-05-11T09:30:00+00:00</updated><id>/golang/ollama/llm/streaming/ios/2026/05/11/go-llm-api-streaming</id><content type="html" xml:base="/golang/ollama/llm/streaming/ios/2026/05/11/go-llm-api-streaming.html"><![CDATA[<p><img src="/assets/images/2026-05-08-go-llm-api-streaming/banner.png" alt="banner" /></p>

<p>In this post, we’ll build a small Go REST API that wraps a local <a href="https://ollama.com">Ollama</a> instance and exposes three endpoints:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">GET /api/v1/models</code> — list locally available models</li>
  <li><code class="language-plaintext highlighter-rouge">POST /api/v1/generate</code> — non-streaming generation</li>
  <li><code class="language-plaintext highlighter-rouge">POST /api/v1/generate/stream</code> — token-by-token generation over <strong>Server-Sent Events</strong></li>
</ul>

<p>Then we’ll watch the API in action from a small SwiftUI iOS app.</p>

<p>The full backend code is at <a href="https://github.com/tiagomelo/go-llm-api">https://github.com/tiagomelo/go-llm-api</a>.</p>

<blockquote>
  <p>Side note: I didn’t start this project from a blank slate. I bootstrapped it from my own open-source Go REST API template, <a href="https://github.com/tiagomelo/go-templates/tree/main/example-rest-api"><code class="language-plaintext highlighter-rouge">go-templates/example-rest-api</code></a> — it already wires up routing, structured logging, middleware, graceful shutdown, Swagger generation, and a sensible <code class="language-plaintext highlighter-rouge">Makefile</code>. That saved me an evening of boilerplate and let me focus on the Ollama-specific bits.</p>
</blockquote>

<hr />

<h2 id="what-were-building">What we’re building</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌────────────────┐      HTTP / SSE       ┌──────────────┐      HTTP        ┌──────────┐
│  SwiftUI app   │  ───────────────────► │   Go API     │ ───────────────► │  Ollama  │
│  (iOS / iPad)  │  ◄─────────────────── │  (this repo) │ ◄─────────────── │ (Docker) │
└────────────────┘  data: {"response":…} └──────────────┘  ndjson chunks   └──────────┘
</code></pre></div></div>

<p>The Go API is the only thing that talks to Ollama. The iOS app talks to the Go API. Streaming flows end-to-end: as Ollama emits each token, the Go server forwards it as an SSE frame, and the iOS app appends it to the screen as it arrives.</p>

<hr />

<h2 id="running-ollama">Running Ollama</h2>

<p>The <code class="language-plaintext highlighter-rouge">.env</code> file:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Ollama Docker Configuration
OLLAMA_CONTAINER_NAME=ollama
OLLAMA_HOST=localhost
OLLAMA_PORT=11434
OLLAMA_MODEL_NAME=llama3.2:1b
DOCKER_NETWORK_NAME=ollama_network

# Ollama HTTP Client Configuration
OLLAMA_HTTP_CLIENT_TIMEOUT_SECONDS=30               
OLLAMA_HTTP_CLIENT_KEEP_ALIVE_SECONDS=30
OLLAMA_HTTP_CLIENT_IDLE_CONN_TIMEOUT_SECONDS=90
OLLAMA_HTTP_CLIENT_TLS_HANDSHAKE_TIMEOUT_SECONDS=10
OLLAMA_HTTP_CLIENT_EXPECT_CONTINUE_TIMEOUT_SECONDS=1

# Go LLM API Configuration
GO_LLM_API_PORT=4000
</code></pre></div></div>

<p>A minimal <code class="language-plaintext highlighter-rouge">docker-compose.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">ollama</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ollama/ollama:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">${OLLAMA_CONTAINER_NAME}</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${OLLAMA_PORT}:${OLLAMA_PORT}"</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">ollama-data:/root/.ollama</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">observability</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">ollama-data</span><span class="pi">:</span>

<span class="na">networks</span><span class="pi">:</span>
  <span class="na">observability</span><span class="pi">:</span>
    <span class="na">driver</span><span class="pi">:</span> <span class="s">bridge</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">${DOCKER_NETWORK_NAME}</span>
</code></pre></div></div>

<p>Pull a small model:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make download-model
</code></pre></div></div>

<p>This runs <code class="language-plaintext highlighter-rouge">ollama pull</code> inside the container and caches the weights in the <code class="language-plaintext highlighter-rouge">ollama-data</code> volume.</p>

<hr />

<h2 id="the-ollama-client">The Ollama client</h2>

<p>A thin wrapper that knows three things — list models, generate, and stream-generate.</p>

<p>The streaming version is the interesting one. It returns two channels: chunks and errors.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">GenerateStreamChunk</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Response</span> <span class="kt">string</span> <span class="s">`json:"response"`</span>
	<span class="n">Done</span>     <span class="kt">bool</span>   <span class="s">`json:"done"`</span>
<span class="p">}</span>

<span class="k">func</span> <span class="p">(</span><span class="n">c</span> <span class="n">Client</span><span class="p">)</span> <span class="n">GenerateStream</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">model</span><span class="p">,</span> <span class="n">prompt</span> <span class="kt">string</span><span class="p">,</span> <span class="n">context</span> <span class="o">...</span><span class="kt">int</span><span class="p">)</span> <span class="p">(</span><span class="o">&lt;-</span><span class="k">chan</span> <span class="n">GenerateStreamChunk</span><span class="p">,</span> <span class="o">&lt;-</span><span class="k">chan</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="n">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="n">GenerateStreamChunk</span><span class="p">)</span>
	<span class="n">errCh</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="kt">error</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>

	<span class="k">go</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
		<span class="c">// Closing both channels on exit lets the consumer's `range` loop</span>
		<span class="c">// terminate and signals "no more error coming" on errCh.</span>
		<span class="k">defer</span> <span class="nb">close</span><span class="p">(</span><span class="n">out</span><span class="p">)</span>
		<span class="k">defer</span> <span class="nb">close</span><span class="p">(</span><span class="n">errCh</span><span class="p">)</span>

		<span class="n">reqBody</span> <span class="o">:=</span> <span class="n">GenerateParams</span><span class="p">{</span>
			<span class="n">Model</span><span class="o">:</span>   <span class="n">model</span><span class="p">,</span>
			<span class="n">Prompt</span><span class="o">:</span>  <span class="n">prompt</span><span class="p">,</span>
			<span class="n">Context</span><span class="o">:</span> <span class="n">context</span><span class="p">,</span>
			<span class="n">Stream</span><span class="o">:</span>  <span class="no">true</span><span class="p">,</span> <span class="c">// Ollama returns NDJSON, one JSON object per line.</span>
		<span class="p">}</span>

		<span class="c">// json.Marshal should not fail for this struct; the check exists so</span>
		<span class="c">// that if it ever does we surface it instead of silently swallowing.</span>
		<span class="n">b</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">json</span><span class="o">.</span><span class="n">Marshal</span><span class="p">(</span><span class="n">reqBody</span><span class="p">)</span>
		<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
			<span class="n">errCh</span> <span class="o">&lt;-</span> <span class="n">errors</span><span class="o">.</span><span class="n">WithMessage</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="s">"failed to marshal request body"</span><span class="p">)</span>
			<span class="k">return</span>
		<span class="p">}</span>

		<span class="c">// Attaching ctx to the request lets `httpClient.Do` and the body reader</span>
		<span class="c">// abort cleanly if the caller cancels — that's what makes Stop work.</span>
		<span class="n">req</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">requestBuilderProvider</span><span class="o">.</span><span class="n">NewRequestWithContext</span><span class="p">(</span>
			<span class="n">ctx</span><span class="p">,</span>
			<span class="n">http</span><span class="o">.</span><span class="n">MethodPost</span><span class="p">,</span>
			<span class="n">c</span><span class="o">.</span><span class="n">baseURL</span><span class="o">+</span><span class="s">"/generate"</span><span class="p">,</span>
			<span class="n">bytes</span><span class="o">.</span><span class="n">NewReader</span><span class="p">(</span><span class="n">b</span><span class="p">),</span>
		<span class="p">)</span>
		<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
			<span class="n">errCh</span> <span class="o">&lt;-</span> <span class="n">errors</span><span class="o">.</span><span class="n">WithMessage</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="s">"failed to create request"</span><span class="p">)</span>
			<span class="k">return</span>
		<span class="p">}</span>
		<span class="n">req</span><span class="o">.</span><span class="n">Header</span><span class="o">.</span><span class="n">Set</span><span class="p">(</span><span class="s">"Content-Type"</span><span class="p">,</span> <span class="s">"application/json"</span><span class="p">)</span>

		<span class="n">resp</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">c</span><span class="o">.</span><span class="n">httpClient</span><span class="o">.</span><span class="n">Do</span><span class="p">(</span><span class="n">req</span><span class="p">)</span>
		<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
			<span class="c">// On transport error, drain and close any partial body so the</span>
			<span class="c">// underlying connection can be returned to the pool instead of</span>
			<span class="c">// being orphaned.</span>
			<span class="k">if</span> <span class="n">resp</span> <span class="o">!=</span> <span class="no">nil</span> <span class="o">&amp;&amp;</span> <span class="n">resp</span><span class="o">.</span><span class="n">Body</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
				<span class="n">io</span><span class="o">.</span><span class="n">Copy</span><span class="p">(</span><span class="n">io</span><span class="o">.</span><span class="n">Discard</span><span class="p">,</span> <span class="n">resp</span><span class="o">.</span><span class="n">Body</span><span class="p">)</span>
				<span class="n">resp</span><span class="o">.</span><span class="n">Body</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span>
			<span class="p">}</span>
			<span class="n">errCh</span> <span class="o">&lt;-</span> <span class="n">errors</span><span class="o">.</span><span class="n">WithMessage</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="s">"failed to execute request"</span><span class="p">)</span>
			<span class="k">return</span>
		<span class="p">}</span>
		<span class="k">defer</span> <span class="n">resp</span><span class="o">.</span><span class="n">Body</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span>

		<span class="k">if</span> <span class="n">isUnsuccessfulStatusCode</span><span class="p">(</span><span class="n">resp</span><span class="o">.</span><span class="n">StatusCode</span><span class="p">)</span> <span class="p">{</span>
			<span class="n">errCh</span> <span class="o">&lt;-</span> <span class="n">errors</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"unexpected status code: %d"</span><span class="p">,</span> <span class="n">resp</span><span class="o">.</span><span class="n">StatusCode</span><span class="p">)</span>
			<span class="k">return</span>
		<span class="p">}</span>

		<span class="c">// json.Decoder happily reads one JSON object at a time from a stream,</span>
		<span class="c">// which matches Ollama's NDJSON output exactly — no manual line</span>
		<span class="c">// splitting needed.</span>
		<span class="n">decoder</span> <span class="o">:=</span> <span class="n">json</span><span class="o">.</span><span class="n">NewDecoder</span><span class="p">(</span><span class="n">resp</span><span class="o">.</span><span class="n">Body</span><span class="p">)</span>

		<span class="k">for</span> <span class="p">{</span>
			<span class="k">var</span> <span class="n">chunk</span> <span class="n">GenerateStreamChunk</span>

			<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">decoder</span><span class="o">.</span><span class="n">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="n">chunk</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
				<span class="c">// io.EOF is the normal end-of-stream signal; everything else</span>
				<span class="c">// is a real decode/transport failure worth reporting.</span>
				<span class="k">if</span> <span class="n">err</span> <span class="o">==</span> <span class="n">io</span><span class="o">.</span><span class="n">EOF</span> <span class="p">{</span>
					<span class="k">return</span>
				<span class="p">}</span>
				<span class="n">errCh</span> <span class="o">&lt;-</span> <span class="n">errors</span><span class="o">.</span><span class="n">WithMessage</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="s">"failed to decode stream chunk"</span><span class="p">)</span>
				<span class="k">return</span>
			<span class="p">}</span>

			<span class="c">// Send the chunk, but stay cancellable: if the consumer is gone</span>
			<span class="c">// or the caller cancelled ctx, exit promptly instead of blocking</span>
			<span class="c">// forever on `out &lt;- chunk`.</span>
			<span class="k">select</span> <span class="p">{</span>
			<span class="k">case</span> <span class="n">out</span> <span class="o">&lt;-</span> <span class="n">chunk</span><span class="o">:</span>
			<span class="k">case</span> <span class="o">&lt;-</span><span class="n">ctx</span><span class="o">.</span><span class="n">Done</span><span class="p">()</span><span class="o">:</span>
				<span class="n">errCh</span> <span class="o">&lt;-</span> <span class="n">ctx</span><span class="o">.</span><span class="n">Err</span><span class="p">()</span>
				<span class="k">return</span>
			<span class="p">}</span>

			<span class="c">// Ollama marks the last chunk with done=true. Returning here</span>
			<span class="c">// closes `out` via the deferred close and ends the consumer's</span>
			<span class="c">// range loop cleanly.</span>
			<span class="k">if</span> <span class="n">chunk</span><span class="o">.</span><span class="n">Done</span> <span class="p">{</span>
				<span class="k">return</span>
			<span class="p">}</span>
		<span class="p">}</span>
	<span class="p">}()</span>

	<span class="k">return</span> <span class="n">out</span><span class="p">,</span> <span class="n">errCh</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two things to notice:</p>

<ol>
  <li>We use <code class="language-plaintext highlighter-rouge">json.Decoder.Decode</code> in a loop — Ollama emits <a href="https://en.wikipedia.org/wiki/JSON_streaming"><strong>NDJSON</strong></a> with <code class="language-plaintext highlighter-rouge">stream: true</code>, one JSON object per line, and the decoder happily reads them one at a time.</li>
  <li>The <code class="language-plaintext highlighter-rouge">select</code> lets the consumer cancel mid-stream. Pass a cancellable context, cancel it, and the goroutine exits cleanly without leaking the connection.</li>
</ol>

<hr />

<h2 id="the-http-handler">The HTTP handler</h2>

<p>The handler converts those Go channels into an SSE stream the browser/app can consume:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">w</span><span class="o">.</span><span class="n">Header</span><span class="p">()</span><span class="o">.</span><span class="n">Set</span><span class="p">(</span><span class="s">"Content-Type"</span><span class="p">,</span> <span class="s">"text/event-stream"</span><span class="p">)</span>
<span class="n">w</span><span class="o">.</span><span class="n">Header</span><span class="p">()</span><span class="o">.</span><span class="n">Set</span><span class="p">(</span><span class="s">"Cache-Control"</span><span class="p">,</span> <span class="s">"no-cache"</span><span class="p">)</span>
<span class="n">w</span><span class="o">.</span><span class="n">Header</span><span class="p">()</span><span class="o">.</span><span class="n">Set</span><span class="p">(</span><span class="s">"Connection"</span><span class="p">,</span> <span class="s">"keep-alive"</span><span class="p">)</span>
<span class="n">w</span><span class="o">.</span><span class="n">Header</span><span class="p">()</span><span class="o">.</span><span class="n">Set</span><span class="p">(</span><span class="s">"X-Accel-Buffering"</span><span class="p">,</span> <span class="s">"no"</span><span class="p">)</span>

<span class="n">flusher</span><span class="p">,</span> <span class="n">ok</span> <span class="o">:=</span> <span class="n">w</span><span class="o">.</span><span class="p">(</span><span class="n">http</span><span class="o">.</span><span class="n">Flusher</span><span class="p">)</span>
<span class="k">if</span> <span class="o">!</span><span class="n">ok</span> <span class="p">{</span>
	<span class="n">web</span><span class="o">.</span><span class="n">RespondWithError</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="n">http</span><span class="o">.</span><span class="n">StatusInternalServerError</span><span class="p">,</span> <span class="s">"streaming not supported"</span><span class="p">)</span>
	<span class="k">return</span>
<span class="p">}</span>

<span class="n">w</span><span class="o">.</span><span class="n">WriteHeader</span><span class="p">(</span><span class="n">http</span><span class="o">.</span><span class="n">StatusOK</span><span class="p">)</span>
<span class="n">flusher</span><span class="o">.</span><span class="n">Flush</span><span class="p">()</span>

<span class="n">chunks</span><span class="p">,</span> <span class="n">errs</span> <span class="o">:=</span> <span class="n">h</span><span class="o">.</span><span class="n">client</span><span class="o">.</span><span class="n">GenerateStream</span><span class="p">(</span>
	<span class="n">r</span><span class="o">.</span><span class="n">Context</span><span class="p">(),</span>
	<span class="n">generateReq</span><span class="o">.</span><span class="n">Model</span><span class="p">,</span>
	<span class="n">generateReq</span><span class="o">.</span><span class="n">Prompt</span><span class="p">,</span>
	<span class="n">generateReq</span><span class="o">.</span><span class="n">Context</span><span class="o">...</span><span class="p">,</span>
<span class="p">)</span>

<span class="k">for</span> <span class="p">{</span>
	<span class="k">select</span> <span class="p">{</span>
	<span class="k">case</span> <span class="n">chunk</span><span class="p">,</span> <span class="n">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="n">chunks</span><span class="o">:</span>
		<span class="k">if</span> <span class="o">!</span><span class="n">ok</span> <span class="p">{</span>
			<span class="k">return</span>
		<span class="p">}</span>

		<span class="k">if</span> <span class="n">chunk</span><span class="o">.</span><span class="n">Done</span> <span class="p">{</span>
			<span class="n">fmt</span><span class="o">.</span><span class="n">Fprintf</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="s">"event: done</span><span class="se">\n</span><span class="s">data: {</span><span class="se">\"</span><span class="s">done</span><span class="se">\"</span><span class="s">:true}</span><span class="se">\n\n</span><span class="s">"</span><span class="p">)</span>
			<span class="n">flusher</span><span class="o">.</span><span class="n">Flush</span><span class="p">()</span>
			<span class="k">return</span>
		<span class="p">}</span>

		<span class="c">// json.Marshal should not fail for this struct (only string/bool fields).</span>
		<span class="c">// This is a defensive check to avoid breaking the SSE stream on unexpected changes.</span>
		<span class="n">b</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">json</span><span class="o">.</span><span class="n">Marshal</span><span class="p">(</span><span class="n">chunk</span><span class="p">)</span>
		<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
			<span class="n">fmt</span><span class="o">.</span><span class="n">Fprintf</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="s">"event: error</span><span class="se">\n</span><span class="s">data: {</span><span class="se">\"</span><span class="s">error</span><span class="se">\"</span><span class="s">:</span><span class="se">\"</span><span class="s">failed to marshal stream chunk</span><span class="se">\"</span><span class="s">}</span><span class="se">\n\n</span><span class="s">"</span><span class="p">)</span>
			<span class="n">flusher</span><span class="o">.</span><span class="n">Flush</span><span class="p">()</span>
			<span class="k">return</span>
		<span class="p">}</span>

		<span class="n">fmt</span><span class="o">.</span><span class="n">Fprintf</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="s">"data: %s</span><span class="se">\n\n</span><span class="s">"</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span>
		<span class="n">flusher</span><span class="o">.</span><span class="n">Flush</span><span class="p">()</span>

	<span class="k">case</span> <span class="n">err</span><span class="p">,</span> <span class="n">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="n">errs</span><span class="o">:</span>
		<span class="k">if</span> <span class="n">ok</span> <span class="o">&amp;&amp;</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
			<span class="n">b</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">json</span><span class="o">.</span><span class="n">Marshal</span><span class="p">(</span><span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span><span class="p">{</span>
				<span class="s">"error"</span><span class="o">:</span> <span class="n">err</span><span class="o">.</span><span class="n">Error</span><span class="p">(),</span>
			<span class="p">})</span>
			<span class="n">fmt</span><span class="o">.</span><span class="n">Fprintf</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="s">"event: error</span><span class="se">\n</span><span class="s">data: %s</span><span class="se">\n\n</span><span class="s">"</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span>
			<span class="n">flusher</span><span class="o">.</span><span class="n">Flush</span><span class="p">()</span>
			<span class="k">return</span>
		<span class="p">}</span>

	<span class="k">case</span> <span class="o">&lt;-</span><span class="n">r</span><span class="o">.</span><span class="n">Context</span><span class="p">()</span><span class="o">.</span><span class="n">Done</span><span class="p">()</span><span class="o">:</span>
		<span class="k">return</span>
	<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The wire format ends up being:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>data: {"response":"Hello","done":false}

data: {"response":", ","done":false}

data: {"response":"world!","done":false}

event: done
data: {"done":true}
</code></pre></div></div>

<p>Each <code class="language-plaintext highlighter-rouge">data:</code> frame is one token. The terminating <code class="language-plaintext highlighter-rouge">event: done</code> tells the client to stop.</p>

<hr />

<h2 id="a-subtle-gotcha-gzip-vs-sse">A subtle gotcha: gzip vs SSE</h2>

<p>The first time I tested this end-to-end, the iOS app showed nothing. The Ollama logs showed a successful 12-second generation, but no tokens appeared on the device — and then the entire response appeared at the very end.</p>

<p>The cause? A piece of middleware applied globally:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">router</span><span class="o">.</span><span class="n">Use</span><span class="p">(</span>
    <span class="n">middleware</span><span class="o">.</span><span class="n">Logger</span><span class="p">(</span><span class="n">c</span><span class="o">.</span><span class="n">Log</span><span class="p">),</span>
    <span class="n">middleware</span><span class="o">.</span><span class="n">Compress</span><span class="p">,</span>        <span class="c">// this one</span>
    <span class="n">middleware</span><span class="o">.</span><span class="n">PanicRecovery</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">middleware.Compress</code> is a thin wrapper around <code class="language-plaintext highlighter-rouge">gorilla/handlers.CompressHandler</code>. It gzip-compresses every response. Combined with SSE, that means:</p>

<ul>
  <li>Each <code class="language-plaintext highlighter-rouge">flusher.Flush()</code> call flushes into the <strong>gzip</strong> writer, not the network socket.</li>
  <li>The gzip writer waits for enough data to compress before flushing to the socket.</li>
  <li>The client receives nothing until the server closes the stream.</li>
</ul>

<blockquote>
  <p>Compression and streaming are mutually exclusive. Pick one per response.</p>
</blockquote>

<p>The fix is to skip compression for SSE clients:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">Compress</span><span class="p">(</span><span class="n">next</span> <span class="n">http</span><span class="o">.</span><span class="n">Handler</span><span class="p">)</span> <span class="n">http</span><span class="o">.</span><span class="n">Handler</span> <span class="p">{</span>
    <span class="n">compressed</span> <span class="o">:=</span> <span class="n">handlers</span><span class="o">.</span><span class="n">CompressHandler</span><span class="p">(</span><span class="n">next</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">http</span><span class="o">.</span><span class="n">HandlerFunc</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">w</span> <span class="n">http</span><span class="o">.</span><span class="n">ResponseWriter</span><span class="p">,</span> <span class="n">r</span> <span class="o">*</span><span class="n">http</span><span class="o">.</span><span class="n">Request</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">strings</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">Header</span><span class="o">.</span><span class="n">Get</span><span class="p">(</span><span class="s">"Accept"</span><span class="p">),</span> <span class="s">"text/event-stream"</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">next</span><span class="o">.</span><span class="n">ServeHTTP</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="n">r</span><span class="p">)</span>
            <span class="k">return</span>
        <span class="p">}</span>
        <span class="n">compressed</span><span class="o">.</span><span class="n">ServeHTTP</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="n">r</span><span class="p">)</span>
    <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Clients that ask for <code class="language-plaintext highlighter-rouge">text/event-stream</code> go straight through; everyone else still gets gzip.</p>

<hr />

<h2 id="another-subtle-gotcha-httpclienttimeout-vs-streaming">Another subtle gotcha: <code class="language-plaintext highlighter-rouge">http.Client.Timeout</code> vs streaming</h2>

<p>The next surprise came once the iOS app worked for <em>short</em> answers. Anything longer than ~30 seconds got cut off mid-stream with this error bubbling up from the SSE error frame:</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">Stream error: failed to decode stream chunk: context deadline exceeded (Client.Timeout or context cancellation while reading body)</code></p>
</blockquote>

<p><img src="/assets/images/2026-05-08-go-llm-api-streaming/error.png" alt="error" /></p>

<p>The culprit was the innocent-looking constructor I had for the Ollama HTTP client:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">newHTTPClient</span> <span class="n">httpClientFactory</span> <span class="o">=</span> <span class="k">func</span><span class="p">(</span><span class="n">timeout</span> <span class="n">time</span><span class="o">.</span><span class="n">Duration</span><span class="p">)</span> <span class="n">httpClient</span> <span class="p">{</span>
    <span class="k">return</span> <span class="o">&amp;</span><span class="n">http</span><span class="o">.</span><span class="n">Client</span><span class="p">{</span><span class="n">Timeout</span><span class="o">:</span> <span class="n">timeout</span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">Timeout</code> field looks like it would only bound “how long do I wait for the server to <em>respond</em>” — but it actually covers the <strong>entire</strong> request lifetime: connection setup, TLS, headers, <em>and</em> body. So as soon as a streaming body ran past 30s, the client tore the connection down, exactly the same way it would for an unresponsive server.</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">http.Client.Timeout</code> is a wall-clock cap on the whole request, body reads included. It’s the wrong knob for streaming.</p>
</blockquote>

<p>The fix is to push the timeout down to the <code class="language-plaintext highlighter-rouge">Transport</code>, where it can apply only to phases that <em>should</em> be bounded — dialing and reading response headers — and leave the body untouched:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">newHTTPClient</span> <span class="n">httpClientFactory</span> <span class="o">=</span> <span class="k">func</span><span class="p">(</span><span class="n">params</span> <span class="n">HTTPClientParams</span><span class="p">)</span> <span class="n">httpClient</span> <span class="p">{</span>
	<span class="k">return</span> <span class="o">&amp;</span><span class="n">http</span><span class="o">.</span><span class="n">Client</span><span class="p">{</span>
		<span class="n">Transport</span><span class="o">:</span> <span class="o">&amp;</span><span class="n">http</span><span class="o">.</span><span class="n">Transport</span><span class="p">{</span>
			<span class="n">Proxy</span><span class="o">:</span> <span class="n">http</span><span class="o">.</span><span class="n">ProxyFromEnvironment</span><span class="p">,</span>
			<span class="n">DialContext</span><span class="o">:</span> <span class="p">(</span><span class="o">&amp;</span><span class="n">net</span><span class="o">.</span><span class="n">Dialer</span><span class="p">{</span>
				<span class="n">Timeout</span><span class="o">:</span>   <span class="n">params</span><span class="o">.</span><span class="n">Timeout</span><span class="p">,</span>
				<span class="n">KeepAlive</span><span class="o">:</span> <span class="n">params</span><span class="o">.</span><span class="n">KeepAlive</span><span class="p">,</span>
			<span class="p">})</span><span class="o">.</span><span class="n">DialContext</span><span class="p">,</span>
			<span class="n">ResponseHeaderTimeout</span><span class="o">:</span> <span class="n">params</span><span class="o">.</span><span class="n">Timeout</span><span class="p">,</span>
			<span class="n">IdleConnTimeout</span><span class="o">:</span>       <span class="n">params</span><span class="o">.</span><span class="n">IdleConnTimeout</span><span class="p">,</span>
			<span class="n">TLSHandshakeTimeout</span><span class="o">:</span>   <span class="n">params</span><span class="o">.</span><span class="n">TLSHandshakeTimeout</span><span class="p">,</span>
			<span class="n">ExpectContinueTimeout</span><span class="o">:</span> <span class="n">params</span><span class="o">.</span><span class="n">ExpectContinueTimeout</span><span class="p">,</span>
		<span class="p">},</span>
	<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now <code class="language-plaintext highlighter-rouge">OLLAMA_HTTP_CLIENT_TIMEOUT_SECONDS=30</code> means <em>“give up if Ollama doesn’t even start answering within 30s”</em>, not <em>“give up after 30s of streaming.”</em> The body can take as long as the model takes; the per-request <code class="language-plaintext highlighter-rouge">context.Context</code> is what actually bounds it. That’s also what keeps the iOS <strong>Stop</strong> button working — cancel the ctx, the read aborts, the goroutine exits cleanly.</p>

<hr />

<h2 id="verifying-with-curl">Verifying with <code class="language-plaintext highlighter-rouge">curl</code></h2>

<p>Before bringing in any client, make sure the server actually streams:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-N</span> <span class="nt">-s</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Accept: text/event-stream"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-X</span> POST http://localhost:4000/api/v1/generate/stream <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"model":"llama3.2:1b","prompt":"say hello"}'</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">-N</code> flag is essential — without it, curl buffers the response and you’ll see the same one-shot behavior the broken middleware gave us. You should see frames trickle in:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>data: {"response":"Hello","done":false}

data: {"response":".","done":false}

event: done
data: {"done":true}
</code></pre></div></div>

<p>If that works, the problem from there on is on the client side.</p>

<hr />

<h2 id="swagger-documentation">Swagger documentation</h2>

<p>Because the project is built on top of <a href="https://github.com/tiagomelo/go-templates/tree/main/example-rest-api"><code class="language-plaintext highlighter-rouge">go-templates/example-rest-api</code></a>, Swagger generation is already wired up — I just had to annotate the new Ollama handlers. Two <code class="language-plaintext highlighter-rouge">make</code> targets do everything:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make swagger      <span class="c"># regenerate doc/swagger.json from code annotations</span>
make swagger-ui   <span class="c"># launch Swagger UI in Docker on port 80</span>
</code></pre></div></div>

<p>Once it’s up, <a href="http://localhost">http://localhost</a> becomes an interactive playground for all three endpoints, including the streaming one — you can hit <em>Try it out</em> and watch the SSE frames come back live:</p>

<p><img src="/assets/images/2026-05-08-go-llm-api-streaming/swagger.png" alt="swagger UI" /></p>

<p>Having Swagger essentially for free was the main reason I started from the template instead of from scratch.</p>

<hr />

<h2 id="tests">Tests</h2>

<p>Both the Ollama client and the HTTP handler have full unit-test coverage. The streaming tests in particular are worth a look because they exercise an <code class="language-plaintext highlighter-rouge">AsyncThrowingStream</code>-like Go pattern: drain the chunks channel via <code class="language-plaintext highlighter-rouge">for chunk := range out</code>, then read the error channel afterwards.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">out</span><span class="p">,</span> <span class="n">errCh</span> <span class="o">:=</span> <span class="n">client</span><span class="o">.</span><span class="n">GenerateStream</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"llama3.2:1b"</span><span class="p">,</span> <span class="s">"Hello"</span><span class="p">)</span>
<span class="k">for</span> <span class="n">chunk</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">out</span> <span class="p">{</span>
    <span class="n">chunks</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">chunks</span><span class="p">,</span> <span class="n">chunk</span><span class="p">)</span>
<span class="p">}</span>
<span class="n">require</span><span class="o">.</span><span class="n">NoError</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="o">&lt;-</span><span class="n">errCh</span><span class="p">)</span>
</code></pre></div></div>

<p>There’s also a context-cancellation test that verifies the goroutine cleanly returns <code class="language-plaintext highlighter-rouge">ctx.Err()</code> mid-stream — important because a hung stream goroutine is a slow leak.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">ctx</span><span class="p">,</span> <span class="n">cancel</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">WithCancel</span><span class="p">(</span><span class="n">context</span><span class="o">.</span><span class="n">Background</span><span class="p">())</span>
<span class="n">out</span><span class="p">,</span> <span class="n">errCh</span> <span class="o">:=</span> <span class="n">client</span><span class="o">.</span><span class="n">GenerateStream</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"llama3.2:1b"</span><span class="p">,</span> <span class="s">"Hello"</span><span class="p">)</span>

<span class="n">first</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="n">out</span>
<span class="n">cancel</span><span class="p">()</span>

<span class="k">for</span> <span class="k">range</span> <span class="n">out</span> <span class="p">{}</span>
<span class="n">err</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="n">errCh</span>
<span class="n">require</span><span class="o">.</span><span class="n">ErrorIs</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">err</span><span class="p">,</span> <span class="n">context</span><span class="o">.</span><span class="n">Canceled</span><span class="p">)</span>
</code></pre></div></div>

<hr />

<h2 id="a-note-on-coverage">A note on coverage</h2>

<p>The first time I checked the HTML coverage report for these tests, half the covered branches showed up in <strong>grey</strong> — easy to mistake for uncovered code. Turns out:</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">go test -race</code> forces <code class="language-plaintext highlighter-rouge">-covermode=atomic</code>, even if you set <code class="language-plaintext highlighter-rouge">-covermode=set</code> explicitly. Atomic mode produces frequency-tinted output: grey is “covered once.”</p>
</blockquote>

<p>The fix is to split the make targets — <code class="language-plaintext highlighter-rouge">test</code> runs the race detector, <code class="language-plaintext highlighter-rouge">coverage</code> runs without it:</p>

<div class="language-makefile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">test</span><span class="o">:</span>
	<span class="p">@</span> go <span class="nb">test</span> <span class="nt">-v</span> <span class="nt">-race</span> ./... <span class="nt">-count</span><span class="o">=</span>1

<span class="nl">coverage</span><span class="o">:</span>
	<span class="p">@</span> go <span class="nb">test</span> <span class="nt">-covermode</span><span class="o">=</span><span class="nb">set</span> <span class="nt">-count</span><span class="o">=</span>1 <span class="se">\</span>
	    <span class="nt">-coverpkg</span><span class="o">=</span><span class="err">$$</span><span class="o">(</span>go list ./... | <span class="nb">tr</span> <span class="s1">'\n'</span> <span class="s1">','</span><span class="o">)</span> <span class="se">\</span>
	    <span class="nt">-coverprofile</span><span class="o">=</span>coverage.out ./...
	<span class="p">@</span> go tool cover <span class="nt">-html</span><span class="o">=</span>coverage.out <span class="nt">-o</span> coverage.html
</code></pre></div></div>

<p>Now the report is binary: red for uncovered, green for covered.</p>

<hr />

<h2 id="seeing-it-in-action">Seeing it in action</h2>

<p>Time for the fun part. The iOS app is a small SwiftUI chat client:</p>

<ul>
  <li>a <strong>Model</strong> picker at the top, populated from <code class="language-plaintext highlighter-rouge">/api/v1/models</code>,</li>
  <li>a scrollable chat history with user and assistant bubbles,</li>
  <li>a message input with a combined <strong>Send / Stop</strong> button,</li>
  <li>a <strong>Stream response</strong> toggle that decides which endpoint to call:
    <ul>
      <li>on → <code class="language-plaintext highlighter-rouge">/api/v1/generate/stream</code> (SSE, token-by-token),</li>
      <li>off → <code class="language-plaintext highlighter-rouge">/api/v1/generate</code> (single JSON response).</li>
    </ul>
  </li>
  <li>a trash icon in the nav bar to wipe the chat.</li>
</ul>

<p>I started the backend, tailed its logs, and drove the app from the simulator. Each interaction below is paired with the matching server-side entries.</p>

<h3 id="booting-the-api">Booting the API</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>make run-api
<span class="o">[</span>+] Running 1/0
 ✔ Container ollama  Running                                              0.0s
<span class="o">{</span><span class="s2">"time"</span>:<span class="s2">"2026-05-12T09:24:24.145201-03:00"</span>,<span class="s2">"level"</span>:<span class="s2">"INFO"</span>,<span class="s2">"msg"</span>:<span class="s2">"Config read successfully: &amp;{ollama localhost 11434 30 ollama_network}"</span><span class="o">}</span>
<span class="o">{</span><span class="s2">"time"</span>:<span class="s2">"2026-05-12T09:24:24.145366-03:00"</span>,<span class="s2">"level"</span>:<span class="s2">"INFO"</span>,<span class="s2">"msg"</span>:<span class="s2">"API listening on :4000"</span><span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">make run-api</code> makes sure the Ollama container is up, then starts the Go server. Within seconds the iOS app opens and pulls the model list to populate the <strong>Model</strong> picker:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"time"</span><span class="p">:</span><span class="s2">"2026-05-12T09:24:42.97345-03:00"</span><span class="p">,</span><span class="nl">"level"</span><span class="p">:</span><span class="s2">"INFO"</span><span class="p">,</span><span class="nl">"msg"</span><span class="p">:</span><span class="s2">"request started"</span><span class="p">,</span><span class="nl">"method"</span><span class="p">:</span><span class="s2">"GET"</span><span class="p">,</span><span class="nl">"path"</span><span class="p">:</span><span class="s2">"/api/v1/models"</span><span class="p">,</span><span class="nl">"remoteaddr"</span><span class="p">:</span><span class="s2">"[::1]:62071"</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"time"</span><span class="p">:</span><span class="s2">"2026-05-12T09:24:43.00456-03:00"</span><span class="p">,</span><span class="nl">"level"</span><span class="p">:</span><span class="s2">"INFO"</span><span class="p">,</span><span class="nl">"msg"</span><span class="p">:</span><span class="s2">"request completed"</span><span class="p">,</span><span class="nl">"method"</span><span class="p">:</span><span class="s2">"GET"</span><span class="p">,</span><span class="nl">"path"</span><span class="p">:</span><span class="s2">"/api/v1/models"</span><span class="p">,</span><span class="nl">"remoteaddr"</span><span class="p">:</span><span class="s2">"[::1]:62071"</span><span class="p">,</span><span class="nl">"since"</span><span class="p">:</span><span class="mi">33329000</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>(<code class="language-plaintext highlighter-rouge">since</code> is in nanoseconds — that’s ~33 ms.)</p>

<h3 id="streaming-on--tokens-arrive-live">Streaming on — tokens arrive live</h3>

<p>With <strong>Stream response</strong> enabled, hitting Send opens an SSE connection to <code class="language-plaintext highlighter-rouge">/api/v1/generate/stream</code>. Tokens land in the chat bubble as Ollama emits them:</p>

<p><img src="/assets/images/2026-05-08-go-llm-api-streaming/gollmStream.gif" alt="streaming demo" /></p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"time"</span><span class="p">:</span><span class="s2">"2026-05-12T09:25:04.660262-03:00"</span><span class="p">,</span><span class="nl">"level"</span><span class="p">:</span><span class="s2">"INFO"</span><span class="p">,</span><span class="nl">"msg"</span><span class="p">:</span><span class="s2">"request started"</span><span class="p">,</span><span class="nl">"method"</span><span class="p">:</span><span class="s2">"POST"</span><span class="p">,</span><span class="nl">"path"</span><span class="p">:</span><span class="s2">"/api/v1/generate/stream"</span><span class="p">,</span><span class="nl">"remoteaddr"</span><span class="p">:</span><span class="s2">"[::1]:62118"</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"time"</span><span class="p">:</span><span class="s2">"2026-05-12T09:25:24.160918-03:00"</span><span class="p">,</span><span class="nl">"level"</span><span class="p">:</span><span class="s2">"INFO"</span><span class="p">,</span><span class="nl">"msg"</span><span class="p">:</span><span class="s2">"request completed"</span><span class="p">,</span><span class="nl">"method"</span><span class="p">:</span><span class="s2">"POST"</span><span class="p">,</span><span class="nl">"path"</span><span class="p">:</span><span class="s2">"/api/v1/generate/stream"</span><span class="p">,</span><span class="nl">"remoteaddr"</span><span class="p">:</span><span class="s2">"[::1]:62118"</span><span class="p">,</span><span class="nl">"since"</span><span class="p">:</span><span class="mi">19500641000</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The full generation took about 19.5 seconds end-to-end, but the user-perceived latency is essentially the time to the <em>first</em> token — the rest arrived progressively while the response was already on screen.</p>

<h3 id="streaming-off--wait-for-the-full-response">Streaming off — wait for the full response</h3>

<p>Flipping the toggle off routes the same kind of prompt through <code class="language-plaintext highlighter-rouge">/api/v1/generate</code> instead. Same model, same backend, very different experience:</p>

<p><img src="/assets/images/2026-05-08-go-llm-api-streaming/gollmNonStream.gif" alt="non-streaming demo" /></p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"time"</span><span class="p">:</span><span class="s2">"2026-05-12T09:26:59.06684-03:00"</span><span class="p">,</span><span class="nl">"level"</span><span class="p">:</span><span class="s2">"INFO"</span><span class="p">,</span><span class="nl">"msg"</span><span class="p">:</span><span class="s2">"request started"</span><span class="p">,</span><span class="nl">"method"</span><span class="p">:</span><span class="s2">"POST"</span><span class="p">,</span><span class="nl">"path"</span><span class="p">:</span><span class="s2">"/api/v1/generate"</span><span class="p">,</span><span class="nl">"remoteaddr"</span><span class="p">:</span><span class="s2">"[::1]:62410"</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"time"</span><span class="p">:</span><span class="s2">"2026-05-12T09:27:11.158067-03:00"</span><span class="p">,</span><span class="nl">"level"</span><span class="p">:</span><span class="s2">"INFO"</span><span class="p">,</span><span class="nl">"msg"</span><span class="p">:</span><span class="s2">"request completed"</span><span class="p">,</span><span class="nl">"method"</span><span class="p">:</span><span class="s2">"POST"</span><span class="p">,</span><span class="nl">"path"</span><span class="p">:</span><span class="s2">"/api/v1/generate"</span><span class="p">,</span><span class="nl">"remoteaddr"</span><span class="p">:</span><span class="s2">"[::1]:62410"</span><span class="p">,</span><span class="nl">"since"</span><span class="p">:</span><span class="mi">12091226000</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>About 12 seconds of empty screen, then the full answer lands in one piece. Same code path on the iOS side except for the URL and the decoder; from the server you don’t see any per-token activity either, because we’re just proxying a single JSON body.</p>

<p>This side-by-side is the most useful demo of the two: same Go API, two endpoints, two very different user-perceived latencies.</p>

<h3 id="stopping-mid-stream">Stopping mid-stream</h3>

<p>The Stop button cancels the iOS task, which cancels the URLSession, which closes the SSE connection — and the Go handler exits via <code class="language-plaintext highlighter-rouge">r.Context().Done()</code>:</p>

<p><img src="/assets/images/2026-05-08-go-llm-api-streaming/gollmStop.gif" alt="stop demo" /></p>

<hr />

<h2 id="final-thoughts">Final thoughts</h2>

<p>What we end up with:</p>

<ul>
  <li>A Go REST API that wraps Ollama with a clean, testable client.</li>
  <li>A real SSE endpoint with proper context cancellation and a working backpressure model via channels.</li>
  <li>A middleware chain that knows when to <em>not</em> compress.</li>
  <li>A test suite that exercises the streaming and cancellation paths the same way a real client would.</li>
  <li>A SwiftUI chat app that consumes either the streaming or the non-streaming endpoint with a single toggle (more on the iOS side in a future post).</li>
</ul>

<hr />

<h2 id="closing-insight">Closing insight</h2>

<blockquote>
  <p>Streaming is mostly about <strong>not</strong> doing things: not buffering, not compressing, not waiting. The hard part is finding which layer is doing the buffering for you, and turning it off.</p>
</blockquote>]]></content><author><name></name></author><category term="golang" /><category term="ollama" /><category term="llm" /><category term="streaming" /><category term="ios" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/2026-05-08-go-llm-api-streaming/banner.png" /><media:content medium="image" url="/assets/images/2026-05-08-go-llm-api-streaming/banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">How to use your custom MCP server with Claude Desktop</title><link href="/golang/mcp/claude/2026/04/23/using-your-mcp-server-with-claude.html" rel="alternate" type="text/html" title="How to use your custom MCP server with Claude Desktop" /><published>2026-04-23T08:52:17+00:00</published><updated>2026-04-23T08:52:17+00:00</updated><id>/golang/mcp/claude/2026/04/23/using-your-mcp-server-with-claude</id><content type="html" xml:base="/golang/mcp/claude/2026/04/23/using-your-mcp-server-with-claude.html"><![CDATA[<p><img src="/assets/images/2026-04-12-using-your-mcp-server-with-claude/banner.png" alt="banner" /></p>

<p>As a follow-up to my previous post, <a href="https://tiagomelo.info/golang/mcp/2026/04/10/go-mcp-server.html">“Building an MCP Server in Go from scratch”</a>, it’s time to actually <em>use</em> that server with a real client.</p>

<p>In this post, we’ll integrate a custom MCP server with <a href="https://claude.ai">Claude Desktop</a> and see the full flow in action: from natural language prompts to JSON-RPC calls over stdio.</p>

<h2 id="configuring-claude-desktop">Configuring Claude desktop</h2>

<p>We’ll use the MCP server introduced in the previous article: <a href="https://github.com/tiagomelo/go-mcp-server">https://github.com/tiagomelo/go-mcp-server</a>.</p>

<p>First, build the binary:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make build
</code></pre></div></div>

<p>Next, register it in Claude Desktop.</p>

<p>On macOS, edit:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/Library/Application Support/Claude/claude_desktop_config.json
</code></pre></div></div>

<p>Add:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"go-mcp-server"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/Users/tiago/go/go-mcp-server/bin/mcp-server"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Important notes:</strong></p>

<ul>
  <li>Use an <strong>absolute path</strong></li>
  <li><code class="language-plaintext highlighter-rouge">~</code> will NOT work</li>
  <li>The binary must be executable</li>
</ul>

<p>After saving, <strong>restart Claude Desktop completely</strong>.</p>

<hr />

<h2 id="understanding-what-happens">Understanding what happens</h2>

<p>When Claude starts, it:</p>

<ol>
  <li>Launches your binary</li>
  <li>Communicates via <strong>stdin/stdout</strong></li>
  <li>Uses <strong>JSON-RPC 2.0</strong></li>
  <li>
    <p>Follows the MCP lifecycle:</p>

    <ul>
      <li><code class="language-plaintext highlighter-rouge">initialize</code></li>
      <li><code class="language-plaintext highlighter-rouge">notifications/initialized</code></li>
      <li><code class="language-plaintext highlighter-rouge">tools/list</code></li>
      <li><code class="language-plaintext highlighter-rouge">tools/call</code></li>
    </ul>
  </li>
</ol>

<p>This is the key idea:</p>

<blockquote>
  <p>Claude is the MCP client. Your Go program is the MCP server.</p>
</blockquote>

<hr />

<h2 id="inspecting-logs">Inspecting logs</h2>

<p>Claude logs are extremely useful when debugging:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">tail</span> <span class="nt">-f</span> ~/Library/Logs/Claude/main.log
</code></pre></div></div>

<p>When Claude Desktop starts, it automatically attempts to connect to the configured MCP servers:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>2026-04-12 12:53:22 <span class="o">[</span>info] MCP Server connection requested <span class="k">for</span>: go-mcp-server
2026-04-12 12:53:22 <span class="o">[</span>info] Running initial blocklist check triggered by ConnectToServer
2026-04-12 12:53:22 <span class="o">[</span>info] Launching MCP Server: go-mcp-server
2026-04-12 12:53:22 <span class="o">[</span>info] MCP Server connection requested <span class="k">for</span>: mcp-registry
2026-04-12 12:53:22 <span class="o">[</span>info] MCP Server connection requested <span class="k">for</span>: Claude <span class="k">in </span>Chrome
</code></pre></div></div>

<p>You can also inspect the server-specific logs:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">tail</span> <span class="nt">-f</span> ~/Library/Logs/Claude/mcp-server-go-mcp-server.log
</code></pre></div></div>

<hr />

<h2 id="tool-usage-in-practice">Tool usage in practice</h2>

<p>Now the fun part: using tools.</p>

<hr />

<h2 id="hello_world"><code class="language-plaintext highlighter-rouge">hello_world</code></h2>

<p>Prompt:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Use the greeting tool to say hello to Tiago
</code></pre></div></div>

<p>Behind the scenes, Claude sends:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>2026-04-12T15:56:28.704Z <span class="o">[</span>go-mcp-server] <span class="o">[</span>info] Message from client: <span class="o">{</span><span class="s2">"method"</span>:<span class="s2">"tools/call"</span>,<span class="s2">"params"</span>:<span class="o">{</span><span class="s2">"name"</span>:<span class="s2">"hello_world"</span>,<span class="s2">"arguments"</span>:<span class="o">{</span><span class="s2">"name"</span>:<span class="s2">"Tiago"</span><span class="o">}}</span>,<span class="s2">"jsonrpc"</span>:<span class="s2">"2.0"</span>,<span class="s2">"id"</span>:2<span class="o">}</span> <span class="o">{</span> metadata: undefined <span class="o">}</span>
</code></pre></div></div>

<p>Your server logs:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">time</span><span class="o">=</span>2026-04-12T12:56:28.705-03:00 <span class="nv">level</span><span class="o">=</span>INFO <span class="nv">msg</span><span class="o">=</span><span class="s2">"tool call started"</span> <span class="nb">id</span><span class="o">=</span>2 <span class="nv">tool</span><span class="o">=</span>hello_world
<span class="nb">time</span><span class="o">=</span>2026-04-12T12:56:28.706-03:00 <span class="nv">level</span><span class="o">=</span>INFO <span class="nv">msg</span><span class="o">=</span><span class="s2">"tool call succeeded"</span> <span class="nb">id</span><span class="o">=</span>2 <span class="nv">tool</span><span class="o">=</span>hello_world
</code></pre></div></div>

<p>And responds:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>2026-04-12T15:56:28.706Z <span class="o">[</span>go-mcp-server] <span class="o">[</span>info] Message from server: <span class="o">{</span><span class="s2">"jsonrpc"</span>:<span class="s2">"2.0"</span>,<span class="s2">"id"</span>:2,<span class="s2">"result"</span>:<span class="o">{</span><span class="s2">"content"</span>:[<span class="o">{</span><span class="s2">"text"</span>:<span class="s2">"{</span><span class="se">\n</span><span class="s2">  </span><span class="se">\"</span><span class="s2">message</span><span class="se">\"</span><span class="s2">: </span><span class="se">\"</span><span class="s2">Hello, Tiago</span><span class="se">\"\n</span><span class="s2">}"</span>,<span class="s2">"type"</span>:<span class="s2">"text"</span><span class="o">}]</span>,<span class="s2">"isError"</span>:false,<span class="s2">"structuredContent"</span>:<span class="o">{</span><span class="s2">"message"</span>:<span class="s2">"Hello, Tiago"</span><span class="o">}}}</span> <span class="o">{</span> metadata: undefined <span class="o">}</span>
</code></pre></div></div>

<p><img src="/assets/images/2026-04-12-using-your-mcp-server-with-claude/hello_world1.png" alt="hello_world prompt" /></p>

<p><img src="/assets/images/2026-04-12-using-your-mcp-server-with-claude/hello_world2.png" alt="hello_world response" /></p>

<hr />

<h2 id="health_check"><code class="language-plaintext highlighter-rouge">health_check</code></h2>

<p>Prompt:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Can you check if this API endpoint is working? https://tiagomelo.info
</code></pre></div></div>

<p>Claude decides to call:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>2026-04-12T16:00:59.474Z <span class="o">[</span>go-mcp-server] <span class="o">[</span>info] Message from client: <span class="o">{</span><span class="s2">"method"</span>:<span class="s2">"tools/call"</span>,<span class="s2">"params"</span>:<span class="o">{</span><span class="s2">"name"</span>:<span class="s2">"health_check"</span>,<span class="s2">"arguments"</span>:<span class="o">{</span><span class="s2">"url"</span>:<span class="s2">"https://tiagomelo.info"</span><span class="o">}}</span>,<span class="s2">"jsonrpc"</span>:<span class="s2">"2.0"</span>,<span class="s2">"id"</span>:3<span class="o">}</span> <span class="o">{</span> metadata: undefined <span class="o">}</span>
</code></pre></div></div>

<p>Server logs:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">time</span><span class="o">=</span>2026-04-12T13:00:59.474-03:00 <span class="nv">level</span><span class="o">=</span>INFO <span class="nv">msg</span><span class="o">=</span><span class="s2">"tool call started"</span> <span class="nb">id</span><span class="o">=</span>3 <span class="nv">tool</span><span class="o">=</span>health_check
<span class="nb">time</span><span class="o">=</span>2026-04-12T13:00:59.887-03:00 <span class="nv">level</span><span class="o">=</span>INFO <span class="nv">msg</span><span class="o">=</span><span class="s2">"tool call succeeded"</span> <span class="nb">id</span><span class="o">=</span>3 <span class="nv">tool</span><span class="o">=</span>health_check
</code></pre></div></div>

<p>Response:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>2026-04-12T16:00:59.888Z <span class="o">[</span>go-mcp-server] <span class="o">[</span>info] Message from server: <span class="o">{</span><span class="s2">"jsonrpc"</span>:<span class="s2">"2.0"</span>,<span class="s2">"id"</span>:3,<span class="s2">"result"</span>:<span class="o">{</span><span class="s2">"content"</span>:[<span class="o">{</span><span class="s2">"text"</span>:<span class="s2">"{</span><span class="se">\n</span><span class="s2">  </span><span class="se">\"</span><span class="s2">url</span><span class="se">\"</span><span class="s2">: </span><span class="se">\"</span><span class="s2">https://tiagomelo.info</span><span class="se">\"</span><span class="s2">,</span><span class="se">\n</span><span class="s2">  </span><span class="se">\"</span><span class="s2">status_code</span><span class="se">\"</span><span class="s2">: 200,</span><span class="se">\n</span><span class="s2">  </span><span class="se">\"</span><span class="s2">latency_ms</span><span class="se">\"</span><span class="s2">: 412,</span><span class="se">\n</span><span class="s2">  </span><span class="se">\"</span><span class="s2">ok</span><span class="se">\"</span><span class="s2">: true</span><span class="se">\n</span><span class="s2">}"</span>,<span class="s2">"type"</span>:<span class="s2">"text"</span><span class="o">}]</span>,<span class="s2">"isError"</span>:false,<span class="s2">"structuredContent"</span>:<span class="o">{</span><span class="s2">"url"</span>:<span class="s2">"https://tiagomelo.info"</span>,<span class="s2">"status_code"</span>:200,<span class="s2">"latency_ms"</span>:412,<span class="s2">"ok"</span>:true<span class="o">}}}</span> <span class="o">{</span> metadata: undefined <span class="o">}</span>
</code></pre></div></div>

<p><img src="/assets/images/2026-04-12-using-your-mcp-server-with-claude/health_check1.png" alt="health_check prompt" /></p>

<p><img src="/assets/images/2026-04-12-using-your-mcp-server-with-claude/health_check2.png" alt="health_check response" /></p>

<hr />

<h2 id="latency_percentiles"><code class="language-plaintext highlighter-rouge">latency_percentiles</code></h2>

<p>Prompt:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Calculate latency percentiles for these values: 10, 20, 30, 100, 200
</code></pre></div></div>

<p>Claude calls:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>2026-04-12T16:03:03.504Z <span class="o">[</span>go-mcp-server] <span class="o">[</span>info] Message from client: <span class="o">{</span><span class="s2">"method"</span>:<span class="s2">"tools/call"</span>,<span class="s2">"params"</span>:<span class="o">{</span><span class="s2">"name"</span>:<span class="s2">"latency_percentiles"</span>,<span class="s2">"arguments"</span>:<span class="o">{</span><span class="s2">"values"</span>:[10,20,30,100,200]<span class="o">}}</span>,<span class="s2">"jsonrpc"</span>:<span class="s2">"2.0"</span>,<span class="s2">"id"</span>:4<span class="o">}</span> <span class="o">{</span> metadata: undefined <span class="o">}</span>
</code></pre></div></div>

<p>Server logs:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">time</span><span class="o">=</span>2026-04-12T13:03:03.505-03:00 <span class="nv">level</span><span class="o">=</span>INFO <span class="nv">msg</span><span class="o">=</span><span class="s2">"tool call started"</span> <span class="nb">id</span><span class="o">=</span>4 <span class="nv">tool</span><span class="o">=</span>latency_percentiles
<span class="nb">time</span><span class="o">=</span>2026-04-12T13:03:03.505-03:00 <span class="nv">level</span><span class="o">=</span>INFO <span class="nv">msg</span><span class="o">=</span><span class="s2">"tool call succeeded"</span> <span class="nb">id</span><span class="o">=</span>4 <span class="nv">tool</span><span class="o">=</span>latency_percentiles
</code></pre></div></div>

<p>Response:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>2026-04-12T16:03:03.505Z <span class="o">[</span>go-mcp-server] <span class="o">[</span>info] Message from server: <span class="o">{</span><span class="s2">"jsonrpc"</span>:<span class="s2">"2.0"</span>,<span class="s2">"id"</span>:4,<span class="s2">"result"</span>:<span class="o">{</span><span class="s2">"content"</span>:[<span class="o">{</span><span class="s2">"text"</span>:<span class="s2">"{</span><span class="se">\n</span><span class="s2">  </span><span class="se">\"</span><span class="s2">count</span><span class="se">\"</span><span class="s2">: 5,</span><span class="se">\n</span><span class="s2">  </span><span class="se">\"</span><span class="s2">min</span><span class="se">\"</span><span class="s2">: 10,</span><span class="se">\n</span><span class="s2">  </span><span class="se">\"</span><span class="s2">p50</span><span class="se">\"</span><span class="s2">: 30,</span><span class="se">\n</span><span class="s2">  </span><span class="se">\"</span><span class="s2">p95</span><span class="se">\"</span><span class="s2">: 179.99999999999997,</span><span class="se">\n</span><span class="s2">  </span><span class="se">\"</span><span class="s2">p99</span><span class="se">\"</span><span class="s2">: 196,</span><span class="se">\n</span><span class="s2">  </span><span class="se">\"</span><span class="s2">max</span><span class="se">\"</span><span class="s2">: 200,</span><span class="se">\n</span><span class="s2">  </span><span class="se">\"</span><span class="s2">avg</span><span class="se">\"</span><span class="s2">: 72</span><span class="se">\n</span><span class="s2">}"</span>,<span class="s2">"type"</span>:<span class="s2">"text"</span><span class="o">}]</span>,<span class="s2">"isError"</span>:false,<span class="s2">"structuredContent"</span>:<span class="o">{</span><span class="s2">"count"</span>:5,<span class="s2">"min"</span>:10,<span class="s2">"p50"</span>:30,<span class="s2">"p95"</span>:179.99999999999997,<span class="s2">"p99"</span>:196,<span class="s2">"max"</span>:200,<span class="s2">"avg"</span>:72<span class="o">}}}</span> <span class="o">{</span> metadata: undefined <span class="o">}</span>
</code></pre></div></div>

<p><img src="/assets/images/2026-04-12-using-your-mcp-server-with-claude/percentiles1.png" alt="percentiles prompt" /></p>

<p><img src="/assets/images/2026-04-12-using-your-mcp-server-with-claude/percentiles2.png" alt="percentiles response" /></p>

<hr />

<h2 id="key-takeaway-prompts-dont-call-tools">Key takeaway: prompts don’t call tools</h2>

<p>One of the most important concepts:</p>

<blockquote>
  <p>You don’t call tools directly. You describe intent, and the model decides.</p>
</blockquote>

<p>This means:</p>

<p>Good:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Check if https://tiagomelo.info is up
</code></pre></div></div>

<p>Bad:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Call tools/call with health_check
</code></pre></div></div>

<p>Tool descriptions are what guide this decision.</p>

<hr />

<h2 id="tool-description-matters-a-lot">Tool description matters (a lot)</h2>

<p>A good description should:</p>

<ol>
  <li>Explain what the tool does</li>
  <li>Explain when to use it</li>
</ol>

<p>Example:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Description</span><span class="o">:</span> <span class="s">"Check the health of a URL by performing an HTTP GET request. Use this when the user asks if a website or API is up, reachable, or responding correctly."</span>
</code></pre></div></div>

<p>This is what makes Claude choose your tool instead of answering from general knowledge.</p>

<hr />

<h2 id="important-constraints">Important constraints</h2>

<p>When building MCP servers:</p>

<h3 id="1-stdout-must-be-clean">1. stdout must be clean</h3>

<ul>
  <li>Only JSON-RPC messages</li>
  <li>No logs</li>
  <li>No debug prints</li>
</ul>

<h3 id="2-logs-must-go-to-stderr">2. logs must go to stderr</h3>

<p>Otherwise Claude will crash with:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Unexpected token 'p' ... not valid JSON
</code></pre></div></div>

<h3 id="3-tool-names-must-be-valid">3. tool names must be valid</h3>

<p>Claude currently enforces:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>^[a-zA-Z0-9_-]{1,64}$
</code></pre></div></div>

<p>So this is invalid:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>postgres.query
</code></pre></div></div>

<p>Use:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>postgres_query
</code></pre></div></div>

<hr />

<h2 id="debugging-tips">Debugging tips</h2>

<p>If things don’t work:</p>

<ol>
  <li>
    <p>Check logs:</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">tail</span> <span class="nt">-f</span> ~/Library/Logs/Claude/<span class="k">*</span>.log
</code></pre></div>    </div>
  </li>
  <li>
    <p>Run your server manually</p>
  </li>
  <li>
    <p>Verify:</p>

    <ul>
      <li>no stdout pollution</li>
      <li>valid JSON</li>
      <li>correct tool names</li>
    </ul>
  </li>
</ol>

<hr />

<h2 id="final-thoughts">Final thoughts</h2>

<p>At this point, you now have:</p>

<ul>
  <li>A working MCP server in Go</li>
  <li>Integrated with Claude Desktop</li>
  <li>Tools being called automatically</li>
  <li>Full visibility via logs</li>
</ul>

<p>This is the foundation for building:</p>

<ul>
  <li>internal tooling</li>
  <li>developer assistants</li>
  <li>production integrations</li>
  <li>AI-powered backends</li>
</ul>

<hr />

<h2 id="closing-insight">Closing insight</h2>

<blockquote>
  <p>MCP is not magic.
It’s just a local process speaking JSON-RPC over stdio.</p>
</blockquote>

<p>And once you understand that, you can build anything on top of it.</p>]]></content><author><name></name></author><category term="golang" /><category term="mcp" /><category term="claude" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/2026-04-12-using-your-mcp-server-with-claude/banner.png" /><media:content medium="image" url="/assets/images/2026-04-12-using-your-mcp-server-with-claude/banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Building an MCP Server in Go from scratch</title><link href="/golang/mcp/2026/04/10/go-mcp-server.html" rel="alternate" type="text/html" title="Building an MCP Server in Go from scratch" /><published>2026-04-10T08:06:11+00:00</published><updated>2026-04-10T08:06:11+00:00</updated><id>/golang/mcp/2026/04/10/go-mcp-server</id><content type="html" xml:base="/golang/mcp/2026/04/10/go-mcp-server.html"><![CDATA[<p><img src="/assets/images/2026-04-09-go-mcp-server/banner.png" alt="banner" /></p>

<h2 id="introduction">Introduction</h2>

<p>The <a href="https://modelcontextprotocol.io/">Model Context Protocol (MCP)</a> is an open standard that defines how AI applications (like <a href="https://claude.ai">Claude</a>) communicate with external tools and data sources. Think of it as a USB port for AI: a universal interface that lets any compliant client talk to any compliant server.</p>

<p>In this article, we’ll build an MCP server in <a href="https://go.dev/">Go</a> from scratch – no third-party MCP libraries, just the standard library and the MCP specification. By the end, you’ll understand how the protocol works at the wire level and have a working server with three example tools.</p>

<h2 id="what-is-mcp">What is MCP?</h2>

<p>MCP follows a client-server architecture where:</p>

<ul>
  <li><strong>MCP Clients</strong> are AI applications (Claude Desktop, Claude Code, IDE extensions) that need to access external capabilities.</li>
  <li><strong>MCP Servers</strong> expose those capabilities as <strong>tools</strong> – functions that the AI can discover and invoke.</li>
</ul>

<p>The transport layer is <a href="https://www.jsonrpc.org/specification">JSON-RPC 2.0</a> over <strong>stdio</strong> (standard input/output). The client spawns the server as a child process, sends JSON-RPC requests to its stdin, and reads JSON-RPC responses from its stdout.</p>

<p><img src="/assets/images/2026-04-09-go-mcp-server/diagram1.png" alt="mcp diagram" /></p>

<h2 id="json-rpc-20-the-transport-layer">JSON-RPC 2.0: the transport layer</h2>

<p>Before diving into MCP specifics, let’s understand the transport. JSON-RPC 2.0 defines two types of messages:</p>

<p><strong>Requests</strong> have an <code class="language-plaintext highlighter-rouge">id</code> and expect a response:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2.0"</span><span class="p">,</span><span class="w"> </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ping"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Notifications</strong> have no <code class="language-plaintext highlighter-rouge">id</code> and expect no response:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2.0"</span><span class="p">,</span><span class="w"> </span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"notifications/initialized"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Responses</strong> echo back the request <code class="language-plaintext highlighter-rouge">id</code> and carry either a <code class="language-plaintext highlighter-rouge">result</code> or an <code class="language-plaintext highlighter-rouge">error</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2.0"</span><span class="p">,</span><span class="w"> </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nl">"result"</span><span class="p">:</span><span class="w"> </span><span class="p">{}}</span><span class="w">
</span></code></pre></div></div>

<p>Error codes are standardized by the JSON-RPC spec:</p>

<table>
  <thead>
    <tr>
      <th>Code</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>-32700</td>
      <td>Parse error</td>
    </tr>
    <tr>
      <td>-32600</td>
      <td>Invalid request</td>
    </tr>
    <tr>
      <td>-32601</td>
      <td>Method not found</td>
    </tr>
    <tr>
      <td>-32602</td>
      <td>Invalid params</td>
    </tr>
    <tr>
      <td>-32603</td>
      <td>Internal error</td>
    </tr>
  </tbody>
</table>

<p>Since the transport is stdio, each JSON-RPC message must be a <strong>single line</strong> – no pretty-printing, no line breaks within a message.</p>

<h2 id="the-mcp-initialization-handshake">The MCP initialization handshake</h2>

<p>Before a client can use any tools, it must complete a handshake:</p>

<p><img src="/assets/images/2026-04-09-go-mcp-server/diagram2.png" alt="mcp sequence diagram" /></p>

<ol>
  <li>The client sends an <code class="language-plaintext highlighter-rouge">initialize</code> <strong>request</strong> with its protocol version and capabilities.</li>
  <li>The server responds with its own protocol version, capabilities, and instructions.</li>
  <li>The client sends a <code class="language-plaintext highlighter-rouge">notifications/initialized</code> <strong>notification</strong> (no <code class="language-plaintext highlighter-rouge">id</code>, no response expected).</li>
  <li>Only after step 3 does the server accept <code class="language-plaintext highlighter-rouge">tools/list</code> and <code class="language-plaintext highlighter-rouge">tools/call</code> requests.</li>
</ol>

<p>This handshake ensures both sides agree on the protocol version and know each other’s capabilities before any work begins.</p>

<h2 id="project-structure">Project structure</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go-mcp-server/
├── cmd/
│   └── main.go            # Entry point, signal handling, graceful shutdown
├── jsonrpc/
│   └── jsonrpc.go          # JSON-RPC 2.0 types and error codes
├── server/
│   ├── server.go           # MCP server: request routing, handlers, tool registry
│   └── server_test.go      # Integration tests
├── tools/
│   ├── hello.go            # hello_world tool
│   ├── health.go           # health_check tool
│   ├── percentiles.go      # latency_percentiles tool
│   ├── http.go             # HTTP abstractions (for testability)
│   └── tools.go            # Tool registration (wires definitions to handlers)
├── Makefile
├── go.mod
└── go.sum
</code></pre></div></div>

<h2 id="implementation">Implementation</h2>

<h3 id="json-rpc-types">JSON-RPC types</h3>

<p>We start with the wire format. The <code class="language-plaintext highlighter-rouge">jsonrpc</code> package defines the request, response, and error types that map directly to the JSON-RPC 2.0 spec:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// jsonrpc/jsonrpc.go</span>

<span class="k">package</span> <span class="n">jsonrpc</span>

<span class="k">import</span> <span class="s">"encoding/json"</span>

<span class="c">// Request represents a JSON-RPC request object.</span>
<span class="k">type</span> <span class="n">Request</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">JSONRPC</span> <span class="kt">string</span>          <span class="s">`json:"jsonrpc"`</span>
	<span class="n">ID</span>      <span class="n">any</span>             <span class="s">`json:"id,omitempty"`</span>
	<span class="n">Method</span>  <span class="kt">string</span>          <span class="s">`json:"method"`</span>
	<span class="n">Params</span>  <span class="n">json</span><span class="o">.</span><span class="n">RawMessage</span> <span class="s">`json:"params,omitempty"`</span>
<span class="p">}</span>

<span class="c">// Response represents a JSON-RPC response object.</span>
<span class="k">type</span> <span class="n">Response</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">JSONRPC</span> <span class="kt">string</span> <span class="s">`json:"jsonrpc"`</span>
	<span class="n">ID</span>      <span class="n">any</span>    <span class="s">`json:"id,omitempty"`</span>
	<span class="n">Result</span>  <span class="n">any</span>    <span class="s">`json:"result,omitempty"`</span>
	<span class="n">Error</span>   <span class="o">*</span><span class="n">Error</span> <span class="s">`json:"error,omitempty"`</span>
<span class="p">}</span>

<span class="c">// Error represents a JSON-RPC error object.</span>
<span class="k">type</span> <span class="n">Error</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Code</span>    <span class="kt">int</span>    <span class="s">`json:"code"`</span>
	<span class="n">Message</span> <span class="kt">string</span> <span class="s">`json:"message"`</span>
	<span class="n">Data</span>    <span class="n">any</span>    <span class="s">`json:"data,omitempty"`</span>
<span class="p">}</span>

<span class="c">// Predefined error codes for JSON-RPC 2.0.</span>
<span class="c">// https://www.jsonrpc.org/specification#error_object</span>
<span class="k">const</span> <span class="p">(</span>
	<span class="n">ParseError</span>     <span class="o">=</span> <span class="o">-</span><span class="m">32700</span>
	<span class="n">InvalidRequest</span> <span class="o">=</span> <span class="o">-</span><span class="m">32600</span>
	<span class="n">MethodNotFound</span> <span class="o">=</span> <span class="o">-</span><span class="m">32601</span>
	<span class="n">InvalidParams</span>  <span class="o">=</span> <span class="o">-</span><span class="m">32602</span>
	<span class="n">InternalError</span>  <span class="o">=</span> <span class="o">-</span><span class="m">32603</span>
<span class="p">)</span>
</code></pre></div></div>

<p>A few things to note:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">ID</code> is <code class="language-plaintext highlighter-rouge">any</code> because JSON-RPC allows string or numeric IDs, and notifications have no ID at all (<code class="language-plaintext highlighter-rouge">omitempty</code> handles that).</li>
  <li><code class="language-plaintext highlighter-rouge">Params</code> is <code class="language-plaintext highlighter-rouge">json.RawMessage</code> so we can defer parsing until we know which method was called.</li>
  <li>The error codes are defined by the <a href="https://www.jsonrpc.org/specification#error_object">JSON-RPC 2.0 specification</a>, not MCP. MCP inherits them since it uses JSON-RPC as its transport.</li>
</ul>

<h3 id="the-server">The server</h3>

<p>The <code class="language-plaintext highlighter-rouge">server</code> package is the heart of the project. Let’s walk through it in sections.</p>

<h4 id="types-and-constructor">Types and constructor</h4>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// server/server.go</span>

<span class="k">const</span> <span class="n">ProtocolVersion</span> <span class="o">=</span> <span class="s">"2025-06-18"</span>

<span class="c">// ToolDefinition defines a tool that can be called by the client.</span>
<span class="k">type</span> <span class="n">ToolDefinition</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Name</span>        <span class="kt">string</span>         <span class="s">`json:"name"`</span>
	<span class="n">Description</span> <span class="kt">string</span>         <span class="s">`json:"description,omitempty"`</span>
	<span class="n">InputSchema</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span> <span class="s">`json:"inputSchema"`</span>
<span class="p">}</span>

<span class="c">// ToolHandler is a function that implements the logic of a tool.</span>
<span class="k">type</span> <span class="n">ToolHandler</span> <span class="k">func</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">arguments</span> <span class="n">json</span><span class="o">.</span><span class="n">RawMessage</span><span class="p">)</span> <span class="p">(</span><span class="n">any</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>

<span class="c">// Server implements the MCP protocol over JSON-RPC 2.0.</span>
<span class="k">type</span> <span class="n">Server</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">in</span>  <span class="n">io</span><span class="o">.</span><span class="n">Reader</span>
	<span class="n">out</span> <span class="n">io</span><span class="o">.</span><span class="n">Writer</span>

	<span class="n">mu</span>          <span class="n">sync</span><span class="o">.</span><span class="n">RWMutex</span>
	<span class="n">tools</span>       <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">ToolHandler</span>
	<span class="n">definitions</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">ToolDefinition</span>
	<span class="n">initialized</span> <span class="kt">bool</span>
	<span class="n">handlers</span>    <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="k">func</span><span class="p">(</span><span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">jsonrpc</span><span class="o">.</span><span class="n">Request</span><span class="p">)</span> <span class="kt">error</span>
	<span class="n">logger</span>      <span class="o">*</span><span class="n">slog</span><span class="o">.</span><span class="n">Logger</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The server reads from <code class="language-plaintext highlighter-rouge">in</code> and writes to <code class="language-plaintext highlighter-rouge">out</code>. In production these are <code class="language-plaintext highlighter-rouge">os.Stdin</code> and <code class="language-plaintext highlighter-rouge">os.Stdout</code>, but accepting <code class="language-plaintext highlighter-rouge">io.Reader</code>/<code class="language-plaintext highlighter-rouge">io.Writer</code> makes the server fully testable without real I/O.</p>

<p>The constructor wires up the method dispatch table:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">New</span><span class="p">(</span><span class="n">in</span> <span class="n">io</span><span class="o">.</span><span class="n">Reader</span><span class="p">,</span> <span class="n">out</span> <span class="n">io</span><span class="o">.</span><span class="n">Writer</span><span class="p">,</span> <span class="n">logger</span> <span class="o">*</span><span class="n">slog</span><span class="o">.</span><span class="n">Logger</span><span class="p">)</span> <span class="o">*</span><span class="n">Server</span> <span class="p">{</span>
	<span class="n">s</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">Server</span><span class="p">{</span>
		<span class="n">in</span><span class="o">:</span>          <span class="n">in</span><span class="p">,</span>
		<span class="n">out</span><span class="o">:</span>         <span class="n">out</span><span class="p">,</span>
		<span class="n">tools</span><span class="o">:</span>       <span class="nb">make</span><span class="p">(</span><span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">ToolHandler</span><span class="p">),</span>
		<span class="n">definitions</span><span class="o">:</span> <span class="nb">make</span><span class="p">(</span><span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">ToolDefinition</span><span class="p">),</span>
		<span class="n">logger</span><span class="o">:</span>      <span class="n">logger</span><span class="p">,</span>
	<span class="p">}</span>

	<span class="n">s</span><span class="o">.</span><span class="n">handlers</span> <span class="o">=</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="k">func</span><span class="p">(</span><span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">jsonrpc</span><span class="o">.</span><span class="n">Request</span><span class="p">)</span> <span class="kt">error</span><span class="p">{</span>
		<span class="s">"initialize"</span><span class="o">:</span>                <span class="n">s</span><span class="o">.</span><span class="n">handleInitialize</span><span class="p">,</span>
		<span class="s">"notifications/initialized"</span><span class="o">:</span> <span class="n">s</span><span class="o">.</span><span class="n">handleInitializedNotification</span><span class="p">,</span>
		<span class="s">"ping"</span><span class="o">:</span>                      <span class="n">s</span><span class="o">.</span><span class="n">handlePing</span><span class="p">,</span>
		<span class="s">"tools/list"</span><span class="o">:</span>                <span class="n">s</span><span class="o">.</span><span class="n">handleToolsList</span><span class="p">,</span>
		<span class="s">"tools/call"</span><span class="o">:</span>                <span class="n">s</span><span class="o">.</span><span class="n">handleToolsCall</span><span class="p">,</span>
	<span class="p">}</span>

	<span class="k">return</span> <span class="n">s</span>
<span class="p">}</span>
</code></pre></div></div>

<h4 id="tool-registration">Tool registration</h4>

<p>Tools can be registered at any time before or after initialization:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">Server</span><span class="p">)</span> <span class="n">RegisterTool</span><span class="p">(</span><span class="n">def</span> <span class="n">ToolDefinition</span><span class="p">,</span> <span class="n">handler</span> <span class="n">ToolHandler</span><span class="p">)</span> <span class="p">{</span>
	<span class="n">s</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Lock</span><span class="p">()</span>
	<span class="k">defer</span> <span class="n">s</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Unlock</span><span class="p">()</span>

	<span class="n">s</span><span class="o">.</span><span class="n">definitions</span><span class="p">[</span><span class="n">def</span><span class="o">.</span><span class="n">Name</span><span class="p">]</span> <span class="o">=</span> <span class="n">def</span>
	<span class="n">s</span><span class="o">.</span><span class="n">tools</span><span class="p">[</span><span class="n">def</span><span class="o">.</span><span class="n">Name</span><span class="p">]</span> <span class="o">=</span> <span class="n">handler</span>
<span class="p">}</span>
</code></pre></div></div>

<h4 id="the-main-loop">The main loop</h4>

<p>The <code class="language-plaintext highlighter-rouge">Run</code> method reads JSON-RPC messages line by line. Since <code class="language-plaintext highlighter-rouge">bufio.Scanner.Scan()</code> is a blocking call that doesn’t respect context cancellation, we push it into a goroutine and use channels so the main loop can <code class="language-plaintext highlighter-rouge">select</code> on both incoming lines and <code class="language-plaintext highlighter-rouge">ctx.Done()</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">Server</span><span class="p">)</span> <span class="n">Run</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
	<span class="n">s</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span><span class="s">"mcp server started"</span><span class="p">,</span> <span class="n">slog</span><span class="o">.</span><span class="n">String</span><span class="p">(</span><span class="s">"protocolVersion"</span><span class="p">,</span> <span class="n">ProtocolVersion</span><span class="p">))</span>
	<span class="k">defer</span> <span class="n">s</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span><span class="s">"mcp server stopped"</span><span class="p">)</span>

	<span class="n">scanner</span> <span class="o">:=</span> <span class="n">bufio</span><span class="o">.</span><span class="n">NewScanner</span><span class="p">(</span><span class="n">s</span><span class="o">.</span><span class="n">in</span><span class="p">)</span>
	<span class="n">scanner</span><span class="o">.</span><span class="n">Buffer</span><span class="p">(</span><span class="nb">make</span><span class="p">([]</span><span class="kt">byte</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">64</span><span class="o">*</span><span class="m">1024</span><span class="p">),</span> <span class="m">1024</span><span class="o">*</span><span class="m">1024</span><span class="p">)</span>

	<span class="c">// scanCh delivers lines from stdin so we can select on ctx.Done().</span>
	<span class="n">scanCh</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span>
	<span class="n">scanErr</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="kt">error</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>
	<span class="k">go</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
		<span class="k">defer</span> <span class="nb">close</span><span class="p">(</span><span class="n">scanCh</span><span class="p">)</span>
		<span class="k">for</span> <span class="n">scanner</span><span class="o">.</span><span class="n">Scan</span><span class="p">()</span> <span class="p">{</span>
			<span class="n">line</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">byte</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">scanner</span><span class="o">.</span><span class="n">Bytes</span><span class="p">()))</span>
			<span class="nb">copy</span><span class="p">(</span><span class="n">line</span><span class="p">,</span> <span class="n">scanner</span><span class="o">.</span><span class="n">Bytes</span><span class="p">())</span>
			<span class="n">scanCh</span> <span class="o">&lt;-</span> <span class="n">line</span>
		<span class="p">}</span>
		<span class="n">scanErr</span> <span class="o">&lt;-</span> <span class="n">scanner</span><span class="o">.</span><span class="n">Err</span><span class="p">()</span>
	<span class="p">}()</span>

	<span class="k">for</span> <span class="p">{</span>
		<span class="k">var</span> <span class="n">line</span> <span class="p">[]</span><span class="kt">byte</span>
		<span class="k">select</span> <span class="p">{</span>
		<span class="k">case</span> <span class="o">&lt;-</span><span class="n">ctx</span><span class="o">.</span><span class="n">Done</span><span class="p">()</span><span class="o">:</span>
			<span class="k">return</span> <span class="n">ctx</span><span class="o">.</span><span class="n">Err</span><span class="p">()</span>
		<span class="k">case</span> <span class="n">l</span><span class="p">,</span> <span class="n">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="n">scanCh</span><span class="o">:</span>
			<span class="k">if</span> <span class="o">!</span><span class="n">ok</span> <span class="p">{</span>
				<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="n">scanErr</span><span class="p">;</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
					<span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"reading input: %w"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
				<span class="p">}</span>
				<span class="k">return</span> <span class="no">nil</span>
			<span class="p">}</span>
			<span class="n">line</span> <span class="o">=</span> <span class="n">l</span>
		<span class="p">}</span>

		<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">line</span><span class="p">)</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
			<span class="k">continue</span>
		<span class="p">}</span>

		<span class="c">// ... parse JSON, validate version, dispatch to handler</span>
	<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Without the goroutine + channel approach, a <code class="language-plaintext highlighter-rouge">Ctrl+C</code> signal would cancel the context, but the loop would remain blocked on <code class="language-plaintext highlighter-rouge">scanner.Scan()</code> until new input arrived – making the server unable to shut down cleanly.</p>

<p>Each incoming line goes through three stages:</p>

<ol>
  <li><strong>Parse</strong>: unmarshal JSON. If it fails, send a <code class="language-plaintext highlighter-rouge">ParseError</code> response.</li>
  <li><strong>Validate</strong>: check that <code class="language-plaintext highlighter-rouge">jsonrpc</code> is <code class="language-plaintext highlighter-rouge">"2.0"</code>. If not, send an <code class="language-plaintext highlighter-rouge">InvalidRequest</code> response.</li>
  <li><strong>Dispatch</strong>: route to the appropriate handler based on the <code class="language-plaintext highlighter-rouge">method</code> field.</li>
</ol>

<p>The dispatch also handles the <code class="language-plaintext highlighter-rouge">errNotInitialized</code> sentinel: when a request arrives before the handshake is complete, the error response has already been written to the client – we just <code class="language-plaintext highlighter-rouge">continue</code> to the next message instead of killing the server:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">handleRequest</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">req</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">errors</span><span class="o">.</span><span class="n">Is</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="n">errNotInitialized</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">continue</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">errors</span><span class="o">.</span><span class="n">WithMessage</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="s">"failed to handle request"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<h4 id="request-dispatch">Request dispatch</h4>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">Server</span><span class="p">)</span> <span class="n">handleRequest</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">req</span> <span class="n">jsonrpc</span><span class="o">.</span><span class="n">Request</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
	<span class="n">handler</span><span class="p">,</span> <span class="n">ok</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">handlers</span><span class="p">[</span><span class="n">req</span><span class="o">.</span><span class="n">Method</span><span class="p">]</span>
	<span class="k">if</span> <span class="n">ok</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">handler</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">req</span><span class="p">)</span>
	<span class="p">}</span>

	<span class="c">// Unknown notification (no ID) -- silently ignore per the spec.</span>
	<span class="k">if</span> <span class="n">req</span><span class="o">.</span><span class="n">ID</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="n">s</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">Debug</span><span class="p">(</span><span class="s">"ignoring unknown notification"</span><span class="p">,</span> <span class="n">slog</span><span class="o">.</span><span class="n">String</span><span class="p">(</span><span class="s">"method"</span><span class="p">,</span> <span class="n">req</span><span class="o">.</span><span class="n">Method</span><span class="p">))</span>
		<span class="k">return</span> <span class="no">nil</span>
	<span class="p">}</span>

	<span class="c">// Unknown method with an ID -- respond with MethodNotFound.</span>
	<span class="k">return</span> <span class="n">s</span><span class="o">.</span><span class="n">writeResponse</span><span class="p">(</span><span class="n">jsonrpc</span><span class="o">.</span><span class="n">Response</span><span class="p">{</span>
		<span class="n">JSONRPC</span><span class="o">:</span> <span class="s">"2.0"</span><span class="p">,</span>
		<span class="n">ID</span><span class="o">:</span>      <span class="n">req</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span>
		<span class="n">Error</span><span class="o">:</span> <span class="o">&amp;</span><span class="n">jsonrpc</span><span class="o">.</span><span class="n">Error</span><span class="p">{</span>
			<span class="n">Code</span><span class="o">:</span>    <span class="n">jsonrpc</span><span class="o">.</span><span class="n">MethodNotFound</span><span class="p">,</span>
			<span class="n">Message</span><span class="o">:</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"method not found: %s"</span><span class="p">,</span> <span class="n">req</span><span class="o">.</span><span class="n">Method</span><span class="p">),</span>
		<span class="p">},</span>
	<span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Per the JSON-RPC spec, unknown <strong>notifications</strong> (no <code class="language-plaintext highlighter-rouge">id</code>) are silently ignored, while unknown <strong>requests</strong> (with an <code class="language-plaintext highlighter-rouge">id</code>) get a <code class="language-plaintext highlighter-rouge">MethodNotFound</code> error response.</p>

<h4 id="initialization-handlers">Initialization handlers</h4>

<p>The <code class="language-plaintext highlighter-rouge">initialize</code> handler responds with the server’s protocol version, capabilities, and instructions:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">Server</span><span class="p">)</span> <span class="n">handleInitialize</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">req</span> <span class="n">jsonrpc</span><span class="o">.</span><span class="n">Request</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
	<span class="n">s</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span><span class="s">"initialize request received"</span><span class="p">,</span> <span class="n">slog</span><span class="o">.</span><span class="n">Any</span><span class="p">(</span><span class="s">"id"</span><span class="p">,</span> <span class="n">req</span><span class="o">.</span><span class="n">ID</span><span class="p">))</span>

	<span class="k">type</span> <span class="n">initializeParams</span> <span class="k">struct</span> <span class="p">{</span>
		<span class="n">ProtocolVersion</span> <span class="kt">string</span>         <span class="s">`json:"protocolVersion"`</span>
		<span class="n">Capabilities</span>    <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span> <span class="s">`json:"capabilities"`</span>
		<span class="n">ClientInfo</span>      <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span> <span class="s">`json:"clientInfo"`</span>
	<span class="p">}</span>

	<span class="k">var</span> <span class="n">params</span> <span class="n">initializeParams</span>
	<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">req</span><span class="o">.</span><span class="n">Params</span><span class="p">)</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
		<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">json</span><span class="o">.</span><span class="n">Unmarshal</span><span class="p">(</span><span class="n">req</span><span class="o">.</span><span class="n">Params</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">params</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
			<span class="k">return</span> <span class="n">s</span><span class="o">.</span><span class="n">writeResponse</span><span class="p">(</span><span class="n">jsonrpc</span><span class="o">.</span><span class="n">Response</span><span class="p">{</span>
				<span class="n">JSONRPC</span><span class="o">:</span> <span class="s">"2.0"</span><span class="p">,</span>
				<span class="n">ID</span><span class="o">:</span>      <span class="n">req</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span>
				<span class="n">Error</span><span class="o">:</span> <span class="o">&amp;</span><span class="n">jsonrpc</span><span class="o">.</span><span class="n">Error</span><span class="p">{</span>
					<span class="n">Code</span><span class="o">:</span>    <span class="n">jsonrpc</span><span class="o">.</span><span class="n">InvalidParams</span><span class="p">,</span>
					<span class="n">Message</span><span class="o">:</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"invalid initialize params: %v"</span><span class="p">,</span> <span class="n">err</span><span class="p">),</span>
				<span class="p">},</span>
			<span class="p">})</span>
		<span class="p">}</span>
	<span class="p">}</span>

	<span class="k">return</span> <span class="n">s</span><span class="o">.</span><span class="n">writeResponse</span><span class="p">(</span><span class="n">jsonrpc</span><span class="o">.</span><span class="n">Response</span><span class="p">{</span>
		<span class="n">JSONRPC</span><span class="o">:</span> <span class="s">"2.0"</span><span class="p">,</span>
		<span class="n">ID</span><span class="o">:</span>      <span class="n">req</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span>
		<span class="n">Result</span><span class="o">:</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span><span class="p">{</span>
			<span class="s">"protocolVersion"</span><span class="o">:</span> <span class="n">ProtocolVersion</span><span class="p">,</span>
			<span class="s">"capabilities"</span><span class="o">:</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span><span class="p">{</span>
				<span class="s">"tools"</span><span class="o">:</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span><span class="p">{</span>
					<span class="s">"listChanged"</span><span class="o">:</span> <span class="no">false</span><span class="p">,</span>
				<span class="p">},</span>
			<span class="p">},</span>
			<span class="s">"serverInfo"</span><span class="o">:</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span><span class="p">{</span>
				<span class="s">"name"</span><span class="o">:</span>    <span class="s">"go-mcp-server"</span><span class="p">,</span>
				<span class="s">"version"</span><span class="o">:</span> <span class="s">"0.1.0"</span><span class="p">,</span>
			<span class="p">},</span>
			<span class="s">"instructions"</span><span class="o">:</span> <span class="s">"This educational MCP server provides hello_world, health_check, and latency_percentiles tools."</span><span class="p">,</span>
		<span class="p">},</span>
	<span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">notifications/initialized</code> handler flips the <code class="language-plaintext highlighter-rouge">initialized</code> flag:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">Server</span><span class="p">)</span> <span class="n">handleInitializedNotification</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">req</span> <span class="n">jsonrpc</span><span class="o">.</span><span class="n">Request</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
	<span class="n">s</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Lock</span><span class="p">()</span>
	<span class="n">s</span><span class="o">.</span><span class="n">initialized</span> <span class="o">=</span> <span class="no">true</span>
	<span class="n">s</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Unlock</span><span class="p">()</span>
	<span class="n">s</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span><span class="s">"initialized notification received"</span><span class="p">)</span>
	<span class="k">return</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<h4 id="the-initialization-guard">The initialization guard</h4>

<p>Any handler that requires initialization calls <code class="language-plaintext highlighter-rouge">requireInitialized</code> first. If the server hasn’t been initialized, it writes an error response to the client and returns a sentinel error:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">errNotInitialized</span> <span class="o">=</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"server not initialized"</span><span class="p">)</span>

<span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">Server</span><span class="p">)</span> <span class="n">requireInitialized</span><span class="p">(</span><span class="n">req</span> <span class="n">jsonrpc</span><span class="o">.</span><span class="n">Request</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
	<span class="n">s</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">RLock</span><span class="p">()</span>
	<span class="n">initialized</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">initialized</span>
	<span class="n">s</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">RUnlock</span><span class="p">()</span>

	<span class="k">if</span> <span class="n">initialized</span> <span class="p">{</span>
		<span class="k">return</span> <span class="no">nil</span>
	<span class="p">}</span>

	<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">writeResponse</span><span class="p">(</span><span class="n">jsonrpc</span><span class="o">.</span><span class="n">Response</span><span class="p">{</span>
		<span class="n">JSONRPC</span><span class="o">:</span> <span class="s">"2.0"</span><span class="p">,</span>
		<span class="n">ID</span><span class="o">:</span>      <span class="n">req</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span>
		<span class="n">Error</span><span class="o">:</span> <span class="o">&amp;</span><span class="n">jsonrpc</span><span class="o">.</span><span class="n">Error</span><span class="p">{</span>
			<span class="n">Code</span><span class="o">:</span>    <span class="n">jsonrpc</span><span class="o">.</span><span class="n">InvalidRequest</span><span class="p">,</span>
			<span class="n">Message</span><span class="o">:</span> <span class="s">"server has not received notifications/initialized yet"</span><span class="p">,</span>
		<span class="p">},</span>
	<span class="p">});</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">err</span>
	<span class="p">}</span>

	<span class="k">return</span> <span class="n">errNotInitialized</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Returning <code class="language-plaintext highlighter-rouge">errNotInitialized</code> instead of <code class="language-plaintext highlighter-rouge">nil</code> is critical. Without the sentinel, the callers would check <code class="language-plaintext highlighter-rouge">if err != nil</code>, see <code class="language-plaintext highlighter-rouge">nil</code>, and <strong>fall through to execute the handler anyway</strong> – sending a second response for the same request.</p>

<h4 id="tool-listing">Tool listing</h4>

<p><code class="language-plaintext highlighter-rouge">handleToolsList</code> returns all registered tools sorted alphabetically:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">Server</span><span class="p">)</span> <span class="n">handleToolsList</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">req</span> <span class="n">jsonrpc</span><span class="o">.</span><span class="n">Request</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">requireInitialized</span><span class="p">(</span><span class="n">req</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">err</span>
	<span class="p">}</span>

	<span class="n">s</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">RLock</span><span class="p">()</span>
	<span class="k">defer</span> <span class="n">s</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">RUnlock</span><span class="p">()</span>

	<span class="n">defs</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="n">ToolDefinition</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="o">.</span><span class="n">definitions</span><span class="p">))</span>
	<span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">def</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">s</span><span class="o">.</span><span class="n">definitions</span> <span class="p">{</span>
		<span class="n">defs</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">defs</span><span class="p">,</span> <span class="n">def</span><span class="p">)</span>
	<span class="p">}</span>

	<span class="n">sort</span><span class="o">.</span><span class="n">Slice</span><span class="p">(</span><span class="n">defs</span><span class="p">,</span> <span class="k">func</span><span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">j</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">defs</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">.</span><span class="n">Name</span> <span class="o">&lt;</span> <span class="n">defs</span><span class="p">[</span><span class="n">j</span><span class="p">]</span><span class="o">.</span><span class="n">Name</span>
	<span class="p">})</span>

	<span class="k">return</span> <span class="n">s</span><span class="o">.</span><span class="n">writeResponse</span><span class="p">(</span><span class="n">jsonrpc</span><span class="o">.</span><span class="n">Response</span><span class="p">{</span>
		<span class="n">JSONRPC</span><span class="o">:</span> <span class="s">"2.0"</span><span class="p">,</span>
		<span class="n">ID</span><span class="o">:</span>      <span class="n">req</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span>
		<span class="n">Result</span><span class="o">:</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span><span class="p">{</span>
			<span class="s">"tools"</span><span class="o">:</span> <span class="n">defs</span><span class="p">,</span>
		<span class="p">},</span>
	<span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<h4 id="tool-invocation">Tool invocation</h4>

<p><code class="language-plaintext highlighter-rouge">handleToolsCall</code> looks up the tool by name, runs it with a 10-second timeout, and returns either the result or an error:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">Server</span><span class="p">)</span> <span class="n">handleToolsCall</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">req</span> <span class="n">jsonrpc</span><span class="o">.</span><span class="n">Request</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">requireInitialized</span><span class="p">(</span><span class="n">req</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">err</span>
	<span class="p">}</span>

	<span class="k">type</span> <span class="n">callParams</span> <span class="k">struct</span> <span class="p">{</span>
		<span class="n">Name</span>      <span class="kt">string</span>          <span class="s">`json:"name"`</span>
		<span class="n">Arguments</span> <span class="n">json</span><span class="o">.</span><span class="n">RawMessage</span> <span class="s">`json:"arguments"`</span>
	<span class="p">}</span>

	<span class="k">var</span> <span class="n">params</span> <span class="n">callParams</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">json</span><span class="o">.</span><span class="n">Unmarshal</span><span class="p">(</span><span class="n">req</span><span class="o">.</span><span class="n">Params</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">params</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">s</span><span class="o">.</span><span class="n">writeResponse</span><span class="p">(</span><span class="n">jsonrpc</span><span class="o">.</span><span class="n">Response</span><span class="p">{</span>
			<span class="n">JSONRPC</span><span class="o">:</span> <span class="s">"2.0"</span><span class="p">,</span>
			<span class="n">ID</span><span class="o">:</span>      <span class="n">req</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span>
			<span class="n">Error</span><span class="o">:</span> <span class="o">&amp;</span><span class="n">jsonrpc</span><span class="o">.</span><span class="n">Error</span><span class="p">{</span>
				<span class="n">Code</span><span class="o">:</span>    <span class="n">jsonrpc</span><span class="o">.</span><span class="n">InvalidParams</span><span class="p">,</span>
				<span class="n">Message</span><span class="o">:</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"invalid tools/call params: %v"</span><span class="p">,</span> <span class="n">err</span><span class="p">),</span>
			<span class="p">},</span>
		<span class="p">})</span>
	<span class="p">}</span>

	<span class="n">s</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">RLock</span><span class="p">()</span>
	<span class="n">handler</span><span class="p">,</span> <span class="n">ok</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">tools</span><span class="p">[</span><span class="n">params</span><span class="o">.</span><span class="n">Name</span><span class="p">]</span>
	<span class="n">s</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">RUnlock</span><span class="p">()</span>

	<span class="k">if</span> <span class="o">!</span><span class="n">ok</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">s</span><span class="o">.</span><span class="n">writeResponse</span><span class="p">(</span><span class="n">jsonrpc</span><span class="o">.</span><span class="n">Response</span><span class="p">{</span>
			<span class="n">JSONRPC</span><span class="o">:</span> <span class="s">"2.0"</span><span class="p">,</span>
			<span class="n">ID</span><span class="o">:</span>      <span class="n">req</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span>
			<span class="n">Error</span><span class="o">:</span> <span class="o">&amp;</span><span class="n">jsonrpc</span><span class="o">.</span><span class="n">Error</span><span class="p">{</span>
				<span class="n">Code</span><span class="o">:</span>    <span class="n">jsonrpc</span><span class="o">.</span><span class="n">InvalidParams</span><span class="p">,</span>
				<span class="n">Message</span><span class="o">:</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"unknown tool: %s"</span><span class="p">,</span> <span class="n">params</span><span class="o">.</span><span class="n">Name</span><span class="p">),</span>
			<span class="p">},</span>
		<span class="p">})</span>
	<span class="p">}</span>

	<span class="n">callCtx</span><span class="p">,</span> <span class="n">cancel</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">WithTimeout</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="m">10</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">)</span>
	<span class="k">defer</span> <span class="n">cancel</span><span class="p">()</span>

	<span class="n">result</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">handler</span><span class="p">(</span><span class="n">callCtx</span><span class="p">,</span> <span class="n">params</span><span class="o">.</span><span class="n">Arguments</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">s</span><span class="o">.</span><span class="n">writeResponse</span><span class="p">(</span><span class="n">jsonrpc</span><span class="o">.</span><span class="n">Response</span><span class="p">{</span>
			<span class="n">JSONRPC</span><span class="o">:</span> <span class="s">"2.0"</span><span class="p">,</span>
			<span class="n">ID</span><span class="o">:</span>      <span class="n">req</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span>
			<span class="n">Result</span><span class="o">:</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span><span class="p">{</span>
				<span class="s">"content"</span><span class="o">:</span> <span class="p">[]</span><span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span><span class="p">{</span>
					<span class="p">{</span><span class="s">"type"</span><span class="o">:</span> <span class="s">"text"</span><span class="p">,</span> <span class="s">"text"</span><span class="o">:</span> <span class="n">err</span><span class="o">.</span><span class="n">Error</span><span class="p">()},</span>
				<span class="p">},</span>
				<span class="s">"isError"</span><span class="o">:</span> <span class="no">true</span><span class="p">,</span>
			<span class="p">},</span>
		<span class="p">})</span>
	<span class="p">}</span>

	<span class="n">pretty</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">json</span><span class="o">.</span><span class="n">MarshalIndent</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="s">""</span><span class="p">,</span> <span class="s">"  "</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">errors</span><span class="o">.</span><span class="n">WithMessage</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="s">"failed to indent json response"</span><span class="p">)</span>
	<span class="p">}</span>

	<span class="k">return</span> <span class="n">s</span><span class="o">.</span><span class="n">writeResponse</span><span class="p">(</span><span class="n">jsonrpc</span><span class="o">.</span><span class="n">Response</span><span class="p">{</span>
		<span class="n">JSONRPC</span><span class="o">:</span> <span class="s">"2.0"</span><span class="p">,</span>
		<span class="n">ID</span><span class="o">:</span>      <span class="n">req</span><span class="o">.</span><span class="n">ID</span><span class="p">,</span>
		<span class="n">Result</span><span class="o">:</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span><span class="p">{</span>
			<span class="s">"content"</span><span class="o">:</span> <span class="p">[]</span><span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span><span class="p">{</span>
				<span class="p">{</span><span class="s">"type"</span><span class="o">:</span> <span class="s">"text"</span><span class="p">,</span> <span class="s">"text"</span><span class="o">:</span> <span class="kt">string</span><span class="p">(</span><span class="n">pretty</span><span class="p">)},</span>
			<span class="p">},</span>
			<span class="s">"structuredContent"</span><span class="o">:</span> <span class="n">result</span><span class="p">,</span>
			<span class="s">"isError"</span><span class="o">:</span>           <span class="no">false</span><span class="p">,</span>
		<span class="p">},</span>
	<span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Note that tool errors are <strong>not</strong> JSON-RPC errors. Per the MCP spec, a tool that fails returns a successful JSON-RPC response with <code class="language-plaintext highlighter-rouge">isError: true</code> in the result. JSON-RPC errors are reserved for protocol-level problems (bad params, unknown method, etc.).</p>

<p>The following diagram illustrates the full request flow for a <code class="language-plaintext highlighter-rouge">tools/call</code>:</p>

<p><img src="/assets/images/2026-04-09-go-mcp-server/diagram3.png" alt="mcp flow" /></p>

<h3 id="the-tools">The tools</h3>

<p>Each tool is a plain Go function. The <code class="language-plaintext highlighter-rouge">tools</code> package defines three examples.</p>

<h4 id="hello_world">hello_world</h4>

<p>A simple greeter:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// tools/hello.go</span>

<span class="k">type</span> <span class="n">HelloArgs</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Name</span> <span class="kt">string</span> <span class="s">`json:"name"`</span>
<span class="p">}</span>

<span class="k">type</span> <span class="n">HelloResult</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Message</span> <span class="kt">string</span> <span class="s">`json:"message"`</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">Hello</span><span class="p">(</span><span class="n">args</span> <span class="n">HelloArgs</span><span class="p">)</span> <span class="p">(</span><span class="n">HelloResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="n">name</span> <span class="o">:=</span> <span class="n">strings</span><span class="o">.</span><span class="n">TrimSpace</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">Name</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">name</span> <span class="o">==</span> <span class="s">""</span> <span class="p">{</span>
		<span class="n">name</span> <span class="o">=</span> <span class="s">"world"</span>
	<span class="p">}</span>

	<span class="k">return</span> <span class="n">HelloResult</span><span class="p">{</span>
		<span class="n">Message</span><span class="o">:</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"Hello, %s"</span><span class="p">,</span> <span class="n">name</span><span class="p">),</span>
	<span class="p">},</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<h4 id="health_check">health_check</h4>

<p>Performs an HTTP <code class="language-plaintext highlighter-rouge">GET</code> and returns the status code and latency:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// tools/health.go</span>

<span class="k">type</span> <span class="n">HealthCheckArgs</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">URL</span>       <span class="kt">string</span> <span class="s">`json:"url"`</span>
	<span class="n">TimeoutMS</span> <span class="kt">int</span>    <span class="s">`json:"timeout_ms,omitempty"`</span>
<span class="p">}</span>

<span class="k">type</span> <span class="n">HealthCheckResult</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">URL</span>        <span class="kt">string</span> <span class="s">`json:"url"`</span>
	<span class="n">StatusCode</span> <span class="kt">int</span>    <span class="s">`json:"status_code"`</span>
	<span class="n">LatencyMS</span>  <span class="kt">int64</span>  <span class="s">`json:"latency_ms"`</span>
	<span class="n">OK</span>         <span class="kt">bool</span>   <span class="s">`json:"ok"`</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">HealthCheck</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">args</span> <span class="n">HealthCheckArgs</span><span class="p">)</span> <span class="p">(</span><span class="n">HealthCheckResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="k">if</span> <span class="n">args</span><span class="o">.</span><span class="n">URL</span> <span class="o">==</span> <span class="s">""</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">HealthCheckResult</span><span class="p">{},</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"url is required"</span><span class="p">)</span>
	<span class="p">}</span>

	<span class="n">timeout</span> <span class="o">:=</span> <span class="m">3</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Second</span>
	<span class="k">if</span> <span class="n">args</span><span class="o">.</span><span class="n">TimeoutMS</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
		<span class="n">timeout</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">Duration</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">TimeoutMS</span><span class="p">)</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Millisecond</span>
	<span class="p">}</span>

	<span class="n">client</span> <span class="o">:=</span> <span class="n">newHTTPClient</span><span class="p">(</span><span class="n">timeout</span><span class="p">)</span>

	<span class="n">req</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">requestBuilderProvider</span><span class="o">.</span><span class="n">NewRequestWithContext</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">http</span><span class="o">.</span><span class="n">MethodGet</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">URL</span><span class="p">,</span> <span class="no">nil</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">HealthCheckResult</span><span class="p">{},</span> <span class="n">errors</span><span class="o">.</span><span class="n">WithMessage</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="s">"failed to create request"</span><span class="p">)</span>
	<span class="p">}</span>

	<span class="n">start</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">()</span>
	<span class="n">resp</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">client</span><span class="o">.</span><span class="n">Do</span><span class="p">(</span><span class="n">req</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">HealthCheckResult</span><span class="p">{},</span> <span class="n">errors</span><span class="o">.</span><span class="n">WithMessage</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="s">"failed to perform request"</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="k">defer</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
		<span class="n">io</span><span class="o">.</span><span class="n">Copy</span><span class="p">(</span><span class="n">io</span><span class="o">.</span><span class="n">Discard</span><span class="p">,</span> <span class="n">resp</span><span class="o">.</span><span class="n">Body</span><span class="p">)</span>
		<span class="n">resp</span><span class="o">.</span><span class="n">Body</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span>
	<span class="p">}()</span>

	<span class="n">latency</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">Since</span><span class="p">(</span><span class="n">start</span><span class="p">)</span><span class="o">.</span><span class="n">Milliseconds</span><span class="p">()</span>

	<span class="k">return</span> <span class="n">HealthCheckResult</span><span class="p">{</span>
		<span class="n">URL</span><span class="o">:</span>        <span class="n">args</span><span class="o">.</span><span class="n">URL</span><span class="p">,</span>
		<span class="n">StatusCode</span><span class="o">:</span> <span class="n">resp</span><span class="o">.</span><span class="n">StatusCode</span><span class="p">,</span>
		<span class="n">LatencyMS</span><span class="o">:</span>  <span class="n">latency</span><span class="p">,</span>
		<span class="n">OK</span><span class="o">:</span>         <span class="n">resp</span><span class="o">.</span><span class="n">StatusCode</span> <span class="o">&gt;=</span> <span class="m">200</span> <span class="o">&amp;&amp;</span> <span class="n">resp</span><span class="o">.</span><span class="n">StatusCode</span> <span class="o">&lt;</span> <span class="m">300</span><span class="p">,</span>
	<span class="p">},</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Notice that we drain the response body before closing it. This ensures the underlying TCP connection can be reused by the HTTP client’s connection pool. Calling <code class="language-plaintext highlighter-rouge">resp.Body.Close()</code> alone doesn’t guarantee connection reuse if the body wasn’t fully read.</p>

<p>The HTTP client and request builder are abstracted behind interfaces for testability:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// tools/http.go</span>

<span class="k">type</span> <span class="n">requestBuilder</span> <span class="k">interface</span> <span class="p">{</span>
	<span class="n">NewRequestWithContext</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">method</span> <span class="kt">string</span><span class="p">,</span> <span class="n">url</span> <span class="kt">string</span><span class="p">,</span> <span class="n">body</span> <span class="n">io</span><span class="o">.</span><span class="n">Reader</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="n">http</span><span class="o">.</span><span class="n">Request</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
<span class="p">}</span>

<span class="k">var</span> <span class="n">requestBuilderProvider</span> <span class="n">requestBuilder</span> <span class="o">=</span> <span class="o">&amp;</span><span class="n">defaultRequestBuilderProvider</span><span class="p">{}</span>

<span class="k">type</span> <span class="n">httpClient</span> <span class="k">interface</span> <span class="p">{</span>
	<span class="n">Do</span><span class="p">(</span><span class="n">req</span> <span class="o">*</span><span class="n">http</span><span class="o">.</span><span class="n">Request</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="n">http</span><span class="o">.</span><span class="n">Response</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
<span class="p">}</span>

<span class="k">type</span> <span class="n">httpClientFactory</span> <span class="k">func</span><span class="p">(</span><span class="n">timeout</span> <span class="n">time</span><span class="o">.</span><span class="n">Duration</span><span class="p">)</span> <span class="n">httpClient</span>

<span class="k">var</span> <span class="n">newHTTPClient</span> <span class="n">httpClientFactory</span> <span class="o">=</span> <span class="k">func</span><span class="p">(</span><span class="n">timeout</span> <span class="n">time</span><span class="o">.</span><span class="n">Duration</span><span class="p">)</span> <span class="n">httpClient</span> <span class="p">{</span>
	<span class="k">return</span> <span class="o">&amp;</span><span class="n">http</span><span class="o">.</span><span class="n">Client</span><span class="p">{</span><span class="n">Timeout</span><span class="o">:</span> <span class="n">timeout</span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The factory pattern for <code class="language-plaintext highlighter-rouge">httpClient</code> lets us inject different timeouts per request while still being mockable in tests.</p>

<h4 id="latency_percentiles">latency_percentiles</h4>

<p>Computes statistical percentiles for a list of numeric values:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// tools/percentiles.go</span>

<span class="k">type</span> <span class="n">PercentilesArgs</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Values</span> <span class="p">[]</span><span class="kt">float64</span> <span class="s">`json:"values"`</span>
<span class="p">}</span>

<span class="k">type</span> <span class="n">PercentilesResult</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Count</span> <span class="kt">int</span>     <span class="s">`json:"count"`</span>
	<span class="n">Min</span>   <span class="kt">float64</span> <span class="s">`json:"min"`</span>
	<span class="n">P50</span>   <span class="kt">float64</span> <span class="s">`json:"p50"`</span>
	<span class="n">P95</span>   <span class="kt">float64</span> <span class="s">`json:"p95"`</span>
	<span class="n">P99</span>   <span class="kt">float64</span> <span class="s">`json:"p99"`</span>
	<span class="n">Max</span>   <span class="kt">float64</span> <span class="s">`json:"max"`</span>
	<span class="n">Avg</span>   <span class="kt">float64</span> <span class="s">`json:"avg"`</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">Percentiles</span><span class="p">(</span><span class="n">args</span> <span class="n">PercentilesArgs</span><span class="p">)</span> <span class="p">(</span><span class="n">PercentilesResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">Values</span><span class="p">)</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">PercentilesResult</span><span class="p">{},</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"values must not be empty"</span><span class="p">)</span>
	<span class="p">}</span>

	<span class="n">values</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">float64</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">Values</span><span class="p">))</span>
	<span class="nb">copy</span><span class="p">(</span><span class="n">values</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">Values</span><span class="p">)</span>
	<span class="n">sort</span><span class="o">.</span><span class="n">Float64s</span><span class="p">(</span><span class="n">values</span><span class="p">)</span>

	<span class="k">var</span> <span class="n">sum</span> <span class="kt">float64</span>
	<span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">v</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">values</span> <span class="p">{</span>
		<span class="n">sum</span> <span class="o">+=</span> <span class="n">v</span>
	<span class="p">}</span>

	<span class="k">return</span> <span class="n">PercentilesResult</span><span class="p">{</span>
		<span class="n">Count</span><span class="o">:</span> <span class="nb">len</span><span class="p">(</span><span class="n">values</span><span class="p">),</span>
		<span class="n">Min</span><span class="o">:</span>   <span class="n">values</span><span class="p">[</span><span class="m">0</span><span class="p">],</span>
		<span class="n">P50</span><span class="o">:</span>   <span class="n">percentile</span><span class="p">(</span><span class="n">values</span><span class="p">,</span> <span class="m">50</span><span class="p">),</span>
		<span class="n">P95</span><span class="o">:</span>   <span class="n">percentile</span><span class="p">(</span><span class="n">values</span><span class="p">,</span> <span class="m">95</span><span class="p">),</span>
		<span class="n">P99</span><span class="o">:</span>   <span class="n">percentile</span><span class="p">(</span><span class="n">values</span><span class="p">,</span> <span class="m">99</span><span class="p">),</span>
		<span class="n">Max</span><span class="o">:</span>   <span class="n">values</span><span class="p">[</span><span class="nb">len</span><span class="p">(</span><span class="n">values</span><span class="p">)</span><span class="o">-</span><span class="m">1</span><span class="p">],</span>
		<span class="n">Avg</span><span class="o">:</span>   <span class="n">sum</span> <span class="o">/</span> <span class="kt">float64</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">values</span><span class="p">)),</span>
	<span class="p">},</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Note that we copy the input slice before sorting to avoid mutating the caller’s data.</p>

<h4 id="wiring-it-all-together">Wiring it all together</h4>

<p>The <code class="language-plaintext highlighter-rouge">RegisterDefaultTools</code> function connects tool definitions (name, description, input schema) to their handlers:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// tools/tools.go</span>

<span class="k">func</span> <span class="n">RegisterDefaultTools</span><span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">server</span><span class="o">.</span><span class="n">Server</span><span class="p">)</span> <span class="p">{</span>
	<span class="n">s</span><span class="o">.</span><span class="n">RegisterTool</span><span class="p">(</span>
		<span class="n">server</span><span class="o">.</span><span class="n">ToolDefinition</span><span class="p">{</span>
			<span class="n">Name</span><span class="o">:</span>        <span class="s">"hello_world"</span><span class="p">,</span>
			<span class="n">Description</span><span class="o">:</span> <span class="s">"Generate a greeting message for a given name. Use this when the user asks to greet someone, say hello, or produce a simple greeting."</span><span class="p">,</span>
			<span class="n">InputSchema</span><span class="o">:</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span><span class="p">{</span>
				<span class="s">"type"</span><span class="o">:</span> <span class="s">"object"</span><span class="p">,</span>
				<span class="s">"properties"</span><span class="o">:</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span><span class="p">{</span>
					<span class="s">"name"</span><span class="o">:</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span><span class="p">{</span>
						<span class="s">"type"</span><span class="o">:</span>        <span class="s">"string"</span><span class="p">,</span>
						<span class="s">"description"</span><span class="o">:</span> <span class="s">"Optional name to greet."</span><span class="p">,</span>
					<span class="p">},</span>
				<span class="p">},</span>
			<span class="p">},</span>
		<span class="p">},</span>
		<span class="k">func</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">raw</span> <span class="n">json</span><span class="o">.</span><span class="n">RawMessage</span><span class="p">)</span> <span class="p">(</span><span class="n">any</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
			<span class="k">var</span> <span class="n">args</span> <span class="n">HelloArgs</span>
			<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">raw</span><span class="p">)</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
				<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">json</span><span class="o">.</span><span class="n">Unmarshal</span><span class="p">(</span><span class="n">raw</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">args</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
					<span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"decoding arguments: %w"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
				<span class="p">}</span>
			<span class="p">}</span>
			<span class="k">return</span> <span class="n">Hello</span><span class="p">(</span><span class="n">args</span><span class="p">)</span>
		<span class="p">},</span>
	<span class="p">)</span>

	<span class="c">// ... health_check and latency_percentiles follow the same pattern</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">InputSchema</code> follows <a href="https://json-schema.org/">JSON Schema</a> format, which is how MCP clients know what arguments each tool expects.</p>

<h3 id="entry-point-and-graceful-shutdown">Entry point and graceful shutdown</h3>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// cmd/main.go</span>

<span class="k">func</span> <span class="n">run</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">logger</span> <span class="o">*</span><span class="n">slog</span><span class="o">.</span><span class="n">Logger</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
	<span class="n">mcpServer</span> <span class="o">:=</span> <span class="n">server</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">Stdin</span><span class="p">,</span> <span class="n">os</span><span class="o">.</span><span class="n">Stdout</span><span class="p">,</span> <span class="n">logger</span><span class="p">)</span>
	<span class="n">tools</span><span class="o">.</span><span class="n">RegisterDefaultTools</span><span class="p">(</span><span class="n">mcpServer</span><span class="p">)</span>

	<span class="n">shutdown</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="n">os</span><span class="o">.</span><span class="n">Signal</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>
	<span class="n">signal</span><span class="o">.</span><span class="n">Notify</span><span class="p">(</span><span class="n">shutdown</span><span class="p">,</span> <span class="n">os</span><span class="o">.</span><span class="n">Interrupt</span><span class="p">,</span> <span class="n">syscall</span><span class="o">.</span><span class="n">SIGTERM</span><span class="p">)</span>

	<span class="n">ctx</span><span class="p">,</span> <span class="n">cancel</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">WithCancel</span><span class="p">(</span><span class="n">ctx</span><span class="p">)</span>
	<span class="k">defer</span> <span class="n">cancel</span><span class="p">()</span>

	<span class="n">serverErrors</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="kt">error</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>

	<span class="k">go</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
		<span class="n">serverErrors</span> <span class="o">&lt;-</span> <span class="n">mcpServer</span><span class="o">.</span><span class="n">Run</span><span class="p">(</span><span class="n">ctx</span><span class="p">)</span>
	<span class="p">}()</span>

	<span class="k">select</span> <span class="p">{</span>
	<span class="k">case</span> <span class="n">err</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="n">serverErrors</span><span class="o">:</span>
		<span class="k">return</span> <span class="n">errors</span><span class="o">.</span><span class="n">WithMessage</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="s">"MCP server error"</span><span class="p">)</span>
	<span class="k">case</span> <span class="n">sig</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="n">shutdown</span><span class="o">:</span>
		<span class="n">logger</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span><span class="s">"shutdown signal received"</span><span class="p">,</span> <span class="n">slog</span><span class="o">.</span><span class="n">String</span><span class="p">(</span><span class="s">"signal"</span><span class="p">,</span> <span class="n">sig</span><span class="o">.</span><span class="n">String</span><span class="p">()))</span>
		<span class="n">cancel</span><span class="p">()</span>
		<span class="k">return</span> <span class="no">nil</span>
	<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">select</code> between <code class="language-plaintext highlighter-rouge">serverErrors</code> and <code class="language-plaintext highlighter-rouge">shutdown</code> is key. Without it, a <code class="language-plaintext highlighter-rouge">Ctrl+C</code> signal would be captured by the channel but never read, making the server unable to stop.</p>

<h2 id="testing-it-manually">Testing it manually</h2>

<p>Start the server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make run
</code></pre></div></div>

<p>This runs <code class="language-plaintext highlighter-rouge">cat | go run cmd/main.go</code>, keeping stdin open so you can type messages interactively.</p>

<p><strong>Step 1: Initialize</strong></p>

<p>Paste this line and press Enter:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="s2">"2.0"</span><span class="p">,</span><span class="nl">"id"</span><span class="p">:</span><span class="mi">1</span><span class="p">,</span><span class="nl">"method"</span><span class="p">:</span><span class="s2">"initialize"</span><span class="p">,</span><span class="nl">"params"</span><span class="p">:{</span><span class="nl">"protocolVersion"</span><span class="p">:</span><span class="s2">"2025-06-18"</span><span class="p">,</span><span class="nl">"capabilities"</span><span class="p">:{},</span><span class="nl">"clientInfo"</span><span class="p">:{</span><span class="nl">"name"</span><span class="p">:</span><span class="s2">"manual-test"</span><span class="p">,</span><span class="nl">"version"</span><span class="p">:</span><span class="s2">"0.1.0"</span><span class="p">}}}</span><span class="w">
</span></code></pre></div></div>

<p>You’ll get back the server capabilities.</p>

<p><strong>Step 2: Confirm initialization</strong></p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="s2">"2.0"</span><span class="p">,</span><span class="nl">"method"</span><span class="p">:</span><span class="s2">"notifications/initialized"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>No response (it’s a notification).</p>

<p><strong>Step 3: List tools</strong></p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="s2">"2.0"</span><span class="p">,</span><span class="nl">"id"</span><span class="p">:</span><span class="mi">2</span><span class="p">,</span><span class="nl">"method"</span><span class="p">:</span><span class="s2">"tools/list"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Step 4: Call a tool</strong></p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="s2">"2.0"</span><span class="p">,</span><span class="nl">"id"</span><span class="p">:</span><span class="mi">3</span><span class="p">,</span><span class="nl">"method"</span><span class="p">:</span><span class="s2">"tools/call"</span><span class="p">,</span><span class="nl">"params"</span><span class="p">:{</span><span class="nl">"name"</span><span class="p">:</span><span class="s2">"hello_world"</span><span class="p">,</span><span class="nl">"arguments"</span><span class="p">:{</span><span class="nl">"name"</span><span class="p">:</span><span class="s2">"Tiago"</span><span class="p">}}}</span><span class="w">
</span></code></pre></div></div>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="s2">"2.0"</span><span class="p">,</span><span class="nl">"id"</span><span class="p">:</span><span class="mi">4</span><span class="p">,</span><span class="nl">"method"</span><span class="p">:</span><span class="s2">"tools/call"</span><span class="p">,</span><span class="nl">"params"</span><span class="p">:{</span><span class="nl">"name"</span><span class="p">:</span><span class="s2">"health_check"</span><span class="p">,</span><span class="nl">"arguments"</span><span class="p">:{</span><span class="nl">"url"</span><span class="p">:</span><span class="s2">"https://httpbin.org/get"</span><span class="p">,</span><span class="nl">"timeout_ms"</span><span class="p">:</span><span class="mi">5000</span><span class="p">}}}</span><span class="w">
</span></code></pre></div></div>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="s2">"2.0"</span><span class="p">,</span><span class="nl">"id"</span><span class="p">:</span><span class="mi">5</span><span class="p">,</span><span class="nl">"method"</span><span class="p">:</span><span class="s2">"tools/call"</span><span class="p">,</span><span class="nl">"params"</span><span class="p">:{</span><span class="nl">"name"</span><span class="p">:</span><span class="s2">"latency_percentiles"</span><span class="p">,</span><span class="nl">"arguments"</span><span class="p">:{</span><span class="nl">"values"</span><span class="p">:[</span><span class="mf">12.5</span><span class="p">,</span><span class="mf">45.3</span><span class="p">,</span><span class="mf">67.8</span><span class="p">,</span><span class="mf">23.1</span><span class="p">,</span><span class="mf">89.4</span><span class="p">,</span><span class="mf">34.6</span><span class="p">,</span><span class="mf">56.7</span><span class="p">,</span><span class="mf">78.9</span><span class="p">,</span><span class="mf">11.2</span><span class="p">,</span><span class="mf">99.0</span><span class="p">]}}}</span><span class="w">
</span></code></pre></div></div>

<p>Remember: each message must be on a <strong>single line</strong>. The server reads input line by line with <code class="language-plaintext highlighter-rouge">bufio.Scanner</code>, so a line break in the middle of a JSON message will cause a parse error.</p>

<p>Press <code class="language-plaintext highlighter-rouge">Ctrl+C</code> to stop the server.</p>

<h2 id="integration-tests">Integration tests</h2>

<p>Since the server accepts <code class="language-plaintext highlighter-rouge">io.Reader</code>/<code class="language-plaintext highlighter-rouge">io.Writer</code>, we can test the full request/response flow without real stdio. We feed JSON-RPC messages through a <code class="language-plaintext highlighter-rouge">strings.Reader</code> and capture output in a <code class="language-plaintext highlighter-rouge">bytes.Buffer</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// server/server_test.go</span>

<span class="k">func</span> <span class="n">TestRun_ToolsCall_Success</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
	<span class="n">input</span> <span class="o">:=</span> <span class="n">initHandshake</span><span class="p">()</span> <span class="o">+</span>
		<span class="s">`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"msg":"hi"}}}`</span> <span class="o">+</span> <span class="s">"</span><span class="se">\n</span><span class="s">"</span>
	<span class="n">out</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">bytes</span><span class="o">.</span><span class="n">Buffer</span><span class="p">{}</span>
	<span class="n">s</span> <span class="o">:=</span> <span class="n">New</span><span class="p">(</span><span class="n">strings</span><span class="o">.</span><span class="n">NewReader</span><span class="p">(</span><span class="n">input</span><span class="p">),</span> <span class="n">out</span><span class="p">,</span> <span class="n">discardLogger</span><span class="p">())</span>
	<span class="n">registerEchoTool</span><span class="p">(</span><span class="n">s</span><span class="p">)</span>

	<span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">Run</span><span class="p">(</span><span class="n">context</span><span class="o">.</span><span class="n">Background</span><span class="p">())</span>
	<span class="n">require</span><span class="o">.</span><span class="n">NoError</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>

	<span class="n">responses</span> <span class="o">:=</span> <span class="n">parseResponses</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">out</span><span class="p">)</span>
	<span class="n">require</span><span class="o">.</span><span class="n">Len</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">responses</span><span class="p">,</span> <span class="m">2</span><span class="p">)</span> <span class="c">// initialize + tools/call</span>

	<span class="n">callResp</span> <span class="o">:=</span> <span class="n">responses</span><span class="p">[</span><span class="m">1</span><span class="p">]</span>
	<span class="n">require</span><span class="o">.</span><span class="n">Nil</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">callResp</span><span class="o">.</span><span class="n">Error</span><span class="p">)</span>
	<span class="n">require</span><span class="o">.</span><span class="n">Equal</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="kt">float64</span><span class="p">(</span><span class="m">3</span><span class="p">),</span> <span class="n">callResp</span><span class="o">.</span><span class="n">ID</span><span class="p">)</span>

	<span class="n">result</span> <span class="o">:=</span> <span class="n">resultMap</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">callResp</span><span class="p">)</span>
	<span class="n">require</span><span class="o">.</span><span class="n">Equal</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="no">false</span><span class="p">,</span> <span class="n">result</span><span class="p">[</span><span class="s">"isError"</span><span class="p">])</span>
<span class="p">}</span>
</code></pre></div></div>

<p>For error paths, we use custom <code class="language-plaintext highlighter-rouge">io.Writer</code> and <code class="language-plaintext highlighter-rouge">io.Reader</code> implementations:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// errWriter always returns an error on Write.</span>
<span class="k">type</span> <span class="n">errWriter</span> <span class="k">struct</span><span class="p">{}</span>

<span class="k">func</span> <span class="p">(</span><span class="n">w</span> <span class="o">*</span><span class="n">errWriter</span><span class="p">)</span> <span class="n">Write</span><span class="p">(</span><span class="n">p</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">(</span><span class="kt">int</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"write error"</span><span class="p">)</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">TestRun_WriteError_ParseError</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
	<span class="n">input</span> <span class="o">:=</span> <span class="s">"not json</span><span class="se">\n</span><span class="s">"</span>
	<span class="n">s</span> <span class="o">:=</span> <span class="n">New</span><span class="p">(</span><span class="n">strings</span><span class="o">.</span><span class="n">NewReader</span><span class="p">(</span><span class="n">input</span><span class="p">),</span> <span class="o">&amp;</span><span class="n">errWriter</span><span class="p">{},</span> <span class="n">discardLogger</span><span class="p">())</span>

	<span class="n">err</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">Run</span><span class="p">(</span><span class="n">context</span><span class="o">.</span><span class="n">Background</span><span class="p">())</span>
	<span class="n">require</span><span class="o">.</span><span class="n">Error</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
	<span class="n">require</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">err</span><span class="o">.</span><span class="n">Error</span><span class="p">(),</span> <span class="s">"failed to unmarshal request"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>For context cancellation, we use <code class="language-plaintext highlighter-rouge">io.Pipe</code> so the scanner blocks waiting for input, then cancel the context from a goroutine:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">TestRun_ContextCanceled</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
	<span class="n">r</span><span class="p">,</span> <span class="n">w</span> <span class="o">:=</span> <span class="n">io</span><span class="o">.</span><span class="n">Pipe</span><span class="p">()</span>
	<span class="k">defer</span> <span class="n">w</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span>
	<span class="n">out</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">bytes</span><span class="o">.</span><span class="n">Buffer</span><span class="p">{}</span>
	<span class="n">ctx</span><span class="p">,</span> <span class="n">cancel</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">WithCancel</span><span class="p">(</span><span class="n">context</span><span class="o">.</span><span class="n">Background</span><span class="p">())</span>

	<span class="n">s</span> <span class="o">:=</span> <span class="n">New</span><span class="p">(</span><span class="n">r</span><span class="p">,</span> <span class="n">out</span><span class="p">,</span> <span class="n">discardLogger</span><span class="p">())</span>

	<span class="n">done</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="kt">error</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>
	<span class="k">go</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
		<span class="n">done</span> <span class="o">&lt;-</span> <span class="n">s</span><span class="o">.</span><span class="n">Run</span><span class="p">(</span><span class="n">ctx</span><span class="p">)</span>
	<span class="p">}()</span>

	<span class="n">cancel</span><span class="p">()</span>

	<span class="n">err</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="n">done</span>
	<span class="n">require</span><span class="o">.</span><span class="n">ErrorIs</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">err</span><span class="p">,</span> <span class="n">context</span><span class="o">.</span><span class="n">Canceled</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="running-the-tests">Running the tests</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make <span class="nb">test</span>
</code></pre></div></div>

<p>For coverage:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make coverage
</code></pre></div></div>

<h2 id="conclusion">Conclusion</h2>

<p>We’ve built a working MCP server from scratch in Go, covering:</p>

<ul>
  <li><strong>JSON-RPC 2.0</strong> as the wire protocol</li>
  <li><strong>MCP initialization handshake</strong> (initialize -&gt; initialized notification -&gt; ready)</li>
  <li><strong>Tool registration and invocation</strong> with JSON Schema input definitions</li>
  <li><strong>Graceful shutdown</strong> with signal handling and context cancellation</li>
  <li><strong>Integration tests</strong> that achieve 99%+ coverage by testing through the public stdio interface</li>
</ul>

<p>The full source code is available on <a href="https://github.com/tiagomelo/go-mcp-server">GitHub</a>.</p>]]></content><author><name></name></author><category term="golang" /><category term="mcp" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/2026-04-09-go-mcp-server/banner.png" /><media:content medium="image" url="/assets/images/2026-04-09-go-mcp-server/banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Photo Puzzle: turn your photos into jigsaw puzzles</title><link href="/opensource/reactnative/expo/mobile/ios/2026/03/31/photo-puzzle.html" rel="alternate" type="text/html" title="Photo Puzzle: turn your photos into jigsaw puzzles" /><published>2026-03-31T13:29:29+00:00</published><updated>2026-03-31T13:29:29+00:00</updated><id>/opensource/reactnative/expo/mobile/ios/2026/03/31/photo-puzzle</id><content type="html" xml:base="/opensource/reactnative/expo/mobile/ios/2026/03/31/photo-puzzle.html"><![CDATA[<p><img src="/assets/images/2026-03-31-photo-puzzle/banner.png" alt="banner" /></p>

<p>I’m excited to announce that <a href="https://apps.apple.com/us/app/make-your-photo-puzzle/id6760949600">Photo Puzzle</a> is now available on the App Store!</p>

<p>It’s a mobile app that transforms your photos into interactive jigsaw puzzles. Pick any photo from your gallery or snap a new one with your camera, choose a difficulty level, and start solving.</p>

<p>I built it with <a href="https://reactnative.dev/">React Native</a> and <a href="https://expo.dev/">Expo</a>.</p>

<p><a href="https://apps.apple.com/us/app/make-your-photo-puzzle/id6760949600">
  <img src="/assets/images/2026-03-31-photo-puzzle/appstore-badge.svg" alt="Download on the App Store" width="200" />
</a></p>

<hr />

<h2 id="features">features</h2>

<ul>
  <li><strong>Create puzzles from any photo</strong> — use your gallery or take a new picture with the camera</li>
  <li><strong>5 difficulty levels</strong> — from Easy (3×3) to Master (8×8)</li>
  <li><strong>Smooth animations with haptics</strong> — pieces snap into place with satisfying feedback</li>
  <li><strong>Progress tracking</strong> — track your time and number of moves</li>
  <li><strong>Hint system</strong> — toggle piece numbers when you need a little help</li>
  <li><strong>Puzzle gallery</strong> — save and revisit your puzzles</li>
  <li><strong>Pro upgrade</strong> — unlock unlimited hints and remove ads</li>
</ul>

<hr />

<h2 id="screenshots">screenshots</h2>

<!-- TODO: add screenshots -->

<p><img src="/assets/images/2026-03-31-photo-puzzle/screenshot1.webp" alt="screenshot 1" /></p>

<p><img src="/assets/images/2026-03-31-photo-puzzle/screenshot2.webp" alt="screenshot 2" /></p>

<p><img src="/assets/images/2026-03-31-photo-puzzle/screenshot3.webp" alt="screenshot 3" /></p>

<p><img src="/assets/images/2026-03-31-photo-puzzle/screenshot4.webp" alt="screenshot 4" /></p>

<p><img src="/assets/images/2026-03-31-photo-puzzle/screenshot5.webp" alt="screenshot 5" /></p>

<p><img src="/assets/images/2026-03-31-photo-puzzle/screenshot6.webp" alt="screenshot 6" /></p>

<hr />

<h2 id="how-it-works">how it works</h2>

<p>When you select a photo, the app slices it into a grid of pieces based on your chosen difficulty. The pieces are then shuffled, and you drag them into position to reconstruct the original image.</p>

<p>Under the hood, it uses <a href="https://docs.swmansion.com/react-native-gesture-handler/">React Native Gesture Handler</a> for smooth drag interactions and <a href="https://docs.swmansion.com/react-native-reanimated/">React Native Reanimated</a> for performant animations that run on the native thread.</p>

<hr />

<h2 id="tech-stack">tech stack</h2>

<ul>
  <li><a href="https://reactnative.dev/">React Native</a> + <a href="https://expo.dev/">Expo</a></li>
  <li><a href="https://docs.swmansion.com/react-native-gesture-handler/">React Native Gesture Handler</a> for drag interactions</li>
  <li><a href="https://docs.swmansion.com/react-native-reanimated/">React Native Reanimated</a> for animations</li>
  <li><a href="https://docs.expo.dev/versions/latest/sdk/imagemanipulator/">Expo Image Manipulator</a> for slicing photos into puzzle pieces</li>
  <li><a href="https://react-native-iap.dooboolab.com/">React Native IAP</a> for in-app purchases</li>
  <li><a href="https://docs.page/invertase/react-native-google-mobile-ads">Google Mobile Ads</a> for ad integration</li>
</ul>

<hr />

<h2 id="try-it-out">try it out</h2>

<p><a href="https://apps.apple.com/us/app/make-your-photo-puzzle/id6760949600">Photo Puzzle</a> is free to download on the App Store. Give it a try and let me know what you think!</p>]]></content><author><name></name></author><category term="opensource" /><category term="reactnative" /><category term="expo" /><category term="mobile" /><category term="ios" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/2026-03-31-photo-puzzle/banner.png" /><media:content medium="image" url="/assets/images/2026-03-31-photo-puzzle/banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Open source project: go-money</title><link href="/opensource/golang/2026/03/11/go-money.html" rel="alternate" type="text/html" title="Open source project: go-money" /><published>2026-03-11T14:12:58+00:00</published><updated>2026-03-11T14:12:58+00:00</updated><id>/opensource/golang/2026/03/11/go-money</id><content type="html" xml:base="/opensource/golang/2026/03/11/go-money.html"><![CDATA[<p><img src="/assets/images/2026-03-11-go-money/banner.png" alt="banner" /></p>

<p><em>check out my other open source projects <a href="https://tiagomelo.info/opensource/">here</a></em></p>

<h1 id="go-money">go-money</h1>

<p><a href="https://github.com/tiagomelo/go-money">https://github.com/tiagomelo/go-money</a></p>

<p>A simple, idiomatic Go library for working with monetary values, implementing <a href="https://martinfowler.com/eaaCatalog/money.html">Martin Fowler’s Money pattern</a>.</p>

<p>Amounts are stored as <code class="language-plaintext highlighter-rouge">int64</code> minor units (e.g. cents) to avoid floating-point precision issues. All operations return new values — no mutation, no pointers, no panics.</p>

<h2 id="fowlers-money-pattern-compliance">Fowler’s Money pattern compliance</h2>

<p>This library implements <a href="https://martinfowler.com/eaaCatalog/money.html">Martin Fowler’s Money pattern</a> from <em>Patterns of Enterprise Application Architecture</em>:</p>

<table>
  <thead>
    <tr>
      <th>Requirement</th>
      <th>Implementation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Store amount as integer minor units</td>
      <td><code class="language-plaintext highlighter-rouge">int64</code> (cents, pence, etc.)</td>
    </tr>
    <tr>
      <td>Pair amount with currency</td>
      <td><code class="language-plaintext highlighter-rouge">Money</code> struct holds both</td>
    </tr>
    <tr>
      <td>Currency-aware arithmetic</td>
      <td><code class="language-plaintext highlighter-rouge">Add</code>, <code class="language-plaintext highlighter-rouge">Subtract</code> return <code class="language-plaintext highlighter-rouge">error</code> on mismatch</td>
    </tr>
    <tr>
      <td>Multiplication</td>
      <td><code class="language-plaintext highlighter-rouge">Multiply(int)</code></td>
    </tr>
    <tr>
      <td>Allocation without loss</td>
      <td><code class="language-plaintext highlighter-rouge">Allocate</code> and <code class="language-plaintext highlighter-rouge">Split</code> distribute remainders as single units</td>
    </tr>
    <tr>
      <td>Value Object equality</td>
      <td><code class="language-plaintext highlighter-rouge">Equals(Money) (bool, error)</code></td>
    </tr>
    <tr>
      <td>Comparison</td>
      <td><code class="language-plaintext highlighter-rouge">GreaterThan</code>, <code class="language-plaintext highlighter-rouge">LessThan</code> return <code class="language-plaintext highlighter-rouge">(bool, error)</code></td>
    </tr>
    <tr>
      <td>Value semantics</td>
      <td><code class="language-plaintext highlighter-rouge">Money</code> is a plain struct — no pointers, all operations return new values</td>
    </tr>
  </tbody>
</table>

<h2 id="installation">Installation</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go get github.com/tiagomelo/go-money
</code></pre></div></div>

<h2 id="usage">Usage</h2>

<h3 id="creating-a-monetary-value">Creating a monetary value</h3>

<p>Amounts are in minor units (cents, pence, etc.).</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">price</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">1999</span><span class="p">,</span> <span class="n">money</span><span class="o">.</span><span class="n">USD</span><span class="p">)</span> <span class="c">// $19.99</span>
<span class="n">vat</span>   <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">400</span><span class="p">,</span>  <span class="n">money</span><span class="o">.</span><span class="n">EUR</span><span class="p">)</span> <span class="c">// €4.00</span>
</code></pre></div></div>

<h3 id="arithmetic">Arithmetic</h3>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">a</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">1000</span><span class="p">,</span> <span class="n">money</span><span class="o">.</span><span class="n">USD</span><span class="p">)</span>
<span class="n">b</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">250</span><span class="p">,</span>  <span class="n">money</span><span class="o">.</span><span class="n">USD</span><span class="p">)</span>

<span class="n">sum</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">a</span><span class="o">.</span><span class="n">Add</span><span class="p">(</span><span class="n">b</span><span class="p">)</span>       <span class="c">// $12.50</span>
<span class="n">diff</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">a</span><span class="o">.</span><span class="n">Subtract</span><span class="p">(</span><span class="n">b</span><span class="p">)</span> <span class="c">// $7.50</span>
<span class="n">doubled</span> <span class="o">:=</span> <span class="n">a</span><span class="o">.</span><span class="n">Multiply</span><span class="p">(</span><span class="m">2</span><span class="p">)</span>   <span class="c">// $20.00</span>
</code></pre></div></div>

<p>Operations on different currencies return an error:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">usd</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">100</span><span class="p">,</span> <span class="n">money</span><span class="o">.</span><span class="n">USD</span><span class="p">)</span>
<span class="n">eur</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">100</span><span class="p">,</span> <span class="n">money</span><span class="o">.</span><span class="n">EUR</span><span class="p">)</span>

<span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">usd</span><span class="o">.</span><span class="n">Add</span><span class="p">(</span><span class="n">eur</span><span class="p">)</span> <span class="c">// err: "cannot add EUR to USD"</span>
</code></pre></div></div>

<h3 id="comparison">Comparison</h3>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">a</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">1000</span><span class="p">,</span> <span class="n">money</span><span class="o">.</span><span class="n">USD</span><span class="p">)</span>
<span class="n">b</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">500</span><span class="p">,</span>  <span class="n">money</span><span class="o">.</span><span class="n">USD</span><span class="p">)</span>

<span class="n">eq</span><span class="p">,</span>  <span class="n">err</span> <span class="o">:=</span> <span class="n">a</span><span class="o">.</span><span class="n">Equals</span><span class="p">(</span><span class="n">b</span><span class="p">)</span>      <span class="c">// false, nil</span>
<span class="n">gt</span><span class="p">,</span>  <span class="n">err</span> <span class="o">:=</span> <span class="n">a</span><span class="o">.</span><span class="n">GreaterThan</span><span class="p">(</span><span class="n">b</span><span class="p">)</span> <span class="c">// true,  nil</span>
<span class="n">lt</span><span class="p">,</span>  <span class="n">err</span> <span class="o">:=</span> <span class="n">a</span><span class="o">.</span><span class="n">LessThan</span><span class="p">(</span><span class="n">b</span><span class="p">)</span>    <span class="c">// false, nil</span>

<span class="n">same</span> <span class="o">:=</span> <span class="n">a</span><span class="o">.</span><span class="n">SameCurrency</span><span class="p">(</span><span class="n">b</span><span class="p">)</span> <span class="c">// true</span>
</code></pre></div></div>

<p>Comparing different currencies returns an error:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">usd</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">100</span><span class="p">,</span> <span class="n">money</span><span class="o">.</span><span class="n">USD</span><span class="p">)</span>
<span class="n">eur</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">100</span><span class="p">,</span> <span class="n">money</span><span class="o">.</span><span class="n">EUR</span><span class="p">)</span>

<span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">usd</span><span class="o">.</span><span class="n">GreaterThan</span><span class="p">(</span><span class="n">eur</span><span class="p">)</span> <span class="c">// err: "currency mismatch: USD and EUR"</span>
</code></pre></div></div>

<h3 id="absolute-value">Absolute value</h3>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">m</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="o">-</span><span class="m">500</span><span class="p">,</span> <span class="n">money</span><span class="o">.</span><span class="n">USD</span><span class="p">)</span>
<span class="n">abs</span> <span class="o">:=</span> <span class="n">m</span><span class="o">.</span><span class="n">Absolute</span><span class="p">()</span> <span class="c">// $5.00</span>
</code></pre></div></div>

<h3 id="splitting">Splitting</h3>

<p>Divides an amount into <code class="language-plaintext highlighter-rouge">n</code> equal parts. Any remainder is distributed one unit at a time to the first parts, ensuring no value is lost.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">m</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">101</span><span class="p">,</span> <span class="n">money</span><span class="o">.</span><span class="n">USD</span><span class="p">)</span> <span class="c">// $1.01</span>

<span class="n">parts</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">m</span><span class="o">.</span><span class="n">Split</span><span class="p">(</span><span class="m">4</span><span class="p">)</span>
<span class="c">// parts[0] = $0.26  (gets the leftover cent)</span>
<span class="c">// parts[1] = $0.25</span>
<span class="c">// parts[2] = $0.25</span>
<span class="c">// parts[3] = $0.25</span>
<span class="c">// sum = $1.01 ✓</span>
</code></pre></div></div>

<h3 id="allocation">Allocation</h3>

<p>Distributes an amount according to ratios. Remainder is distributed one unit at a time to the first allocations, ensuring no value is lost.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">m</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">100</span><span class="p">,</span> <span class="n">money</span><span class="o">.</span><span class="n">USD</span><span class="p">)</span> <span class="c">// $1.00</span>

<span class="c">// 2:1 split</span>
<span class="n">parts</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">m</span><span class="o">.</span><span class="n">Allocate</span><span class="p">(</span><span class="m">2</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>
<span class="c">// parts[0] = $0.67</span>
<span class="c">// parts[1] = $0.33</span>
<span class="c">// sum = $1.00 ✓</span>

<span class="c">// Equal three-way split with a remainder</span>
<span class="n">m2</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">101</span><span class="p">,</span> <span class="n">money</span><span class="o">.</span><span class="n">USD</span><span class="p">)</span> <span class="c">// $1.01</span>
<span class="n">parts</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">m2</span><span class="o">.</span><span class="n">Allocate</span><span class="p">(</span><span class="m">1</span><span class="p">,</span> <span class="m">1</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>
<span class="c">// parts[0] = $0.34  (gets the leftover cent)</span>
<span class="c">// parts[1] = $0.34</span>
<span class="c">// parts[2] = $0.33</span>
<span class="c">// sum = $1.01 ✓</span>
</code></pre></div></div>

<h3 id="display">Display</h3>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">m</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">123456</span><span class="p">,</span> <span class="n">money</span><span class="o">.</span><span class="n">USD</span><span class="p">)</span>
<span class="n">fmt</span><span class="o">.</span><span class="n">Println</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">Display</span><span class="p">())</span>        <span class="c">// $1,234.56</span>
<span class="n">fmt</span><span class="o">.</span><span class="n">Println</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">AsMajorUnits</span><span class="p">())</span>  <span class="c">// 1234.56 (float64, display only)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">AsMajorUnits</code> returns a <code class="language-plaintext highlighter-rouge">float64</code> and should never be used for arithmetic.</p>

<h3 id="json">JSON</h3>

<p><code class="language-plaintext highlighter-rouge">Money</code> implements <code class="language-plaintext highlighter-rouge">json.Marshaler</code> and <code class="language-plaintext highlighter-rouge">json.Unmarshaler</code>. The amount is serialized in minor units.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">m</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">1999</span><span class="p">,</span> <span class="n">money</span><span class="o">.</span><span class="n">USD</span><span class="p">)</span>

<span class="n">b</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">json</span><span class="o">.</span><span class="n">Marshal</span><span class="p">(</span><span class="n">m</span><span class="p">)</span>
<span class="c">// {"amount":1999,"currency":"USD"}</span>

<span class="k">var</span> <span class="n">got</span> <span class="n">money</span><span class="o">.</span><span class="n">Money</span>
<span class="n">err</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">Unmarshal</span><span class="p">(</span><span class="n">b</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">got</span><span class="p">)</span>
<span class="c">// got == m</span>

<span class="c">// Unknown currency codes are rejected:</span>
<span class="n">err</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">Unmarshal</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">`{"amount":100,"currency":"XYZ"}`</span><span class="p">),</span> <span class="o">&amp;</span><span class="n">got</span><span class="p">)</span>
<span class="c">// err: "unknown currency code: XYZ"</span>
</code></pre></div></div>

<h3 id="database-storage">Database storage</h3>

<p><code class="language-plaintext highlighter-rouge">Money</code> and <code class="language-plaintext highlighter-rouge">Currency</code> both implement <code class="language-plaintext highlighter-rouge">driver.Valuer</code> and <code class="language-plaintext highlighter-rouge">sql.Scanner</code>, so they work directly with <code class="language-plaintext highlighter-rouge">database/sql</code> and any compatible ORM.</p>

<p>The recommended schema stores amount and currency in <strong>two separate columns</strong>:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">products</span> <span class="p">(</span>
    <span class="n">id</span>            <span class="nb">SERIAL</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
    <span class="n">price_amount</span>  <span class="nb">BIGINT</span>      <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>  <span class="c1">-- minor units (e.g. cents)</span>
    <span class="n">price_currency</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span> <span class="k">NOT</span> <span class="k">NULL</span>   <span class="c1">-- ISO 4217 code (e.g. 'USD')</span>
<span class="p">);</span>
</code></pre></div></div>

<p><strong>Writing:</strong></p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">m</span> <span class="o">:=</span> <span class="n">money</span><span class="o">.</span><span class="n">NewMoney</span><span class="p">(</span><span class="m">1999</span><span class="p">,</span> <span class="n">money</span><span class="o">.</span><span class="n">USD</span><span class="p">)</span> <span class="c">// $19.99</span>

<span class="n">amountVal</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">m</span><span class="o">.</span><span class="n">Value</span><span class="p">()</span>           <span class="c">// int64(1999)</span>
<span class="n">currencyVal</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">m</span><span class="o">.</span><span class="n">Currency</span><span class="o">.</span><span class="n">Value</span><span class="p">()</span> <span class="c">// "USD"</span>

<span class="n">db</span><span class="o">.</span><span class="n">Exec</span><span class="p">(</span>
    <span class="s">`INSERT INTO products (price_amount, price_currency) VALUES ($1, $2)`</span><span class="p">,</span>
    <span class="n">amountVal</span><span class="p">,</span> <span class="n">currencyVal</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p><strong>Reading:</strong></p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">m</span> <span class="n">money</span><span class="o">.</span><span class="n">Money</span>

<span class="n">row</span> <span class="o">:=</span> <span class="n">db</span><span class="o">.</span><span class="n">QueryRow</span><span class="p">(</span><span class="s">`SELECT price_amount, price_currency FROM products WHERE id = $1`</span><span class="p">,</span> <span class="n">id</span><span class="p">)</span>
<span class="n">err</span> <span class="o">:=</span> <span class="n">row</span><span class="o">.</span><span class="n">Scan</span><span class="p">(</span><span class="o">&amp;</span><span class="n">m</span><span class="o">.</span><span class="n">Amount</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">m</span><span class="o">.</span><span class="n">Currency</span><span class="p">)</span>
<span class="c">// m is fully reconstructed, including all currency formatting metadata</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Currency.Scan</code> validates the code and returns an error for unknown values:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">c</span> <span class="n">money</span><span class="o">.</span><span class="n">Currency</span>
<span class="n">err</span> <span class="o">:=</span> <span class="n">c</span><span class="o">.</span><span class="n">Scan</span><span class="p">(</span><span class="s">"XYZ"</span><span class="p">)</span> <span class="c">// err: "unknown currency code: XYZ"</span>
</code></pre></div></div>

<h3 id="supported-currencies">Supported currencies</h3>

<p>All <a href="https://en.wikipedia.org/wiki/ISO_4217">ISO 4217 currencies</a> are supported as package-level variables:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">money</span><span class="o">.</span><span class="n">USD</span>  <span class="n">money</span><span class="o">.</span><span class="n">EUR</span>  <span class="n">money</span><span class="o">.</span><span class="n">GBP</span>  <span class="n">money</span><span class="o">.</span><span class="n">JPY</span>
<span class="n">money</span><span class="o">.</span><span class="n">BRL</span>  <span class="n">money</span><span class="o">.</span><span class="n">CAD</span>  <span class="n">money</span><span class="o">.</span><span class="n">AUD</span>  <span class="n">money</span><span class="o">.</span><span class="n">CHF</span>
<span class="c">// ... and 150+ more</span>
</code></pre></div></div>

<h2 id="design-notes">Design notes</h2>

<ul>
  <li><strong>Minor units</strong>: amounts are always <code class="language-plaintext highlighter-rouge">int64</code> minor units. <code class="language-plaintext highlighter-rouge">NewMoney(100, money.USD)</code> is $1.00, not $100.</li>
  <li><strong>Value semantics</strong>: <code class="language-plaintext highlighter-rouge">Money</code> is a plain struct, not a pointer. Assigning it copies it.</li>
  <li><strong>No panics</strong>: all error conditions (currency mismatch, invalid split/allocate arguments, unknown currency) return <code class="language-plaintext highlighter-rouge">error</code>.</li>
  <li><strong>Type-safe currencies</strong>: currency codes are a dedicated unexported type; only the predefined package variables (<code class="language-plaintext highlighter-rouge">money.USD</code>, <code class="language-plaintext highlighter-rouge">money.EUR</code>, etc.) are valid — you cannot pass an arbitrary string.</li>
  <li><strong>Remainder-safe arithmetic</strong>: <code class="language-plaintext highlighter-rouge">Split</code> and <code class="language-plaintext highlighter-rouge">Allocate</code> guarantee that <code class="language-plaintext highlighter-rouge">sum(parts) == original</code>, distributing any remainder as single units to the first elements.</li>
  <li><strong>Database-ready</strong>: <code class="language-plaintext highlighter-rouge">Money</code> and <code class="language-plaintext highlighter-rouge">Currency</code> implement <code class="language-plaintext highlighter-rouge">driver.Valuer</code> and <code class="language-plaintext highlighter-rouge">sql.Scanner</code>. Store them as two columns — amount (<code class="language-plaintext highlighter-rouge">BIGINT</code>) and currency code (<code class="language-plaintext highlighter-rouge">VARCHAR(3)</code>). Unknown currency codes are rejected on scan.</li>
</ul>

<h2 id="running-tests">Running tests</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make <span class="nb">test</span>
</code></pre></div></div>

<p>With coverage report:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make coverage
</code></pre></div></div>

<h2 id="license">License</h2>

<p>MIT.</p>]]></content><author><name></name></author><category term="opensource" /><category term="golang" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/2026-03-11-go-money/banner.png" /><media:content medium="image" url="/assets/images/2026-03-11-go-money/banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Understanding PgBouncer: connection pooling for PostgreSQL</title><link href="/psql/pgbouncer/prometheus/grafana/2026/02/26/pgbouncer.html" rel="alternate" type="text/html" title="Understanding PgBouncer: connection pooling for PostgreSQL" /><published>2026-02-26T12:22:14+00:00</published><updated>2026-02-26T12:22:14+00:00</updated><id>/psql/pgbouncer/prometheus/grafana/2026/02/26/pgbouncer</id><content type="html" xml:base="/psql/pgbouncer/prometheus/grafana/2026/02/26/pgbouncer.html"><![CDATA[<p><img src="/assets/images/2026-02-26-pgbouncer/banner.png" alt="banner" /></p>

<p><a href="https://www.postgresql.org/">PostgreSQL</a> has a configurable limit on the number of simultaneous client connections:
<a href="https://postgresqlco.nf/doc/en/param/max_connections/"><code class="language-plaintext highlighter-rouge">max_connections</code></a>. When an application exceeds this limit, <a href="https://www.postgresql.org/">Postgres</a> refuses new connections with
<code class="language-plaintext highlighter-rouge">FATAL: sorry, too many clients already</code>. This is not a theoretical concern — it is a real
failure mode that affects production systems as they grow.</p>

<p>This article explains what <a href="https://www.pgbouncer.org/">PgBouncer</a> is, how it works, and why it helps. To make the benefits
concrete, we will run two benchmarks using <a href="https://www.postgresql.org/docs/current/pgbench.html">pgbench</a> and observe the results in <a href="https://grafana.com/">Grafana</a>, comparing
direct <a href="https://www.postgresql.org/">Postgres</a> connections against connections through <a href="https://www.pgbouncer.org/">PgBouncer</a>.</p>

<p>All code is available at <a href="https://github.com/tiagomelo/pgbouncer-demo">https://github.com/tiagomelo/pgbouncer-demo</a>.</p>

<hr />

<h2 id="how-postgresql-manages-connections">How PostgreSQL manages connections</h2>

<p><a href="https://www.postgresql.org/">PostgreSQL</a> uses a process-per-connection model. Every client connection spawns a dedicated
backend process on the server. That process allocates memory, holds file descriptors, and
participates in lock management for the entire duration of the connection — whether it is
executing a query or sitting idle between requests.</p>

<p>This works well at low connection counts. At high connection counts, two problems emerge.
First, Postgres runs out of <a href="https://postgresqlco.nf/doc/en/param/max_connections/"><code class="language-plaintext highlighter-rouge">max_connections</code></a> slots and starts refusing clients. Second, even
before hitting that limit, the overhead of managing many concurrent backend processes — each
consuming shared memory and competing for resources — begins to degrade throughput.</p>

<p>The underlying issue is that most connections in a typical web application are idle most of the
time. A pool of 100 application threads might keep 100 Postgres connections open, but only a
handful execute queries at any given moment. The rest hold server resources while doing nothing.</p>

<hr />

<h2 id="what-pgbouncer-does">What PgBouncer does</h2>

<p><a href="https://www.pgbouncer.org/">PgBouncer</a> is a connection pooler. It sits between the application and <a href="https://www.postgresql.org/">Postgres</a>, accepting client
connections on one side and maintaining a small pool of real server connections on the other.</p>

<p>When a client connects to <a href="https://www.pgbouncer.org/">PgBouncer</a>, it does not immediately get a <a href="https://www.postgresql.org/">Postgres</a> connection. Instead,
<a href="https://www.pgbouncer.org/">PgBouncer</a> assigns the client a server connection from the pool when the client needs to execute
a transaction, and returns that connection to the pool when the transaction is complete. From
the application’s perspective, it has a connection. From <a href="https://www.postgresql.org/">Postgres’s</a> perspective, it only ever
sees the small pool.</p>

<p>This means you can have 200 application clients connected to <a href="https://www.pgbouncer.org/">PgBouncer</a> while <a href="https://www.postgresql.org/">Postgres</a> only
maintains 25 server connections. <a href="https://www.pgbouncer.org/">PgBouncer</a> handles the multiplexing.</p>

<h3 id="pool-modes">Pool modes</h3>

<p><a href="https://www.pgbouncer.org/">PgBouncer</a> supports <a href="https://www.pgbouncer.org/config.html">three pool modes</a> that differ in when server connections are returned to the
pool:</p>

<p><strong>Session mode</strong> — a server connection is assigned to a client for the entire session. The
client holds the connection until it disconnects. This is the most compatible mode but provides
the least pooling benefit, since each client still occupies a server connection for its full
lifetime.</p>

<p><strong>Transaction mode</strong> — a server connection is held only for the duration of a single
transaction, then returned to the pool. This is the most efficient mode and the one used in
this article. A server connection is only consumed when work is actually being done.</p>

<p><strong>Statement mode</strong> — a server connection is returned to the pool after each individual
statement. This is rarely used because it breaks multi-statement transactions.</p>

<p>Transaction mode comes with one important limitation: features that maintain state across
transactions do not work. This includes <code class="language-plaintext highlighter-rouge">SET</code> session variables, advisory locks,
<code class="language-plaintext highlighter-rouge">LISTEN/NOTIFY</code>, and protocol-level prepared statements. For most standard OLTP workloads —
REST APIs, background job processors, microservices — this is not a problem.</p>

<hr />

<h2 id="the-demo-setup">The demo setup</h2>

<p>To show <a href="https://www.pgbouncer.org/">PgBouncer’s</a> effects concretely, we will run <a href="https://www.postgresql.org/docs/current/pgbench.html">pgbench</a> against <a href="https://www.postgresql.org/">Postgres</a> directly and then
against <a href="https://www.pgbouncer.org/">PgBouncer</a>, observing the results through <a href="https://prometheus.io/">Prometheus</a> and <a href="https://grafana.com/">Grafana</a>.</p>

<p>The stack runs entirely in Docker:</p>

<ul>
  <li><strong>PostgreSQL 17</strong> — configured with <code class="language-plaintext highlighter-rouge">max_connections=100</code></li>
  <li><strong>PgBouncer</strong> (<a href="https://hub.docker.com/r/edoburu/pgbouncer/"><code class="language-plaintext highlighter-rouge">edoburu/pgbouncer</code></a>) — the connection pooler</li>
  <li><strong>postgres_exporter</strong> — collects Postgres metrics for Prometheus</li>
  <li><strong>pgbouncer_exporter</strong> — collects PgBouncer pool metrics for Prometheus</li>
  <li><strong>Prometheus</strong> — stores the metrics</li>
  <li><strong>Grafana</strong> — displays the metrics in real time</li>
</ul>

<h3 id="docker-composeyaml">docker-compose.yaml</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">postgres</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">postgres:17</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">${POSTGRES_DATABASE_CONTAINER_NAME}</span>
    <span class="na">command</span><span class="pi">:</span> <span class="s">postgres -c max_connections=$POSTGRES_MAX_CONNECTIONS</span>
    <span class="na">env_file</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">.env</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">$POSTGRES_PORT:$POSTGRES_PORT"</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">postgres_data:/var/lib/postgresql/data</span>
    <span class="na">healthcheck</span><span class="pi">:</span>
      <span class="na">test</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">CMD-SHELL"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">pg_isready</span><span class="nv"> </span><span class="s">-U</span><span class="nv"> </span><span class="s">$POSTGRES_USER</span><span class="nv"> </span><span class="s">-d</span><span class="nv"> </span><span class="s">$POSTGRES_DB"</span><span class="pi">]</span>
      <span class="na">interval</span><span class="pi">:</span> <span class="s">$POSTGRES_HEALTH_CHECK_INTERVAL</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="s">$POSTGRES_HEALTH_CHECK_TIMEOUT</span>
      <span class="na">retries</span><span class="pi">:</span> <span class="s">$POSTGRES_HEALTH_CHECK_RETRIES</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">psqldb-network</span>

  <span class="na">pgbouncer</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">edoburu/pgbouncer</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">${PGBOUNCER_CONTAINER_NAME}</span>
    <span class="na">env_file</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">.env</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">$PGBOUNCER_PORT:$PGBOUNCER_PORT"</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="na">postgres</span><span class="pi">:</span>
        <span class="na">condition</span><span class="pi">:</span> <span class="s">service_healthy</span>
    <span class="na">healthcheck</span><span class="pi">:</span>
      <span class="na">test</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">CMD-SHELL"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">cat</span><span class="nv"> </span><span class="s">/proc/1/status</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">grep</span><span class="nv"> </span><span class="s">-q</span><span class="nv"> </span><span class="s">pgbouncer"</span><span class="pi">]</span>
      <span class="na">interval</span><span class="pi">:</span> <span class="s">$PGBOUNCER_HEALTH_CHECK_INTERVAL</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="s">$PGBOUNCER_HEALTH_CHECK_TIMEOUT</span>
      <span class="na">retries</span><span class="pi">:</span> <span class="s">$PGBOUNCER_HEALTH_CHECK_RETRIES</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">psqldb-network</span>

  <span class="na">postgres_exporter</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">quay.io/prometheuscommunity/postgres-exporter</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">${POSTGRES_EXPORTER_CONTAINER_NAME}</span>
    <span class="na">env_file</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">.env</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">DATA_SOURCE_URI</span><span class="pi">:</span> <span class="s2">"</span><span class="s">postgres:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable"</span>
      <span class="na">DATA_SOURCE_USER</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${POSTGRES_USER}"</span>
      <span class="na">DATA_SOURCE_PASS</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${POSTGRES_PASSWORD}"</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">$POSTGRES_EXPORTER_PORT:$POSTGRES_EXPORTER_PORT"</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">psqldb-network</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">postgres</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">always</span>
  
  <span class="na">pgbouncer_exporter</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">prometheuscommunity/pgbouncer-exporter</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">${PGBOUNCER_EXPORTER_CONTAINER_NAME}</span>
    <span class="na">command</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">--pgBouncer.connectionString=postgres://test:test@pgbouncer:6432/pgbouncer?sslmode=disable"</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">9127:9127"</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">psqldb-network</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">pgbouncer</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">on-failure</span>

  <span class="na">renderer</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">grafana/grafana-image-renderer:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">${GRAFANA_RENDERER_CONTAINER_NAME}</span>
    <span class="na">expose</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">$GRAFANA_RENDERING_PORT"</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">psqldb-network</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">on-failure</span>

  <span class="na">grafana</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">grafana/grafana:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">${GRAFANA_CONTAINER_NAME}</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">3000:3000</span>
    <span class="na">env_file</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">.env</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">grafana_data:/var/lib/grafana</span>
      <span class="pi">-</span> <span class="s">./grafana/dashboards:/etc/grafana/provisioning/dashboards</span>
      <span class="pi">-</span> <span class="s">./grafana/datasources:/etc/grafana/provisioning/datasources</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">psqldb-network</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">renderer</span>

  <span class="na">prometheus</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">prom/prometheus:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">${PROMETHEUS_CONTAINER_NAME}</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro</span>
      <span class="pi">-</span> <span class="s">prometheus_data:/prometheus</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">$PROMETHEUS_PORT:$PROMETHEUS_PORT"</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">psqldb-network</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">postgres_exporter</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">on-failure</span>

<span class="na">networks</span><span class="pi">:</span>
  <span class="na">psqldb-network</span><span class="pi">:</span>
    <span class="na">driver</span><span class="pi">:</span> <span class="s">bridge</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">grafana_data</span><span class="pi">:</span>
  <span class="na">postgres_data</span><span class="pi">:</span>
  <span class="na">prometheus_data</span><span class="pi">:</span>
</code></pre></div></div>

<h3 id="pgbouncer-configuration">PgBouncer Configuration</h3>

<p>The <a href="https://hub.docker.com/r/edoburu/pgbouncer/"><code class="language-plaintext highlighter-rouge">edoburu/pgbouncer</code></a> image generates <code class="language-plaintext highlighter-rouge">pgbouncer.ini</code> from environment variables at startup:</p>

<pre><code class="language-dotenv">PGBOUNCER_PORT=6432
DB_HOST=postgres
DB_USER=test
DB_PASSWORD=test
DB_NAME=testdb
POOL_MODE=transaction
MAX_CLIENT_CONN=300
DEFAULT_POOL_SIZE=25
LISTEN_PORT=6432
AUTH_TYPE=plain
STATS_USERS=test
ADMIN_USERS=test
</code></pre>

<p><code class="language-plaintext highlighter-rouge">DEFAULT_POOL_SIZE=25</code> means <a href="https://www.pgbouncer.org/">PgBouncer</a> will maintain at most 25 real Postgres connections,
regardless of how many clients are connected to it. <code class="language-plaintext highlighter-rouge">MAX_CLIENT_CONN=300</code> sets the maximum
number of clients that can connect to PgBouncer itself.</p>

<p><code class="language-plaintext highlighter-rouge">STATS_USERS</code> and <code class="language-plaintext highlighter-rouge">ADMIN_USERS</code> grant the <code class="language-plaintext highlighter-rouge">test</code> user access to <a href="https://www.pgbouncer.org/">PgBouncer’s</a> internal statistics
interface. This is required by <a href="https://github.com/prometheus-community/pgbouncer_exporter"><code class="language-plaintext highlighter-rouge">pgbouncer_exporter</code></a> to collect pool metrics — without it, the
exporter connects successfully but cannot query pool data.</p>

<h3 id="the-pgbouncer_exporter">The pgbouncer_exporter</h3>

<p><a href="https://github.com/prometheus-community/pgbouncer_exporter"><code class="language-plaintext highlighter-rouge">pgbouncer_exporter</code></a> collects pool metrics by connecting to PgBouncer’s internal virtual
database called <code class="language-plaintext highlighter-rouge">pgbouncer</code>, which exposes statistics through special <code class="language-plaintext highlighter-rouge">SHOW</code> commands such as
<code class="language-plaintext highlighter-rouge">SHOW POOLS</code> and <code class="language-plaintext highlighter-rouge">SHOW STATS</code>. It requires the connection string as a CLI flag:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">command</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s2">"</span><span class="s">--pgBouncer.connectionString=postgres://test:test@pgbouncer:6432/pgbouncer?sslmode=disable"</span>
</code></pre></div></div>

<p>Note that the database in the connection string is <code class="language-plaintext highlighter-rouge">pgbouncer</code>, not your application database.
This virtual database only exists inside <a href="https://www.pgbouncer.org/">PgBouncer</a> — it is not a real <a href="https://www.postgresql.org/">Postgres</a> database.</p>

<h3 id="prometheus-configuration">Prometheus configuration</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">scrape_configs</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">job_name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">postgres'</span>
    <span class="na">scrape_interval</span><span class="pi">:</span> <span class="s">10s</span>
    <span class="na">static_configs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">targets</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">postgres_exporter:9187'</span><span class="pi">]</span>
  <span class="pi">-</span> <span class="na">job_name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">pgbouncer'</span>
    <span class="na">scrape_interval</span><span class="pi">:</span> <span class="s">10s</span>
    <span class="na">static_configs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">targets</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">pgbouncer_exporter:9127'</span><span class="pi">]</span>
</code></pre></div></div>

<h3 id="grafana-provisioning">Grafana provisioning</h3>

<p><a href="https://grafana.com/">Grafana</a> loads dashboards and datasources automatically from the filesystem on startup via
provisioning configuration files in <code class="language-plaintext highlighter-rouge">grafana/dashboards/</code> and <code class="language-plaintext highlighter-rouge">grafana/datasources/</code>. Both
benchmark dashboards are version-controlled as JSON and available immediately on first run
without any manual setup.</p>

<p>I have some articles showing how to provision dashboards and datasources:</p>

<ul>
  <li><a href="https://tiagomelo.info/golang/prometheus/grafana/observability/2025/10/22/go-grafana-prometheus-example.html">Observability in Go with Prometheus and Grafana: From Metrics to Dashboards</a></li>
  <li><a href="https://tiagomelo.info/go/grpc/grafana/backpressure/2023/08/21/golang-out-of-box-backpressure-handling-grpc-proven-grafana-melo.html">Golang: out-of-box backpressure handling with gRPC, proven by a Grafana dashboard</a></li>
</ul>

<hr />

<h2 id="what-we-measure">What we measure</h2>

<p>To make the comparison meaningful, we track the same metrics during both benchmarks:</p>

<p><strong>Connection utilization</strong> — how much of <code class="language-plaintext highlighter-rouge">max_connections</code> is consumed, and how many connections
are active vs idle in <a href="https://www.postgresql.org/">Postgres</a> at any given time. This shows directly how <a href="https://www.pgbouncer.org/">PgBouncer</a> changes
<a href="https://www.postgresql.org/">Postgres’s</a> view of the workload.</p>

<p><strong>Throughput</strong> — transactions per second committed to the database, measured from
<code class="language-plaintext highlighter-rouge">pg_stat_database</code>.</p>

<p><strong>Memory pressure</strong> — the rate of buffer allocations and bgwriter activity from
<code class="language-plaintext highlighter-rouge">pg_stat_bgwriter</code>. High connection counts cause <a href="https://www.postgresql.org/">Postgres</a> to allocate more shared memory and
work the bgwriter harder.</p>

<p>The <a href="https://www.pgbouncer.org/">PgBouncer</a> dashboard adds one additional panel that has no equivalent in the direct benchmark:
the pool utilization chart showing client connections, waiting clients, active server connections,
and idle server connections side by side. This panel directly illustrates the multiplexing
happening inside <a href="https://www.pgbouncer.org/">PgBouncer</a>.</p>

<hr />

<h2 id="running-the-benchmarks">Running the Benchmarks</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make setup               <span class="c"># clean start + initialize pgbench data (run once)</span>
make benchmark-direct    <span class="c"># run direct Postgres benchmark — Ctrl-C when done</span>
make reset               <span class="c"># reset pgbench schema between runs</span>
make benchmark-pgbouncer <span class="c"># run PgBouncer benchmark — Ctrl-C when done</span>
make clean               <span class="c"># tear everything down</span>
</code></pre></div></div>

<p><a href="https://www.postgresql.org/docs/current/pgbench.html">pgbench</a> is initialized with <code class="language-plaintext highlighter-rouge">SCALE_FACTOR=50</code>, creating approximately 7.2 million rows. The
benchmark parameters:</p>

<pre><code class="language-dotenv">SCALE_FACTOR=50
PSQL_NUMBER_OF_CLIENTS=80
PGBOUNCER_NUMBER_OF_CLIENTS=200
NUMBER_OF_THREADS=8
</code></pre>

<p>The direct benchmark uses 80 clients. The <a href="https://www.pgbouncer.org/">PgBouncer</a> benchmark uses 200 — 2.5x more load.
Both use <code class="language-plaintext highlighter-rouge">-M simple</code> because <a href="https://www.pgbouncer.org/">PgBouncer</a> in transaction mode does not support protocol-level
prepared statements.</p>

<hr />

<h2 id="benchmark-results">Benchmark results</h2>

<h3 id="direct-postgres-5-minutes-80-clients">Direct Postgres (5 minutes, 80 clients)</h3>

<p>In this scenario, <a href="https://www.postgresql.org/docs/current/pgbench.html">pgbench</a> connects directly to <a href="https://www.postgresql.org/">Postgres</a> with <strong>80 clients</strong> and <strong>8 threads</strong>,
bypassing <a href="https://www.pgbouncer.org/">PgBouncer</a> entirely. <a href="https://www.postgresql.org/">Postgres</a> has <code class="language-plaintext highlighter-rouge">max_connections=100</code>, leaving roughly 92 slots
available before <a href="https://www.postgresql.org/docs/current/pgbench.html">pgbench</a> starts — accounting for internal connections from the exporter and
other services.</p>

<h4 id="what-we-observed">What we observed</h4>

<p><strong>Connections under pressure</strong></p>

<p>The <strong>Connections Used</strong> gauge peaked at <strong>83%</strong> — meaning ~83 out of 100 connection slots were
consumed by client activity alone. With <a href="https://github.com/prometheus-community/postgres_exporter"><code class="language-plaintext highlighter-rouge">postgres_exporter</code></a>, <a href="https://www.pgbouncer.org/">PgBouncer</a>, and <a href="https://www.postgresql.org/">Postgres</a> internal
processes already occupying several slots, the system was operating dangerously close to its
ceiling. Any additional connection spike would have triggered <code class="language-plaintext highlighter-rouge">FATAL: sorry, too many clients
already</code>.</p>

<p><img src="/assets/images/2026-02-26-pgbouncer/psql/PSQL_benchmark-1772026794724.png" alt="PSQL connections used" /></p>

<p>The <strong>Postgres Connections by State</strong> panel tells a more detailed story. Active and idle
connections fluctuated heavily and out of phase — active connections spiked up to 34 while idle
connections dropped near zero, then the pattern reversed. This sawtooth behavior reflects
pgbench clients cycling through transactions: they go active during a query, briefly idle between
transactions, then active again. At 80 clients, <a href="https://www.postgresql.org/">Postgres</a> is handling all of this connection
lifecycle management directly, with no buffering layer in between.</p>

<p><img src="/assets/images/2026-02-26-pgbouncer/psql/PSQL_benchmark-1772026782107.png" alt="PSQL connections by state " /></p>

<p><strong>Throughput</strong></p>

<p>TPS started around 5,100 at the beginning of the benchmark, climbed steadily to a peak of
approximately <strong>6,040</strong> around the 2-minute mark, then began degrading. By the end of the
5-minute window, TPS had dropped back to roughly 5,200-5,400, showing visible instability with
frequent oscillation. This degradation is characteristic of connection saturation — as more
connections compete for <a href="https://www.postgresql.org/">Postgres</a> resources, throughput stops scaling and begins declining.</p>

<p><img src="/assets/images/2026-02-26-pgbouncer/psql/PSQL_benchmark-1772026830111.png" alt="PSQL TPS" /></p>

<p><strong>Memory pressure</strong></p>

<p><strong>Buffers Allocated</strong> rose sharply from ~5.6s at the start to a sustained plateau around
<strong>8.4s</strong> — a ~50% increase. This reflects Postgres constantly allocating shared buffer space to
service a large number of concurrent connections, each maintaining its own memory state.</p>

<p><img src="/assets/images/2026-02-26-pgbouncer/psql/PSQL_benchmark-1772026818469.png" alt="PSQL buffers allocated" /></p>

<p><strong>Buffers Cleaned by bgwriter</strong> jumped from ~360 at the start to a sustained ~490-495, an
increase of roughly 35%. The bgwriter is working harder to reclaim dirty buffers because the
high connection count is consuming shared memory faster than Postgres can reuse it.</p>

<p><img src="/assets/images/2026-02-26-pgbouncer/psql/PSQL_benchmark-1772026806607.png" alt="PSQL buffers cleaned" /></p>

<h4 id="key-takeaway">Key takeaway</h4>

<p>With 80 direct clients, <a href="https://www.postgresql.org/">Postgres</a> consumed 83% of its connection budget, showed TPS degradation
over time, and increased memory pressure by ~50%. This is with only 80 clients — well below the
<code class="language-plaintext highlighter-rouge">max_connections</code> limit. Attempting 200 direct clients, as we confirmed earlier, results in
immediate connection refusal errors.</p>

<p>This is the baseline the <a href="https://www.pgbouncer.org/">PgBouncer</a> benchmark is measured against.</p>

<h3 id="pgbouncer-10-minutes-200-clients">PgBouncer (10 minutes, 200 clients)</h3>

<p>In this scenario, the same <a href="https://www.postgresql.org/docs/current/pgbench.html">pgbench</a> workload runs through <a href="https://www.pgbouncer.org/">PgBouncer</a> with <strong>200 clients</strong> and
<strong>8 threads</strong> — 2.5x more clients than the direct benchmark. <a href="https://www.pgbouncer.org/">PgBouncer</a> is configured with
<code class="language-plaintext highlighter-rouge">pool_mode=transaction</code>, <code class="language-plaintext highlighter-rouge">max_client_conn=300</code>, and <code class="language-plaintext highlighter-rouge">default_pool_size=25</code>, meaning all 200
client connections are served through a maximum of 25 real <a href="https://www.postgresql.org/">Postgres</a> connections.</p>

<h4 id="what-we-observed-1">What we observed</h4>

<p><strong>Connection multiplexing</strong></p>

<p>The <strong>Server Connections Pool</strong> panel is where the story becomes undeniable. The yellow line —
clients waiting — hovered consistently around <strong>170</strong>, representing the 200 <a href="https://www.postgresql.org/docs/current/pgbench.html">pgbench</a> clients minus
those actively in a transaction at any given moment. The green line — clients active — stayed
around <strong>25-40</strong>, spiking occasionally to ~39 under burst load. The blue line — server connections
active — remained remarkably flat at <strong>~25</strong>, never exceeding the pool size. Server connections
idle stayed near zero, meaning the pool was being used efficiently with almost no wasted capacity.</p>

<p>200 application clients. 25 <a href="https://www.postgresql.org/">Postgres</a> connections. That is <a href="https://www.pgbouncer.org/">PgBouncer’s</a> core value proposition
visible in a single chart.</p>

<p><img src="/assets/images/2026-02-26-pgbouncer/pgbouncer/PgBouncer_benchmark-1772027714887.png" alt="PGBouncer server connections pool" /></p>

<p>The <strong>Postgres Connections by State</strong> panel confirms this from the database side. Active
connections to <a href="https://www.postgresql.org/">Postgres</a> never exceeded <strong>11</strong>, and idle connections stayed between 1 and 5
throughout the entire benchmark. <a href="https://www.postgresql.org/">Postgres</a> had no idea 200 clients existed — it only ever saw the
small, controlled pool <a href="https://www.pgbouncer.org/">PgBouncer</a> presented to it.</p>

<p><img src="/assets/images/2026-02-26-pgbouncer/pgbouncer/PgBouncer_benchmark-1772027646142.png" alt="PGBouncer PSQL connections by state" /></p>

<p><strong>Connections used</strong></p>

<p>The <strong>Connections Used</strong> gauge held at a steady <strong>28%</strong> throughout the entire benchmark — the
same reading seen when the stack was idle. With <code class="language-plaintext highlighter-rouge">default_pool_size=25</code>, <a href="https://www.pgbouncer.org/">PgBouncer</a> consumed only
25 out of 100 <a href="https://www.postgresql.org/">Postgres</a> connection slots regardless of how many application clients were
connected. Compare this to the direct benchmark’s 83%, achieved with less than half the client
count.</p>

<p><img src="/assets/images/2026-02-26-pgbouncer/pgbouncer/PgBouncer_benchmark-1772027657983.png" alt="PGBouncer PSQL connections used" /></p>

<p><strong>Throughput</strong></p>

<p>TPS started around <strong>3,850</strong> and showed a gradual decline over the 5-minute window, settling
around <strong>3,150-3,500</strong> by the end. This is lower than the direct benchmark’s peak of ~6,040,
which warrants explanation: with 200 clients sharing 25 server connections in transaction mode,
clients that are not in an active transaction must wait for a server connection to become
available. The throughput reflects the bottleneck of the pool size, not <a href="https://www.postgresql.org/">Postgres</a> capacity. In a
real-world scenario, <code class="language-plaintext highlighter-rouge">default_pool_size</code> would be tuned upward (while staying well under
<code class="language-plaintext highlighter-rouge">max_connections</code>) to increase throughput while still preserving the connection budget advantage.</p>

<p>The key distinction is stability: direct <a href="https://www.postgresql.org/">Postgres</a> showed degradation and oscillation under
pressure. <a href="https://www.pgbouncer.org/">PgBouncer</a> showed a predictable, controlled decline — the system remained in full
control throughout.</p>

<p><img src="/assets/images/2026-02-26-pgbouncer/pgbouncer/PgBouncer_benchmark-1772027703195.png" alt="PGBench TPS" /></p>

<p><strong>Memory pressure</strong></p>

<p><strong>Buffers Allocated</strong> dropped significantly compared to the direct benchmark. Starting around
5,370 at the beginning, it declined steadily to a floor of ~4,400 — roughly <strong>13-18% lower</strong>
than the direct benchmark’s sustained plateau of ~8,400. With <a href="https://www.postgresql.org/">Postgres</a> only managing 25 real
connections instead of 80, it allocates far less shared memory per scrape interval.</p>

<p><img src="/assets/images/2026-02-26-pgbouncer/pgbouncer/PgBouncer_benchmark-1772027692016.png" alt="PGBench buffers allocated" /></p>

<p><strong>Buffers Cleaned by bgwriter</strong> was essentially <strong>flat at 495</strong> throughout the entire run, with
only a minor blip visible at 13:52:45. The Y-axis scale tells the story: the entire chart spans
from 494.74 to 495.26 — a range of just 0.52 units. Compare this to the direct benchmark where
buffers cleaned ranged from 360 to 495, a range of 135 units. The bgwriter had almost nothing
to do.</p>

<p><img src="/assets/images/2026-02-26-pgbouncer/pgbouncer/PgBouncer_benchmark-1772027681466.png" alt="PGBench buffers cleaned" /></p>

<h4 id="head-to-head-comparison">Head-to-Head Comparison</h4>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Direct Postgres (80 clients)</th>
      <th>PgBouncer (200 clients)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Connections used</td>
      <td>83%</td>
      <td>28%</td>
    </tr>
    <tr>
      <td>Postgres active connections (peak)</td>
      <td>34</td>
      <td>11</td>
    </tr>
    <tr>
      <td>Postgres idle connections (peak)</td>
      <td>37</td>
      <td>5</td>
    </tr>
    <tr>
      <td>TPS (peak)</td>
      <td>~6,040</td>
      <td>~3,860</td>
    </tr>
    <tr>
      <td>TPS (sustained)</td>
      <td>~5,200–5,400 (degrading)</td>
      <td>~3,200–3,500 (stable)</td>
    </tr>
    <tr>
      <td>Buffers allocated (sustained)</td>
      <td>~8.4s</td>
      <td>~4.4–4.8s</td>
    </tr>
    <tr>
      <td>Buffers cleaned range</td>
      <td>360–495</td>
      <td>494.74–495.26</td>
    </tr>
    <tr>
      <td>Client count</td>
      <td>80</td>
      <td>200</td>
    </tr>
  </tbody>
</table>

<h4 id="key-takeaway-1">Key takeaway</h4>

<p><a href="https://www.pgbouncer.org/">PgBouncer</a> served <strong>2.5x more clients</strong> while consuming <strong>66% fewer <a href="https://www.postgresql.org/">Postgres</a> connection slots</strong>
and applying <strong>~45% less memory pressure</strong> on the buffer pool. The bgwriter was virtually idle
compared to the direct benchmark, and <a href="https://www.postgresql.org/">Postgres</a> never saw more than 11 active connections
regardless of the client load above.</p>

<p>The direct benchmark’s higher raw TPS reflects the fact that each of its 80 clients had a
dedicated server connection and never had to wait for pool availability. This is exactly the
trade-off <a href="https://www.pgbouncer.org/">PgBouncer</a> introduces: you exchange some peak throughput for dramatically better
resource efficiency and headroom. In production systems where <code class="language-plaintext highlighter-rouge">max_connections</code> is a hard
ceiling and connection exhaustion causes outages, that trade-off is almost always worth making.</p>

<hr />

<h2 id="what-the-results-show">What the results show</h2>

<p>The most important number is connection utilization: 83% with 80 direct clients vs 28% with
200 clients through <a href="https://www.pgbouncer.org/">PgBouncer</a>. <a href="https://www.pgbouncer.org/">PgBouncer</a> served 2.5x more clients while leaving <a href="https://www.postgresql.org/">Postgres</a> with
significantly more connection headroom. With direct connections, attempting 200 clients causes
immediate connection refusal errors. With <a href="https://www.pgbouncer.org/">PgBouncer</a>, 200 clients is well within operating range.</p>

<p>The throughput difference — direct <a href="https://www.postgresql.org/">Postgres</a> peaked higher at ~6,040 TPS vs ~3,860 through
<a href="https://www.pgbouncer.org/">PgBouncer</a> — reflects the cost of pool queuing. In transaction mode, a client that is between
transactions must wait for a server connection to become available. Increasing <code class="language-plaintext highlighter-rouge">DEFAULT_POOL_SIZE</code>
reduces this wait and brings throughput closer to the direct benchmark, while still keeping
<a href="https://www.postgresql.org/">Postgres</a> connection utilization controlled. The pool size is a tuning parameter you can adjust
based on your workload.</p>

<p>Memory pressure was also reduced behind <a href="https://www.pgbouncer.org/">PgBouncer</a>, because <a href="https://www.postgresql.org/">Postgres</a> only allocates shared
memory for 25 active server connections rather than 80. This is visible in both the buffer
allocation rate and the bgwriter activity.</p>

<hr />

<h2 id="when-to-use-pgbouncer">When to Use <a href="https://www.pgbouncer.org/">PgBouncer</a></h2>

<p><a href="https://www.pgbouncer.org/">PgBouncer</a> is most useful in deployments where the number of application threads or processes
that hold Postgres connections is large relative to <code class="language-plaintext highlighter-rouge">max_connections</code>. This is common in
horizontally scaled applications, microservice architectures, or applications that use
connection-per-thread models.</p>

<p>It is less necessary in small deployments where the total connection count stays well below
<code class="language-plaintext highlighter-rouge">max_connections</code>, or in applications that already use an efficient connection pool within
the application itself.</p>

<p>Transaction mode is suitable for most standard OLTP workloads. If your application uses session
state features that do not work across transaction boundaries — <code class="language-plaintext highlighter-rouge">SET</code> variables, advisory locks,
<code class="language-plaintext highlighter-rouge">LISTEN/NOTIFY</code>, or protocol-level prepared statements — you will need to use session mode or
refactor those patterns.</p>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p><a href="https://www.pgbouncer.org/">PgBouncer</a> reduces the number of real Postgres connections needed to serve a given number of
application clients. The benchmark results show this concretely: 2.5x more clients, 66% fewer
connection slots used, reduced memory pressure, and a near-idle bgwriter compared to the direct
benchmark.</p>

<p>The setup described in this article — <a href="https://www.pgbouncer.org/">PgBouncer</a> with <a href="https://github.com/prometheus-community/postgres_exporter"><code class="language-plaintext highlighter-rouge">postgres_exporter</code></a>, <a href="https://github.com/prometheus-community/pgbouncer_exporter"><code class="language-plaintext highlighter-rouge">pgbouncer_exporter</code></a>,
<a href="https://prometheus.io/">Prometheus</a>, and <a href="https://grafana.com/">Grafana</a> — gives you a way to observe these effects in your own environment and
tune pool settings based on actual measurements rather than guesswork.</p>

<p>The full project is available at <a href="https://github.com/tiagomelo/pgbouncer-demo">https://github.com/tiagomelo/pgbouncer-demo</a>.</p>]]></content><author><name></name></author><category term="psql" /><category term="pgbouncer" /><category term="prometheus" /><category term="grafana" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/2026-02-26-pgbouncer/banner.png" /><media:content medium="image" url="/assets/images/2026-02-26-pgbouncer/banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Open source project: vid2mp3</title><link href="/opensource/golang/2026/01/08/vid2mp3.html" rel="alternate" type="text/html" title="Open source project: vid2mp3" /><published>2026-01-08T13:40:15+00:00</published><updated>2026-01-08T13:40:15+00:00</updated><id>/opensource/golang/2026/01/08/vid2mp3</id><content type="html" xml:base="/opensource/golang/2026/01/08/vid2mp3.html"><![CDATA[<p><img src="/assets/images/2026-01-08-vid2mp3/banner.png" alt="banner" /></p>

<p><em>check out my other open source projects <a href="https://tiagomelo.info/opensource/">here</a></em></p>

<h1 id="vid2mp3">vid2mp3</h1>

<p>A simple command-line utility to extract audio from video files and convert it to MP3 format.</p>

<p><code class="language-plaintext highlighter-rouge">vid2mp3</code> is designed to be small, dependency-light, and easy to integrate into scripts, Makefiles, and automation workflows.</p>

<hr />

<h2 id="features">features</h2>

<ul>
  <li>Convert video files to MP3</li>
  <li>Supports common video formats (via <a href="https://www.ffmpeg.org/"><code class="language-plaintext highlighter-rouge">ffmpeg</code></a>)</li>
  <li>Simple CLI interface</li>
  <li>Suitable for batch processing</li>
  <li>No <a href="https://go.dev">Go</a> runtime dependencies at execution time</li>
</ul>

<hr />

<h2 id="requirements">requirements</h2>

<ul>
  <li><a href="https://www.ffmpeg.org/"><strong>ffmpeg</strong></a> must be installed and available on <code class="language-plaintext highlighter-rouge">$PATH</code></li>
</ul>

<p>Verify installation:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ffmpeg <span class="nt">-version</span>
</code></pre></div></div>

<hr />

<h2 id="installation">installation</h2>

<h3 id="using-go-install">using <code class="language-plaintext highlighter-rouge">go install</code></h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go <span class="nb">install </span>github.com/tiagomelo/vid2mp3/cmd/vid2mp3@latest
</code></pre></div></div>

<p>The binary will be installed into:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="si">$(</span>go <span class="nb">env </span>GOPATH<span class="si">)</span>/bin
</code></pre></div></div>

<p>Make sure it is on your <code class="language-plaintext highlighter-rouge">PATH</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">PATH</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>go <span class="nb">env </span>GOPATH<span class="si">)</span><span class="s2">/bin:</span><span class="nv">$PATH</span><span class="s2">"</span>
</code></pre></div></div>

<hr />

<h2 id="usage">usage</h2>

<h3 id="basic-usage">basic usage</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vid2mp3 input.mp4
</code></pre></div></div>

<p>This generates:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>input.mp3
</code></pre></div></div>

<p>in the same directory.</p>

<hr />

<h3 id="specify-output-file">specify output file</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vid2mp3 <span class="nt">-o</span> output.mp3 input.mp4
</code></pre></div></div>

<hr />

<h3 id="batch-conversion">batch conversion</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>f <span class="k">in</span> <span class="k">*</span>.mp4<span class="p">;</span> <span class="k">do
  </span>vid2mp3 <span class="s2">"</span><span class="nv">$f</span><span class="s2">"</span>
<span class="k">done</span>
</code></pre></div></div>

<hr />

<h2 id="examples">examples</h2>

<p>Convert a video downloaded from the web:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vid2mp3 lecture.mp4
</code></pre></div></div>

<p>Convert and rename output:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vid2mp3 <span class="nt">-o</span> podcast.mp3 recording.mkv
</code></pre></div></div>

<p>Use inside a Makefile:</p>

<div class="language-make highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">extract-audio</span><span class="o">:</span>
	vid2mp3 assets/video.mp4
</code></pre></div></div>

<hr />

<h2 id="how-it-works">how it works</h2>

<p>Internally, <code class="language-plaintext highlighter-rouge">vid2mp3</code> invokes <a href="https://www.ffmpeg.org/"><code class="language-plaintext highlighter-rouge">ffmpeg</code></a> with the appropriate arguments to:</p>

<ul>
  <li>strip video streams</li>
  <li>extract audio</li>
  <li>encode it as MP3</li>
</ul>

<p>This keeps the implementation simple and reliable by relying on a proven media tool.</p>

<hr />

<h2 id="error-handling">error handling</h2>

<ul>
  <li>Fails fast if <a href="https://www.ffmpeg.org/"><code class="language-plaintext highlighter-rouge">ffmpeg</code></a> is not available</li>
  <li>Propagates <a href="https://www.ffmpeg.org/"><code class="language-plaintext highlighter-rouge">ffmpeg</code></a> errors directly to <code class="language-plaintext highlighter-rouge">stderr</code></li>
  <li>Returns non-zero exit codes on failure (script-friendly)</li>
</ul>

<hr />

<h2 id="development">development</h2>

<h3 id="run-locally">run locally</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go run ./cmd/vid2mp3/vid2mp3.go input.mp4
</code></pre></div></div>

<hr />

<h3 id="unit-tests">unit tests</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make <span class="nb">test</span>
</code></pre></div></div>

<hr />

<h3 id="unit-tests-coverage-report">unit tests coverage report</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make coverage
</code></pre></div></div>]]></content><author><name></name></author><category term="opensource" /><category term="golang" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/2026-01-08-vid2mp3/banner.png" /><media:content medium="image" url="/assets/images/2026-01-08-vid2mp3/banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Understanding .gitattributes: controlling how Git treats your files</title><link href="/git/2025/12/26/gitattributes.html" rel="alternate" type="text/html" title="Understanding .gitattributes: controlling how Git treats your files" /><published>2025-12-26T14:58:56+00:00</published><updated>2025-12-26T14:58:56+00:00</updated><id>/git/2025/12/26/gitattributes</id><content type="html" xml:base="/git/2025/12/26/gitattributes.html"><![CDATA[<p><img src="/assets/images/2025-12-26-gitattributes/banner.png" alt="banner" /></p>

<p>Most teams are familiar with <code class="language-plaintext highlighter-rouge">.gitignore</code>, but far fewer make deliberate use of <a href="https://git-scm.com/docs/gitattributes"><code class="language-plaintext highlighter-rouge">.gitattributes</code></a>.
This is unfortunate, because <a href="https://git-scm.com/docs/gitattributes"><code class="language-plaintext highlighter-rouge">.gitattributes</code></a> plays a critical role in <strong>how Git interprets files</strong>, not merely whether they are tracked.</p>

<p>Used correctly, <a href="https://git-scm.com/docs/gitattributes"><code class="language-plaintext highlighter-rouge">.gitattributes</code></a> improves:</p>

<ul>
  <li>Cross-platform consistency</li>
  <li>Diff and merge quality</li>
  <li>Repository cleanliness</li>
  <li>Tooling reliability (CI, formatters, linters)</li>
  <li>Binary and large-file handling</li>
</ul>

<p>This article explains what <a href="https://git-scm.com/docs/gitattributes"><code class="language-plaintext highlighter-rouge">.gitattributes</code></a> does and presents practical rules that should be considered in professional repositories.</p>

<hr />

<h2 id="what-gitattributes-is">What <code class="language-plaintext highlighter-rouge">.gitattributes</code> Is</h2>

<p><a href="https://git-scm.com/docs/gitattributes"><code class="language-plaintext highlighter-rouge">.gitattributes</code></a> defines <strong>path-based rules</strong> that instruct Git how to:</p>

<ul>
  <li>Normalize line endings</li>
  <li>Classify files as text or binary</li>
  <li>Generate diffs</li>
  <li>Resolve merges</li>
  <li>Apply filters (e.g., Git LFS)</li>
</ul>

<p>In short:</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">.gitignore</code> controls <em>whether</em> files are tracked.
<code class="language-plaintext highlighter-rouge">.gitattributes</code> controls <em>how</em> tracked files are handled.</p>
</blockquote>

<p>Rules can be defined globally, per directory, or per repository, and they are versioned alongside the codebase.</p>

<hr />

<h2 id="line-ending-normalization">Line Ending Normalization</h2>

<p>Line ending inconsistencies are one of the most common sources of unnecessary diffs in multi-platform teams.</p>

<h3 id="recommended-baseline">Recommended Baseline</h3>

<pre><code class="language-gitattributes">* text=auto
</code></pre>

<p>This configuration tells Git to:</p>

<ul>
  <li>Store all text files in the repository using LF</li>
  <li>Convert line endings appropriately in the working tree based on the operating system</li>
</ul>

<p>This approach avoids relying on individual developer configuration such as <code class="language-plaintext highlighter-rouge">core.autocrlf</code>, which is error-prone and inconsistent across environments.</p>

<h3 id="enforcing-explicit-line-endings">Enforcing Explicit Line Endings</h3>

<p>For repositories that require strict control:</p>

<pre><code class="language-gitattributes">*.go   text eol=lf
*.sh   text eol=lf
*.sql  text eol=lf
*.ps1  text eol=crlf
*.bat  text eol=crlf
</code></pre>

<p>This ensures deterministic behavior regardless of platform or editor configuration.</p>

<hr />

<h2 id="explicitly-declaring-binary-files">Explicitly Declaring Binary Files</h2>

<p>Git attempts to infer whether a file is text or binary.
This inference is not always correct and can lead to confusing diffs or merge behavior.</p>

<h3 id="best-practice">Best Practice</h3>

<p>Declare binary files explicitly:</p>

<pre><code class="language-gitattributes">*.png  binary
*.jpg  binary
*.pdf  binary
*.zip  binary
*.mp4  binary
</code></pre>

<p>Benefits:</p>

<ul>
  <li>Prevents meaningless diff output</li>
  <li>Avoids accidental merge conflicts</li>
  <li>Improves performance when working with large assets</li>
</ul>

<hr />

<h2 id="improving-diffs-for-specific-file-types">Improving Diffs for Specific File Types</h2>

<p>Some file formats benefit from custom diff behavior.</p>

<h3 id="marking-files-as-non-diffable">Marking Files as Non-Diffable</h3>

<pre><code class="language-gitattributes">*.lock  -diff
*.min.js -diff
</code></pre>

<p>This reduces noise in pull requests when diffs provide little or no value.</p>

<h3 id="language-aware-diffs">Language-Aware Diffs</h3>

<p>Git supports language-specific diff drivers:</p>

<pre><code class="language-gitattributes">*.go   diff=golang
*.md   diff=markdown
</code></pre>

<p>This improves readability when reviewing changes.</p>

<hr />

<h2 id="controlling-merge-behavior">Controlling Merge Behavior</h2>

<p>Not all files should be merged.</p>

<h3 id="favoring-one-side-during-merges">Favoring One Side During Merges</h3>

<p>For generated or machine-specific files:</p>

<pre><code class="language-gitattributes">*.generated merge=ours
</code></pre>

<p>This ensures Git always prefers the current branch’s version, avoiding pointless conflicts.</p>

<h3 id="disabling-merges-entirely">Disabling Merges Entirely</h3>

<pre><code class="language-gitattributes">*.lock binary
</code></pre>

<p>Binary files cannot be merged meaningfully and should always be treated as such.</p>

<hr />

<h2 id="git-lfs-integration">Git LFS Integration</h2>

<p><a href="https://git-scm.com/docs/gitattributes"><code class="language-plaintext highlighter-rouge">.gitattributes</code></a> is the authoritative way to enable Git LFS:</p>

<pre><code class="language-gitattributes">*.psd filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
</code></pre>

<p>This keeps large files out of the main Git object store while maintaining correct behavior.</p>

<hr />

<h2 id="a-sensible-default-gitattributes">A Sensible Default <code class="language-plaintext highlighter-rouge">.gitattributes</code></h2>

<p>For most professional repositories, the following is a strong starting point:</p>

<pre><code class="language-gitattributes">* text=auto

# Source code
*.go   text eol=lf
*.sh   text eol=lf
*.sql  text eol=lf

# Windows scripts
*.bat  text eol=crlf
*.ps1  text eol=crlf

# Binary files
*.png  binary
*.jpg  binary
*.pdf  binary
*.zip  binary
</code></pre>

<p>This configuration eliminates an entire class of platform-specific issues with minimal effort.</p>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p><a href="https://git-scm.com/docs/gitattributes"><code class="language-plaintext highlighter-rouge">.gitattributes</code></a> is not an advanced or optional feature—it is a foundational tool for maintaining repository correctness and consistency.</p>

<p>Teams that ignore it often compensate with conventions, editor settings, or CI workarounds.
Teams that use it intentionally prevent these problems at the source.</p>

<p>If your repository is shared across platforms, languages, or tooling ecosystems, <a href="https://git-scm.com/docs/gitattributes"><code class="language-plaintext highlighter-rouge">.gitattributes</code></a> deserves the same level of attention as <code class="language-plaintext highlighter-rouge">.gitignore</code>.</p>]]></content><author><name></name></author><category term="git" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/2025-12-26-gitattributes/banner.png" /><media:content medium="image" url="/assets/images/2025-12-26-gitattributes/banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Understanding Postgres Exporter - monitoring PostgreSQL the Prometheus way</title><link href="/postgres/prometheus/grafana/2025/11/10/postgres-exporter.html" rel="alternate" type="text/html" title="Understanding Postgres Exporter - monitoring PostgreSQL the Prometheus way" /><published>2025-11-10T12:47:56+00:00</published><updated>2025-11-10T12:47:56+00:00</updated><id>/postgres/prometheus/grafana/2025/11/10/postgres-exporter</id><content type="html" xml:base="/postgres/prometheus/grafana/2025/11/10/postgres-exporter.html"><![CDATA[<p><img src="/assets/images/2025-11-10-postgres-exporter/banner.png" alt="banner" /></p>

<p>In a <a href="https://tiagomelo.info/golang/prometheus/grafana/observability/2025/10/22/go-grafana-prometheus-example.html">previous article</a>, we saw how create a complete observability setup. Now let’s see a concrete example.</p>

<p>When you want to know what your <a href="https://www.postgresql.org/">PostgreSQL</a> instance is doing — how many transactions it’s handling, how often it checkpoints, or how full its cache is — you don’t have to query <code class="language-plaintext highlighter-rouge">pg_stat_*</code> views manually.</p>

<p>That’s what <strong><a href="https://github.com/prometheus-community/postgres_exporter">postgres_exporter</a></strong> does for you.
It turns <a href="https://www.postgresql.org/">PostgreSQL’s</a> internal statistics into <a href="https://prometheus.io/docs/introduction/overview/"><strong>Prometheus metrics</strong></a>, ready to be scraped and visualized in Grafana.</p>

<p>In this article, we’ll:</p>

<ol>
  <li>Set up <code class="language-plaintext highlighter-rouge">postgres_exporter</code> with Docker Compose,</li>
  <li>Feed it activity using <a href="https://www.postgresql.org/docs/current/pgbench.html"><code class="language-plaintext highlighter-rouge">pgbench</code></a>,</li>
  <li>Watch the metrics come alive in Grafana.</li>
</ol>

<hr />

<h3 id="what-is-postgres-exporter">what is Postgres Exporter?</h3>

<p><code class="language-plaintext highlighter-rouge">postgres_exporter</code> is part of the <a href="https://github.com/prometheus-community/postgres_exporter">prometheus-community</a> project.
It runs alongside your database and exposes metrics via HTTP (default port <strong>9187</strong>) in <a href="https://prometheus.io/docs/introduction/overview/">Prometheus</a> format.</p>

<p>Under the hood, it:</p>

<ul>
  <li>Connects to PostgreSQL using a read-only user.</li>
  <li>
    <p>Periodically runs queries against <a href="https://www.postgresql.org/">PostgreSQL’s</a> internal statistics views, such as:</p>

    <ul>
      <li><code class="language-plaintext highlighter-rouge">pg_stat_database</code></li>
      <li><code class="language-plaintext highlighter-rouge">pg_stat_bgwriter</code></li>
      <li><code class="language-plaintext highlighter-rouge">pg_stat_activity</code></li>
      <li><code class="language-plaintext highlighter-rouge">pg_statio_user_tables</code></li>
    </ul>
  </li>
  <li>Converts the results into numeric time-series metrics with descriptive labels.</li>
</ul>

<p>Each metric comes prefixed with <code class="language-plaintext highlighter-rouge">pg_</code> — for example:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pg_stat_database_xact_commit{datname="example"} 12735
pg_stat_bgwriter_buffers_backend_fsync 4
pg_up 1
</code></pre></div></div>

<p>These metrics can then be scraped by <a href="https://prometheus.io/docs/introduction/overview/">Prometheus</a>, visualized in <a href="https://grafana.com/">Grafana</a>, or used in alert rules.</p>

<hr />

<h3 id="example-deploying-postgres-exporter">example: deploying Postgres Exporter</h3>

<p><code class="language-plaintext highlighter-rouge">docker-compose.yaml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">psql_grafana_db</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">postgres:17</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">${POSTGRES_DATABASE_CONTAINER_NAME}</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">always</span>
    <span class="na">env_file</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">.env</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">5432:5432"</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">psql_grafana_db_data:/var/lib/postgresql/data</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">psqldb-network</span>

  <span class="na">postgres_exporter</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">quay.io/prometheuscommunity/postgres-exporter</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">postgres_exporter</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">DATA_SOURCE_URI</span><span class="pi">:</span> <span class="s2">"</span><span class="s">psql_grafana_db:5432/postgres?sslmode=disable"</span>
      <span class="na">DATA_SOURCE_USER</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${POSTGRES_USER}"</span>
      <span class="na">DATA_SOURCE_PASS</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${POSTGRES_PASSWORD}"</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">9187:9187"</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">psqldb-network</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">psql_grafana_db</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">always</span>

  <span class="na">renderer</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">grafana/grafana-image-renderer:latest</span>
    <span class="na">expose</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="m">8081</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">psqldb-network</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">on-failure</span>

  <span class="na">grafana</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">grafana/grafana:latest</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">3000:3000</span>
    <span class="na">env_file</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">.env</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">grafana_data:/var/lib/grafana</span>
      <span class="pi">-</span> <span class="s">./provisioning/dashboards:/etc/grafana/provisioning/dashboards</span>
      <span class="pi">-</span> <span class="s">./provisioning/datasources:/etc/grafana/provisioning/datasources</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">psqldb-network</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">renderer</span>

  <span class="na">prometheus</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">prom/prometheus:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">prometheus</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./prometheus.yml:/etc/prometheus/prometheus.yml:ro</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">9090:9090"</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">psqldb-network</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">postgres_exporter</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">on-failure</span>

<span class="na">networks</span><span class="pi">:</span>
  <span class="na">psqldb-network</span><span class="pi">:</span>
    <span class="na">driver</span><span class="pi">:</span> <span class="s">bridge</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">grafana_data</span><span class="pi">:</span>
  <span class="na">psql_grafana_db_data</span><span class="pi">:</span>
</code></pre></div></div>

<p>This configuration makes the exporter connect to our <a href="https://www.postgresql.org/">PostgreSQL</a> container (<code class="language-plaintext highlighter-rouge">psql_grafana_db</code>) and expose its metrics at:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://localhost:9187/metrics
</code></pre></div></div>

<p>Open that URL in your browser and you’ll see hundreds of raw metrics in <a href="https://prometheus.io/docs/introduction/overview/">Prometheus</a> format.</p>

<hr />

<h3 id="what-kinds-of-metrics-does-it-expose">what kinds of metrics does it expose?</h3>

<p>The exporter organizes metrics in logical groups, matching <a href="https://www.postgresql.org/">PostgreSQL’s</a> own stats views.</p>

<h4 id="database-level-stats">database-level stats</h4>

<ul>
  <li><code class="language-plaintext highlighter-rouge">pg_stat_database_xact_commit</code> / <code class="language-plaintext highlighter-rouge">pg_stat_database_xact_rollback</code></li>
  <li><code class="language-plaintext highlighter-rouge">pg_stat_database_blks_read</code> / <code class="language-plaintext highlighter-rouge">pg_stat_database_blks_hit</code></li>
  <li><code class="language-plaintext highlighter-rouge">pg_stat_database_tup_returned</code>, <code class="language-plaintext highlighter-rouge">pg_stat_database_tup_fetched</code></li>
</ul>

<p>These are great for understanding workload intensity and cache efficiency.</p>

<h4 id="background-writer">background writer</h4>

<ul>
  <li><code class="language-plaintext highlighter-rouge">pg_stat_bgwriter_buffers_backend_fsync</code></li>
  <li><code class="language-plaintext highlighter-rouge">pg_stat_bgwriter_checkpoints_timed</code></li>
  <li><code class="language-plaintext highlighter-rouge">pg_stat_bgwriter_buffers_alloc</code></li>
</ul>

<p>These describe how often <a href="https://www.postgresql.org/">PostgreSQL</a> flushes dirty buffers and performs checkpoints — key for I/O analysis.</p>

<h4 id="connections">connections</h4>

<ul>
  <li><code class="language-plaintext highlighter-rouge">pg_stat_activity_count</code></li>
  <li><code class="language-plaintext highlighter-rouge">pg_stat_activity_max_tx_duration</code></li>
  <li><code class="language-plaintext highlighter-rouge">pg_up</code> (exporter health metric)</li>
</ul>

<p>These tell you how many sessions are active and if the exporter can reach the database.</p>

<h4 id="table-io">table I/O</h4>

<ul>
  <li><code class="language-plaintext highlighter-rouge">pg_statio_user_tables_idx_blks_read</code></li>
  <li><code class="language-plaintext highlighter-rouge">pg_statio_user_tables_heap_blks_hit</code></li>
</ul>

<p>Useful for detecting tables that don’t fit well in memory.</p>

<hr />

<h3 id="prometheus-scraping-configuration">Prometheus Scraping Configuration</h3>

<p><code class="language-plaintext highlighter-rouge">prometheus.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">scrape_configs</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">job_name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">postgres'</span>
    <span class="na">scrape_interval</span><span class="pi">:</span> <span class="s">10s</span>
    <span class="na">static_configs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">targets</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">postgres_exporter:9187'</span><span class="pi">]</span>
</code></pre></div></div>

<p><a href="https://prometheus.io/docs/introduction/overview/">Prometheus</a> pulls metrics from <code class="language-plaintext highlighter-rouge">postgres_exporter</code> every 10 seconds.</p>

<hr />

<h3 id="grafana-dashboards">Grafana dashboards</h3>

<p><a href="https://grafana.com/">Grafana</a> doesn’t know about <a href="https://www.postgresql.org/">PostgreSQL</a> metrics by default, but the community has already done the hard work.
You can import ready-made dashboards from <a href="https://grafana.com/grafana/dashboards">grafana.com/grafana/dashboards</a>:</p>

<ul>
  <li><a href="https://grafana.com/grafana/dashboards/14114-postgres-overview/">Postgres Overview (14114)</a></li>
  <li><a href="https://grafana.com/grafana/dashboards/9628-postgresql-database/">PostgreSQL Database (9628)</a></li>
</ul>

<p>After downloading the JSON files, make these quick edits:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"datasource"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Prometheus"</span><span class="err">,</span><span class="w">
</span><span class="nl">"refresh"</span><span class="p">:</span><span class="w"> </span><span class="s2">"5s"</span><span class="err">,</span><span class="w">
</span><span class="nl">"time"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"from"</span><span class="p">:</span><span class="w"> </span><span class="s2">"now-5m"</span><span class="p">,</span><span class="w"> </span><span class="nl">"to"</span><span class="p">:</span><span class="w"> </span><span class="s2">"now"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Then drop them into <code class="language-plaintext highlighter-rouge">provisioning/dashboards/</code> — Grafana will auto-load them.</p>

<p>Example provisioning snippet:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="m">1</span>

<span class="na">providers</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">default'</span>
  <span class="na">orgId</span><span class="pi">:</span> <span class="m">1</span>
  <span class="na">folder</span><span class="pi">:</span> <span class="s1">'</span><span class="s">'</span>
  <span class="na">type</span><span class="pi">:</span> <span class="s">file</span>
  <span class="na">disableDeletion</span><span class="pi">:</span> <span class="no">false</span>
  <span class="na">updateIntervalSeconds</span><span class="pi">:</span> <span class="m">30</span> <span class="c1"># how often Grafana will scan for changed dashboards.</span>
  <span class="na">options</span><span class="pi">:</span>
    <span class="na">path</span><span class="pi">:</span> <span class="s">/etc/grafana/provisioning/dashboards</span>

</code></pre></div></div>

<p>Once <a href="https://grafana.com/">Grafana</a> is running, visit <strong><a href="http://localhost:3000">http://localhost:3000</a></strong>
(login: <code class="language-plaintext highlighter-rouge">admin</code> / <code class="language-plaintext highlighter-rouge">admin123</code>) and you’ll see dashboards light up as soon as data starts flowing.</p>

<hr />

<h3 id="generating-activity-with-pgbench">Generating activity with <a href="https://www.postgresql.org/docs/current/pgbench.html">pgbench</a></h3>

<p>To make the exporter’s metrics interesting, we need some workload.
<a href="https://www.postgresql.org/docs/current/pgbench.html"><code class="language-plaintext highlighter-rouge">pgbench</code></a> — <a href="https://www.postgresql.org/">PostgreSQL’s</a> built-in benchmarking tool — is perfect for that.</p>

<p>We’re not measuring performance here, just producing enough transactions to keep stats moving.</p>

<p>The Makefile automates it all:</p>

<ol>
  <li>start <a href="https://www.postgresql.org/">PostgreSQL</a> and monitoring stack</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make start-monitoring
</code></pre></div></div>

<ol>
  <li>run the benchmark</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make run-benchmark
</code></pre></div></div>

<p>which executes something equivalent to:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pgbench <span class="nt">-h</span> localhost <span class="nt">-p</span> 5432 <span class="nt">-U</span> postgres <span class="nt">-c</span> 10 <span class="nt">-j</span> 3 <span class="nt">-T</span> 999999999 example
</code></pre></div></div>

<p>Let it run, open Grafana, and watch metrics like:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">pg_stat_database_xact_commit</code></li>
  <li><code class="language-plaintext highlighter-rouge">pg_stat_bgwriter_checkpoints_timed</code></li>
  <li><code class="language-plaintext highlighter-rouge">pg_stat_database_blks_hit</code>
continuously increase as pgbench clients issue transactions.</li>
</ul>

<hr />

<h3 id="example-dashboards">example dashboards</h3>

<p><img src="/assets/images/2025-11-10-postgres-exporter/1.png" alt="Grafana Postgres Overview Screenshot" />
<em>The “Postgres Overview” dashboard highlighting TPS, cache hit ratio, and checkpoints.</em></p>

<p><img src="/assets/images/2025-11-10-postgres-exporter/2.png" alt="Grafana PostgreSQL Database Screenshot" />
<em>A more complete view including settings, machine metrics, transactions, etc.</em></p>

<hr />

<h3 id="cleaning-up">cleaning up</h3>

<p>To stop everything:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make clean
</code></pre></div></div>

<p>This shuts down containers and removes volumes.</p>

<hr />

<h3 id="why-postgres-exporter-matters">Why Postgres Exporter Matters</h3>

<p><a href="https://www.postgresql.org/">PostgreSQL</a> already exposes rich statistics via system views, but:</p>

<ul>
  <li>They aren’t time-series friendly.</li>
  <li>They require manual SQL queries.</li>
  <li>They’re ephemeral (reset on restart).</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">postgres_exporter</code> solves all that:</p>

<ul>
  <li>Converts stats to Prometheus metrics.</li>
  <li>Preserves history via Prometheus.</li>
  <li>Enables alerting and long-term visualization.</li>
</ul>

<p>It’s the foundation for <strong>observability in PostgreSQL</strong> — from personal setups to enterprise clusters.</p>

<hr />

<h3 id="wrapping-up">wrapping up</h3>

<p>With less than a hundred lines of YAML and one Makefile, you’ve built a self-contained observability stack that highlights the power of <strong>Postgres Exporter</strong>.</p>

<ul>
  <li><a href="https://prometheus.io/docs/introduction/overview/">Prometheus</a> scrapes the metrics.</li>
  <li><a href="https://grafana.com/">Grafana</a> visualizes them.</li>
  <li><a href="https://www.postgresql.org/docs/current/pgbench.html"><code class="language-plaintext highlighter-rouge">pgbench</code></a> just keeps them flowing.</li>
</ul>

<p>Next time you tune parameters like <code class="language-plaintext highlighter-rouge">max_wal_size</code> or <code class="language-plaintext highlighter-rouge">work_mem</code>, you’ll <em>see</em> their impact in real time.</p>

<hr />

<h3 id="download-the-source">download the source</h3>

<p>Here: <a href="https://github.com/tiagomelo/go-psql-grafana-example">https://github.com/tiagomelo/go-psql-grafana-example</a></p>]]></content><author><name></name></author><category term="postgres" /><category term="prometheus" /><category term="grafana" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/2025-11-10-postgres-exporter/banner.png" /><media:content medium="image" url="/assets/images/2025-11-10-postgres-exporter/banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Scaling Kubernetes configuration with Jsonnet: no more copy-paste YAML</title><link href="/jsonnet/kubernetes/devops/configuration/2025/11/03/scaling-configuration-jsonnet.html" rel="alternate" type="text/html" title="Scaling Kubernetes configuration with Jsonnet: no more copy-paste YAML" /><published>2025-11-03T07:21:00+00:00</published><updated>2025-11-03T07:21:00+00:00</updated><id>/jsonnet/kubernetes/devops/configuration/2025/11/03/scaling-configuration-jsonnet</id><content type="html" xml:base="/jsonnet/kubernetes/devops/configuration/2025/11/03/scaling-configuration-jsonnet.html"><![CDATA[<p><img src="/assets/images/2025-11-03-scaling-configuration-jsonnet/banner.png" alt="banner" /></p>

<p>Every <a href="https://kubernetes.io/">Kubernetes</a> project begins with a <code class="language-plaintext highlighter-rouge">deployment.yaml</code> and a <code class="language-plaintext highlighter-rouge">kubectl apply</code>. It’s fast, simple, and satisfying.</p>

<p>But what happens when you go beyond a single environment? Now you’ve got <code class="language-plaintext highlighter-rouge">dev</code>, <code class="language-plaintext highlighter-rouge">staging</code>, and <code class="language-plaintext highlighter-rouge">prod</code>. And maybe a <code class="language-plaintext highlighter-rouge">qa</code> or <code class="language-plaintext highlighter-rouge">sandbox</code> env too.</p>

<p>Suddenly your repo looks like this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>k8s/
├── dev/
│   └── deployment.yaml
├── staging/
│   └── deployment.yaml
└── prod/
    └── deployment.yaml
</code></pre></div></div>

<p>Those files start off identical — but not for long. A changed replica count here, a version mismatch there… and before you know it, your configurations are out of sync.</p>

<p><strong>Config drift has begun.</strong></p>

<p>So how do we scale config without copy-paste chaos?</p>

<p>Enter <a href="https://jsonnet.org/"><strong>Jsonnet</strong></a>.</p>

<hr />

<h2 id="the-problem-with-scaling-yaml">The problem with scaling YAML</h2>

<p>YAML itself doesn’t support:</p>

<ul>
  <li>variables</li>
  <li>functions</li>
  <li>imports</li>
  <li>mixins</li>
  <li>conditional logic</li>
</ul>

<p>Which means Kubernetes users are left maintaining multiple slightly-different files that share 90% of the same structure.</p>

<ul>
  <li>That’s config duplication.</li>
  <li>That’s error-prone.</li>
  <li>That’s what we’re fixing.</li>
</ul>

<hr />

<h2 id="jsonnet-to-the-rescue">Jsonnet to the rescue</h2>

<p><a href="https://jsonnet.org/">Jsonnet</a> is a data templating language:</p>

<ul>
  <li>JSON superscript — but with <code class="language-plaintext highlighter-rouge">import</code>, <code class="language-plaintext highlighter-rouge">function</code>, <code class="language-plaintext highlighter-rouge">if</code>/<code class="language-plaintext highlighter-rouge">else</code>, and more</li>
  <li>Purely declarative and side-effect-free</li>
  <li>Perfect for generating config files</li>
</ul>

<p>And you can generate <strong>dozens</strong> of Kubernetes manifests from a single template.</p>

<hr />

<h2 id="what-were-building">What we’re building</h2>

<p>We’ll build a structure where:</p>

<ol>
  <li><strong>Base config</strong> defines all shared Kubernetes configuration</li>
  <li><strong>Mixins</strong> add reusable extensions (like resources, probes, labels)</li>
  <li><strong>Environment files</strong> override only what’s different</li>
  <li><strong>Everything</strong> can be validated offline using <code class="language-plaintext highlighter-rouge">kubeconform</code></li>
</ol>

<p>Environments covered:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">dev</code></li>
  <li><code class="language-plaintext highlighter-rouge">staging</code></li>
  <li><code class="language-plaintext highlighter-rouge">prod</code></li>
</ul>

<hr />

<h2 id="folder-structure">Folder structure</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>jsonnet-config-scaling/
├── k8s/
│   ├── base.libsonnet           <span class="c"># Base shared Deployment template</span>
│   ├── mixins/
│   │   └── resources.libsonnet  <span class="c"># Add CPU/memory config</span>
│   └── environments/
│       ├── dev.jsonnet
│       ├── staging.jsonnet
│       ├── prod.jsonnet
│       └── all.jsonnet          <span class="c"># Demo: generate all envs via loop</span>
├── output/                      <span class="c"># Generated manifests</span>
├── Makefile
└── README.md
</code></pre></div></div>

<hr />

<h2 id="base-deployment-template">Base deployment template</h2>

<p><code class="language-plaintext highlighter-rouge">k8s/base.libsonnet</code></p>

<div class="language-jsonnet highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span>
  <span class="c1">// deployment generates a Kubernetes Deployment manifest</span>
  <span class="c1">// Parameters:</span>
  <span class="c1">//   name: Name of the deployment</span>
  <span class="c1">//   image: Container image to use</span>
  <span class="c1">//   replicas: Number of replicas</span>
  <span class="c1">//   envVars: (optional) Environment variables for the container</span>
  <span class="nx">deployment</span><span class="p">(</span><span class="nx">name</span><span class="p">,</span> <span class="nx">image</span><span class="p">,</span> <span class="nx">replicas</span><span class="p">,</span> <span class="nx">envVars</span><span class="p">={})::</span> <span class="p">{</span>
    <span class="nx">apiVersion</span><span class="p">:</span> <span class="s">'apps/v1'</span><span class="p">,</span>
    <span class="nx">kind</span><span class="p">:</span> <span class="s">'Deployment'</span><span class="p">,</span>
    <span class="nx">metadata</span><span class="p">:</span> <span class="p">{</span>
      <span class="nx">name</span><span class="p">:</span> <span class="nx">name</span><span class="p">,</span>
      <span class="nx">labels</span><span class="p">:</span> <span class="p">{</span>
        <span class="nx">app</span><span class="p">:</span> <span class="nx">name</span><span class="p">,</span>
      <span class="p">},</span>
    <span class="p">},</span>
    <span class="nx">spec</span><span class="p">:</span> <span class="p">{</span>
      <span class="nx">replicas</span><span class="p">:</span> <span class="nx">replicas</span><span class="p">,</span>
      <span class="nx">selector</span><span class="p">:</span> <span class="p">{</span>
        <span class="nx">matchLabels</span><span class="p">:</span> <span class="p">{</span>
          <span class="nx">app</span><span class="p">:</span> <span class="nx">name</span><span class="p">,</span>
        <span class="p">},</span>
      <span class="p">},</span>
      <span class="nx">template</span><span class="p">:</span> <span class="p">{</span>
        <span class="nx">metadata</span><span class="p">:</span> <span class="p">{</span>
          <span class="nx">labels</span><span class="p">:</span> <span class="p">{</span>
            <span class="nx">app</span><span class="p">:</span> <span class="nx">name</span><span class="p">,</span>
          <span class="p">},</span>
        <span class="p">},</span>
        <span class="nx">spec</span><span class="p">:</span> <span class="p">{</span>
          <span class="nx">containers</span><span class="p">:</span> <span class="p">[{</span>
            <span class="nx">name</span><span class="p">:</span> <span class="nx">name</span><span class="p">,</span>
            <span class="nx">image</span><span class="p">:</span> <span class="nx">image</span><span class="p">,</span>
            <span class="nx">env</span><span class="p">:</span> <span class="nx">std</span><span class="p">.</span><span class="nb">map</span><span class="p">(</span>
              <span class="kd">function</span><span class="p">(</span><span class="nx">k</span><span class="p">)</span> <span class="p">{</span> <span class="nx">name</span><span class="p">:</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">value</span><span class="p">:</span> <span class="nx">envVars</span><span class="p">[</span><span class="nx">k</span><span class="p">]</span> <span class="p">},</span>
              <span class="nx">std</span><span class="p">.</span><span class="nb">objectFields</span><span class="p">(</span><span class="nx">envVars</span><span class="p">),</span>
            <span class="p">),</span>
          <span class="p">}],</span>
        <span class="p">},</span>
      <span class="p">},</span>
    <span class="p">},</span>
  <span class="p">},</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This single function can generate a fully valid Kubernetes Deployment, making it reusable across environments.</p>

<hr />

<h2 id="adding-resource-mixins">Adding resource mixins</h2>

<p>We don’t want to define CPU/memory in every environment — so let’s patch that using a mixin.</p>

<p><code class="language-plaintext highlighter-rouge">k8s/mixins/resources.libsonnet</code></p>

<div class="language-jsonnet highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span>
  <span class="c1">// Mixin to add resource requests and limits to the first container of a Kubernetes Pod spec</span>
  <span class="c1">// Parameters:</span>
  <span class="c1">//   cpu: CPU resource (e.g., "500m", "1")</span>
  <span class="c1">//   memory: Memory resource (e.g., "256Mi", "1Gi")</span>
  <span class="nx">withResources</span><span class="p">(</span><span class="nx">cpu</span><span class="p">,</span> <span class="nx">memory</span><span class="p">)::</span> <span class="p">{</span>
    <span class="nx">spec</span><span class="p">+:</span> <span class="p">{</span>
      <span class="nx">template</span><span class="p">+:</span> <span class="p">{</span>
        <span class="nx">spec</span><span class="p">+:</span> <span class="p">{</span>
          <span class="nx">containers</span><span class="p">:</span> <span class="p">[</span>
            <span class="k">super</span><span class="p">.</span><span class="nx">containers</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="p">{</span>  <span class="c1">// Merge into first container</span>
              <span class="nx">resources</span><span class="p">:</span> <span class="p">{</span>
                <span class="nx">limits</span><span class="p">:</span> <span class="p">{</span> <span class="nx">cpu</span><span class="p">:</span> <span class="nx">cpu</span><span class="p">,</span> <span class="nx">memory</span><span class="p">:</span> <span class="nx">memory</span> <span class="p">},</span>
                <span class="nx">requests</span><span class="p">:</span> <span class="p">{</span> <span class="nx">cpu</span><span class="p">:</span> <span class="nx">cpu</span><span class="p">,</span> <span class="nx">memory</span><span class="p">:</span> <span class="nx">memory</span> <span class="p">},</span>
              <span class="p">},</span>
            <span class="p">},</span>
          <span class="p">]</span> <span class="p">+</span> <span class="k">super</span><span class="p">.</span><span class="nx">containers</span><span class="p">[</span><span class="mi">1</span><span class="p">:],</span>  <span class="c1">// Keep any remaining containers</span>
        <span class="p">},</span>
      <span class="p">},</span>
    <span class="p">},</span>
  <span class="p">},</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That <code class="language-plaintext highlighter-rouge">+</code> deep merge operator lets us add resource configuration <strong>without modifying</strong> anything else in the original container definition.</p>

<hr />

<h2 id="per-environment-files">Per-environment files</h2>

<p>Example: <code class="language-plaintext highlighter-rouge">k8s/environments/dev.jsonnet</code></p>

<div class="language-jsonnet highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">local</span> <span class="nx">base</span> <span class="p">=</span> <span class="k">import</span> <span class="s">'../base.libsonnet'</span><span class="p">;</span>
<span class="k">local</span> <span class="nx">mixins</span> <span class="p">=</span> <span class="k">import</span> <span class="s">'../mixins/resources.libsonnet'</span><span class="p">;</span>

<span class="nx">base</span><span class="p">.</span><span class="nx">deployment</span><span class="p">(</span>
  <span class="nx">name</span><span class="p">=</span><span class="s">'myapp-dev'</span><span class="p">,</span>
  <span class="nx">image</span><span class="p">=</span><span class="s">'myorg/myapp:1.1.0'</span><span class="p">,</span>
  <span class="nx">replicas</span><span class="p">=</span><span class="mi">1</span><span class="p">,</span>
  <span class="nx">envVars</span><span class="p">={</span> <span class="nx">ENV</span><span class="p">:</span> <span class="s">'dev'</span> <span class="p">},</span>
<span class="p">)</span> <span class="p">+</span> <span class="nx">mixins</span><span class="p">.</span><span class="nx">withResources</span><span class="p">(</span><span class="s">'100m'</span><span class="p">,</span> <span class="s">'256Mi'</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">staging</code> and <code class="language-plaintext highlighter-rouge">prod</code> follow this pattern:</p>

<table>
  <thead>
    <tr>
      <th>Env</th>
      <th>Replicas</th>
      <th>CPU</th>
      <th>Memory</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>dev</td>
      <td>1</td>
      <td>100m</td>
      <td>256Mi</td>
    </tr>
    <tr>
      <td>staging</td>
      <td>1</td>
      <td>200m</td>
      <td>512Mi</td>
    </tr>
    <tr>
      <td>prod</td>
      <td>3</td>
      <td>500m</td>
      <td>1Gi</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="generate-all-environments-at-once">Generate all environments at once</h2>

<p>Here’s where Jsonnet loops show their power:</p>

<p><code class="language-plaintext highlighter-rouge">k8s/environments/all.jsonnet</code></p>

<div class="language-jsonnet highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">local</span> <span class="nx">base</span> <span class="p">=</span> <span class="k">import</span> <span class="s">'../base.libsonnet'</span><span class="p">;</span>
<span class="k">local</span> <span class="nx">mixins</span> <span class="p">=</span> <span class="k">import</span> <span class="s">'../mixins/resources.libsonnet'</span><span class="p">;</span>

<span class="c1">// resources for different environments.</span>
<span class="k">local</span> <span class="nx">resourceMap</span> <span class="p">=</span> <span class="p">{</span>
  <span class="nx">dev</span><span class="p">:</span> <span class="p">{</span> <span class="nx">cpu</span><span class="p">:</span> <span class="s">'100m'</span><span class="p">,</span> <span class="nx">memory</span><span class="p">:</span> <span class="s">'256Mi'</span> <span class="p">},</span>
  <span class="nx">staging</span><span class="p">:</span> <span class="p">{</span> <span class="nx">cpu</span><span class="p">:</span> <span class="s">'200m'</span><span class="p">,</span> <span class="nx">memory</span><span class="p">:</span> <span class="s">'512Mi'</span> <span class="p">},</span>
  <span class="nx">prod</span><span class="p">:</span> <span class="p">{</span> <span class="nx">cpu</span><span class="p">:</span> <span class="s">'500m'</span><span class="p">,</span> <span class="nx">memory</span><span class="p">:</span> <span class="s">'1Gi'</span> <span class="p">},</span>
<span class="p">};</span>

<span class="p">[</span>
  <span class="c1">// Generate deployments for all environments.</span>
  <span class="nx">base</span><span class="p">.</span><span class="nx">deployment</span><span class="p">(</span>
    <span class="nx">name</span><span class="p">=</span><span class="s">'myapp-'</span> <span class="p">+</span> <span class="nx">env</span><span class="p">,</span>
    <span class="nx">image</span><span class="p">=</span><span class="s">'myorg/myapp:1.2.0'</span><span class="p">,</span>
    <span class="nx">replicas</span><span class="p">=</span><span class="k">if</span> <span class="nx">env</span> <span class="p">==</span> <span class="s">'prod'</span> <span class="k">then</span> <span class="mi">3</span> <span class="k">else</span> <span class="mi">1</span><span class="p">,</span>
    <span class="nx">envVars</span><span class="p">={</span> <span class="nx">ENV</span><span class="p">:</span> <span class="nx">env</span> <span class="p">},</span>
  <span class="p">)</span> <span class="p">+</span> <span class="nx">mixins</span><span class="p">.</span><span class="nx">withResources</span><span class="p">(</span><span class="nx">resourceMap</span><span class="p">[</span><span class="nx">env</span><span class="p">].</span><span class="nx">cpu</span><span class="p">,</span> <span class="nx">resourceMap</span><span class="p">[</span><span class="nx">env</span><span class="p">].</span><span class="nx">memory</span><span class="p">)</span>
  <span class="k">for</span> <span class="nx">env</span> <span class="k">in</span> <span class="nx">std</span><span class="p">.</span><span class="nb">objectFields</span><span class="p">(</span><span class="nx">resourceMap</span><span class="p">)</span>
<span class="p">]</span>
</code></pre></div></div>

<hr />

<h2 id="yaml-vs-json-why-convert">YAML vs JSON: why convert?</h2>

<p>Jsonnet always outputs <strong>JSON</strong>, which is valid Kubernetes input — but most tools, linters, and CI platforms work best with <strong>YAML</strong>.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">kubectl apply -f file.yaml</code></li>
  <li><code class="language-plaintext highlighter-rouge">kubeconform</code></li>
  <li><code class="language-plaintext highlighter-rouge">helm</code></li>
  <li>Git diffs look better</li>
</ul>

<p>So we convert our Jsonnet output to YAML as a final step before applying or validating.</p>

<hr />

<h2 id="generating-single-yaml-for-all-environments">Generating single YAML for all environments</h2>

<p>Many workflows want all resources in a <strong>single <code class="language-plaintext highlighter-rouge">all.yaml</code></strong> instead of individual files. That’s totally supported — just add a Makefile target:</p>

<div class="language-makefile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">## build-all-to-single-file: generates all environments into a single .yaml
</span><span class="nl">.PHONY</span><span class="o">:</span> <span class="nf">build-all-to-single-file</span>
<span class="nl">build-all-to-single-file</span><span class="o">:</span> <span class="nf">fmt</span>
	<span class="p">@</span><span class="nb">mkdir</span> <span class="nt">-p</span> <span class="nv">$(OUTPUT_DIR)</span>
	<span class="p">@</span><span class="nv">$(JSONNET)</span> k8s/environments/all.jsonnet | <span class="se">\</span>
	yq <span class="nb">eval</span> <span class="nt">-P</span> <span class="s1">'.[]'</span> - | <span class="se">\</span>
	<span class="nb">awk</span> <span class="s1">'BEGIN{first=1} /^apiVersion:/ {if (!first) print "---"; first=0} {print}'</span> <span class="se">\</span>
	<span class="o">&gt;</span> <span class="nv">$(OUTPUT_DIR)</span>/all.yaml
	<span class="p">@</span><span class="nb">echo</span> <span class="s2">"Generated: </span><span class="nv">$(OUTPUT_DIR)</span><span class="s2">/all.yaml"</span>
</code></pre></div></div>

<h3 id="whats-going-on">What’s going on?</h3>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">jsonnet all.jsonnet</code></td>
      <td>Generates a JSON array of Kubernetes objects</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">yq eval -P '.[]'</code></td>
      <td>Converts each element to YAML and prints them individually</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">awk</code></td>
      <td>Adds <code class="language-plaintext highlighter-rouge">---</code> between top-level resources to create a valid multi-doc YAML file</td>
    </tr>
  </tbody>
</table>

<p>This gives you a result like:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">apps/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Deployment</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">myapp-dev</span>
<span class="nn">...</span>

<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">apps/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Deployment</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">myapp-staging</span>
<span class="nn">...</span>

<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">apps/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Deployment</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">myapp-prod</span>
<span class="nn">...</span>
</code></pre></div></div>

<ul>
  <li>
    <p>Ready for <code class="language-plaintext highlighter-rouge">kubectl apply -f all.yaml</code></p>
  </li>
  <li>
    <p>Easy to diff in GitHub</p>
  </li>
  <li>
    <p>Consumed by <code class="language-plaintext highlighter-rouge">kustomize</code>, ArgoCD, Flux, and CI tools</p>
  </li>
</ul>

<hr />

<h2 id="validating-manifests-with-kubeconform">Validating manifests with kubeconform</h2>

<p>You don’t need a Kubernetes cluster to ensure your generated manifests are valid — just use <a href="https://github.com/yannh/kubeconform">kubeconform</a>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>make build
Generated: output/dev.yaml
Generated: output/staging.yaml
Generated: output/prod.yaml

<span class="nv">$ </span>make build-all-to-single-file 
Generated: output/all.yaml

<span class="nv">$ </span>make validate
Summary: 3 resources found <span class="k">in </span>1 file - Valid: 3, Invalid: 0, Errors: 0, Skipped: 0
Validated output/all.yaml
Summary: 1 resource found <span class="k">in </span>1 file - Valid: 1, Invalid: 0, Errors: 0, Skipped: 0
Validated output/dev.yaml
Summary: 1 resource found <span class="k">in </span>1 file - Valid: 1, Invalid: 0, Errors: 0, Skipped: 0
Validated output/prod.yaml
Summary: 1 resource found <span class="k">in </span>1 file - Valid: 1, Invalid: 0, Errors: 0, Skipped: 0
Validated output/staging.yaml
</code></pre></div></div>

<hr />

<h2 id="dependencies">Dependencies</h2>

<p>To follow along and generate the manifests, install these tools:</p>

<h3 id="1-jsonnet">1. Jsonnet</h3>

<p>Used to compile <code class="language-plaintext highlighter-rouge">.jsonnet</code> files into JSON.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># macOS</span>
brew <span class="nb">install </span>jsonnet

<span class="c"># Linux (via package manager)</span>
<span class="nb">sudo </span>apt <span class="nb">install </span>jsonnet

<span class="c"># Or use the GitHub release</span>
https://github.com/google/jsonnet/releases
</code></pre></div></div>

<h3 id="2-kubeconform">2. <code class="language-plaintext highlighter-rouge">kubeconform</code></h3>

<p>Schema validation tool for Kubernetes manifests.
Does <strong>not</strong> require a cluster.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># macOS</span>
brew <span class="nb">install </span>kubeconform

<span class="c"># Linux</span>
curl <span class="nt">-L</span> https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz <span class="se">\</span>
  | <span class="nb">tar </span>xz <span class="o">&amp;&amp;</span> <span class="nb">mv </span>kubeconform /usr/local/bin/
</code></pre></div></div>

<hr />

<h2 id="final-makefile">Final Makefile</h2>

<div class="language-makefile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">JSONNET</span> <span class="o">?=</span> jsonnet
<span class="nv">FMT</span> <span class="o">?=</span> jsonnetfmt
<span class="nv">OUTPUT_DIR</span> <span class="o">=</span> output

<span class="nl">.PHONY</span><span class="o">:</span> <span class="nf">help</span>
<span class="c">## help: shows this help message
</span><span class="nl">help</span><span class="o">:</span>
	<span class="p">@</span><span class="nb">echo</span> <span class="s2">"Usage: make [target]</span><span class="se">\n</span><span class="s2">"</span>
	<span class="p">@</span><span class="nb">sed</span> <span class="nt">-n</span> <span class="s1">'s/^##//p'</span> <span class="nv">${MAKEFILE_LIST}</span> | column <span class="nt">-t</span> <span class="nt">-s</span> <span class="s1">':'</span> |  <span class="nb">sed</span> <span class="nt">-e</span> <span class="s1">'s/^/ /'</span>

<span class="c">## fmt: formats all Jsonnet files
</span><span class="nl">.PHONY</span><span class="o">:</span> <span class="nf">fmt</span>
<span class="nl">fmt</span><span class="o">:</span>
	<span class="p">@</span>find k8s <span class="nt">-name</span> <span class="s1">'*.jsonnet'</span> <span class="nt">-o</span> <span class="nt">-name</span> <span class="s1">'*.libsonnet'</span> | xargs <span class="nv">$(FMT)</span> <span class="nt">-i</span>

<span class="c">## build: generates all environments into output dir
</span><span class="nl">.PHONY</span><span class="o">:</span> <span class="nf">build</span>
<span class="nl">build</span><span class="o">:</span> <span class="nf">fmt</span>
	<span class="p">@</span><span class="nb">mkdir</span> <span class="nt">-p</span> <span class="nv">$(OUTPUT_DIR)</span>
	<span class="p">@</span><span class="k">for </span><span class="nb">env </span><span class="k">in </span>dev staging prod<span class="p">;</span> <span class="k">do</span> <span class="se">\</span>
		<span class="nv">$(JSONNET)</span> k8s/environments/<span class="nv">$$</span>env.jsonnet | yq <span class="nt">-P</span> <span class="o">&gt;</span> <span class="nv">$(OUTPUT_DIR)</span>/<span class="nv">$$</span>env.yaml<span class="p">;</span> <span class="se">\</span>
		<span class="nb">echo</span> <span class="s2">"Generated: </span><span class="nv">$(OUTPUT_DIR)</span><span class="s2">/</span><span class="nv">$$</span><span class="s2">env.yaml"</span><span class="p">;</span> <span class="se">\</span>
	<span class="k">done</span>

<span class="c">## build-all-to-single-file: generates all environments into a single .yaml
</span><span class="nl">.PHONY</span><span class="o">:</span> <span class="nf">build-all-to-single-file</span>
<span class="nl">build-all-to-single-file</span><span class="o">:</span> <span class="nf">fmt</span>
	<span class="p">@</span><span class="nb">mkdir</span> <span class="nt">-p</span> <span class="nv">$(OUTPUT_DIR)</span>
	<span class="p">@</span><span class="nv">$(JSONNET)</span> k8s/environments/all.jsonnet | <span class="se">\</span>
	yq <span class="nb">eval</span> <span class="nt">-P</span> <span class="s1">'.[]'</span> - | <span class="se">\</span>
	<span class="nb">awk</span> <span class="s1">'BEGIN{first=1} /^apiVersion:/ {if (!first) print "---"; first=0} {print}'</span> <span class="se">\</span>
	<span class="o">&gt;</span> <span class="nv">$(OUTPUT_DIR)</span>/all.yaml
	<span class="p">@</span><span class="nb">echo</span> <span class="s2">"Generated: </span><span class="nv">$(OUTPUT_DIR)</span><span class="s2">/all.yaml"</span>

<span class="c">## validate: validates generated k8s manifests (requires kubeconform)
</span><span class="nl">.PHONY</span><span class="o">:</span> <span class="nf">validate</span>
<span class="nl">validate</span><span class="o">:</span>
	<span class="p">@</span><span class="k">for </span>file <span class="k">in</span> <span class="nv">$(OUTPUT_DIR)</span>/<span class="k">*</span>.yaml<span class="p">;</span> <span class="k">do</span> <span class="se">\</span>
		kubeconform <span class="nt">-strict</span> <span class="nt">-summary</span> <span class="nv">$$</span>file <span class="o">||</span> <span class="nb">exit </span>1<span class="p">;</span> <span class="se">\</span>
		<span class="nb">echo</span> <span class="s2">"Validated </span><span class="nv">$$</span><span class="s2">file"</span><span class="p">;</span> <span class="se">\</span>
	<span class="k">done</span>

<span class="c">## clean: cleans output directory
</span><span class="nl">.PHONY</span><span class="o">:</span> <span class="nf">clean</span>
<span class="nl">clean</span><span class="o">:</span>
	<span class="p">@</span><span class="nb">rm</span> <span class="nt">-rf</span> <span class="nv">$(OUTPUT_DIR)</span>
</code></pre></div></div>

<hr />

<h2 id="full-example-repo">Full example repo</h2>

<p>Here: <a href="https://github.com/tiagomelo/jsonnet-config-scaling">https://github.com/tiagomelo/jsonnet-config-scaling</a></p>

<hr />

<h2 id="final-thoughts">Final thoughts</h2>

<p>With this approach we’ve:</p>

<ul>
  <li>Eliminated YAML duplication</li>
  <li>Added structured environment overrides</li>
  <li>Scaled Kubernetes config with clean composition</li>
  <li>Kept full compatibility with <code class="language-plaintext highlighter-rouge">kubectl</code>, Helm, ArgoCD, and CI</li>
  <li>Added static validation with <code class="language-plaintext highlighter-rouge">kubeconform</code></li>
</ul>

<p>If you’re managing more than one environment, Jsonnet is a huge quality-of-life upgrade over raw YAML.</p>

<p>Let me know if you’d like a follow-up article using: ConfigMaps, Secrets, Kustomize, or Helm interop!</p>

<hr />

<h2 id="-references">📚 References</h2>

<ol>
  <li><a href="https://jsonnet.org/learning/tutorial.html">Jsonnet Language</a></li>
  <li><a href="https://github.com/yannh/kubeconform">Kubeconform Validator</a></li>
  <li><a href="https://kubernetes.io/docs/reference/kubectl/">kubectl apply Best Practices</a></li>
</ol>]]></content><author><name></name></author><category term="jsonnet" /><category term="kubernetes" /><category term="devops" /><category term="configuration" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/2025-11-03-scaling-configuration-jsonnet/banner.png" /><media:content medium="image" url="/assets/images/2025-11-03-scaling-configuration-jsonnet/banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>