<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ankit Verma</title>
    <description>The latest articles on DEV Community by Ankit Verma (@dockndevai).</description>
    <link>https://dev.to/dockndevai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4090691%2F3aebacd1-e265-44ab-b11a-572397751831.png</url>
      <title>DEV Community: Ankit Verma</title>
      <link>https://dev.to/dockndevai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dockndevai"/>
    <language>en</language>
    <item>
      <title>I built a macOS screensaver that throws rubber ducks at you, directed by a local model</title>
      <dc:creator>Ankit Verma</dc:creator>
      <pubDate>Sun, 04 Oct 2026 15:35:28 +0000</pubDate>
      <link>https://dev.to/dockndevai/i-built-a-macos-screensaver-that-throws-rubber-ducks-at-you-directed-by-a-local-model-5dmc</link>
      <guid>https://dev.to/dockndevai/i-built-a-macos-screensaver-that-throws-rubber-ducks-at-you-directed-by-a-local-model-5dmc</guid>
      <description>&lt;p&gt;Mac Attack watches the room with the Mac camera, turns each person into a cartoon character, and fires harmless toy effects at them: bubbles, rubber ducks, tomatoes, confetti. It runs as a normal app and as a real macOS screensaver.&lt;/p&gt;

&lt;p&gt;Everything stays on the Mac. Apple's Vision framework produces body rectangles only (no face recognition), frames are processed and released, and nothing is uploaded.&lt;/p&gt;

&lt;p&gt;Download + code: &lt;a href="https://github.com/dockndevai/mac-attack" rel="noopener noreferrer"&gt;https://github.com/dockndevai/mac-attack&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment
&lt;/h2&gt;

&lt;p&gt;The point wasn't the ducks. It was this: can a small, fast decision model make a game feel &lt;em&gt;alive&lt;/em&gt;?&lt;/p&gt;

&lt;p&gt;The director is &lt;a href="https://huggingface.co/convaiinnovations/laya" rel="noopener noreferrer"&gt;Laya&lt;/a&gt;, a non-autoregressive typed-decision model. You hand it a state plus typed questions (choice / score / yes-no) and it answers all of them in &lt;strong&gt;one forward pass&lt;/strong&gt; with calibrated probabilities, about 340 ms on an M2 Pro through a local sidecar.&lt;/p&gt;

&lt;p&gt;Laya never sees pixels. It gets one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"2 humans in view. person-1 is the frog on the left, just arrived, moving left. person-2 has been standing still for 12s. Last event: duck rain 4s ago."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence is the entire interface between vision and behaviour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things I measured
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Sampling beats argmax
&lt;/h3&gt;

&lt;p&gt;Taking the model's top answer made the game repetitive. Sampling from its calibrated distribution made it feel alive: the same scene plays out differently each time, while still following the model's taste. "Chaos mode" is literally a higher sampling temperature.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Policy belongs in code, not in the prompt
&lt;/h3&gt;

&lt;p&gt;The game got boring fast: it fired the same stunt every time. Measuring showed why. For a single stationary person, Laya put &lt;strong&gt;86-95%&lt;/strong&gt; of its probability on one option ("surprise"), every single time.&lt;/p&gt;

&lt;p&gt;I rewrote the question to describe that option as rare. It barely moved: 86% afterwards.&lt;/p&gt;

&lt;p&gt;What fixed it was policy in the sampling code: a just-used effect drops to 8% weight, stunts get cooldowns, and the same effect can never run three times in a row. Result: from ~90% of decisions down to 12%, measured live over a run with 8 different effects.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Measure the model you are actually running
&lt;/h3&gt;

&lt;p&gt;One question with 11 options came back 100% confident every single time. That option count ships with an uncalibrated temperature in this checkpoint, so the probabilities saturate. Splitting it into questions with five options or fewer brought the distributions back to life.&lt;/p&gt;

&lt;p&gt;Related: passing a text summary instead of raw JSON &lt;strong&gt;halved latency&lt;/strong&gt;, because the state is re-read once per question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part macOS refused
&lt;/h2&gt;

&lt;p&gt;Making it a real screensaver was the hard bit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A screensaver can never use the camera.&lt;/strong&gt; A .saver bundle runs inside Apple's sandboxed legacyScreenSaver host, so the camera request is attributed to that host, which has no camera permission. It is denied instantly, without ever showing a prompt. Anything the screensaver spawns inherits that sandbox, and open is blocked too.&lt;/p&gt;

&lt;p&gt;The architecture that works: a launchd agent owns the camera in the normal user session, runs Vision, and serves anonymous person boxes on 127.0.0.1. The screensaver polls that at 10 Hz, runs the game engine, and renders. The helper keeps the camera off until the screensaver actually asks for people, and switches it off again seconds after it stops.&lt;/p&gt;

&lt;p&gt;Other things macOS says no to: the lock/login screen only runs Apple's own screensavers, and there is no API to select someone's screensaver for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability
&lt;/h2&gt;

&lt;p&gt;Laya is never in the render loop. Decisions are event-driven, at most one in flight, bounded by a 2-second timeout. If the model is slow, loading or missing, a local random director takes over and the game never stalls. A 40-minute soak with the model killed mid-run and later frozen with SIGSTOP produced no crash and no memory growth.&lt;/p&gt;

&lt;p&gt;One more thing worth sharing: the model checkpoint costs about 2.4 GB resident, and it used to sit there whether or not anything was using it. It now unloads after three idle minutes (2,398 MB to 320 MB) and reloads in the background on the next decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Mac, unsigned build, so Gatekeeper will warn (right-click, Open). Source, architecture notes and the full list of what worked and what didn't: &lt;a href="https://github.com/dockndevai/mac-attack" rel="noopener noreferrer"&gt;https://github.com/dockndevai/mac-attack&lt;/a&gt;&lt;/p&gt;

</description>
      <category>macos</category>
      <category>swift</category>
      <category>ai</category>
      <category>showdev</category>
    </item>
    <item>
      <title>A question on Discord found two bugs in my RAG system. Fixing them found a third.</title>
      <dc:creator>Ankit Verma</dc:creator>
      <pubDate>Sun, 13 Sep 2026 14:19:05 +0000</pubDate>
      <link>https://dev.to/dockndevai/a-question-on-discord-found-two-bugs-in-my-rag-system-fixing-them-found-a-third-58m6</link>
      <guid>https://dev.to/dockndevai/a-question-on-discord-found-two-bugs-in-my-rag-system-fixing-them-found-a-third-58m6</guid>
      <description>&lt;p&gt;&lt;em&gt;Every test passed. The tests had the same blind spots as the code.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fatx7vglbv89ho4qpq2li.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fatx7vglbv89ho4qpq2li.png" alt="Three cards: one source cited three times, memory aged from the wrong moment, and a 500 that happened once, under a line reading all tests passing" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;A developer saw Ossian's MCP server in a community showcase and asked two questions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do you handle cases where retrieved docs and stored memories conflict? Or when multiple&lt;br&gt;
pieces of context ultimately come from the same underlying source?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had a confident answer to both. Documents and agent memory live in separate tables, are reached&lt;br&gt;
through separate tools, and never meet in a prompt. Duplicates are caught at ingest by content hash.&lt;br&gt;
Done.&lt;/p&gt;

&lt;p&gt;Before replying I read the code to make sure the answer was true. It was half true, and the half&lt;br&gt;
that wasn't is the interesting part.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug one: three passages looked like three sources
&lt;/h2&gt;

&lt;p&gt;Retrieval returns chunks, not documents. Ossian took the top six and numbered them for the model,&lt;br&gt;
so a prompt could look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1] engineering-handbook.txt  …
[2] engineering-handbook.txt  …
[3] engineering-handbook.txt  …
[4] platform-architecture.md  …
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To the model — and to the person reading the citations — that is three sources agreeing and one&lt;br&gt;
dissenting. It is one source said three times. The system prompt even tells the model &lt;em&gt;"if the&lt;br&gt;
context conflicts with itself, say which sources disagree"&lt;/em&gt;, and there was no way for it to tell one&lt;br&gt;
source from several.&lt;/p&gt;

&lt;p&gt;Ingest-time deduplication does nothing here. These are distinct chunks of one legitimate document.&lt;/p&gt;

&lt;p&gt;The fix groups chunks by document id before the prompt is built: one number per document, every&lt;br&gt;
passage kept under it, the document ranked by its best chunk. Two files that merely share a filename&lt;br&gt;
stay separate, because the grouping is on id. On a live question, six retrieved chunks now become&lt;br&gt;
five citations, with one runbook contributing two passages under a single number.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug two: memory decayed from the wrong moment
&lt;/h2&gt;

&lt;p&gt;Agent memory is ranked &lt;code&gt;similarity × importance × 0.5^(age / 30 days)&lt;/code&gt;. Recency matters for memory in&lt;br&gt;
a way it never does for documents: a runbook from three years ago is as true as one from today, a&lt;br&gt;
stated preference from three years ago is not.&lt;/p&gt;

&lt;p&gt;The question is what "age" means. The query measured it from &lt;code&gt;created_at&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An agent that restates a fact — "the user still prefers British English" — hits a deduplicating&lt;br&gt;
upsert, which updates &lt;code&gt;updated_at&lt;/code&gt; and nothing the ranking reads. A preference confirmed every day&lt;br&gt;
for three months decayed exactly as if it had been said once, three months ago.&lt;/p&gt;

&lt;p&gt;The obvious fix is wrong. Recall already records &lt;code&gt;last_used_at&lt;/code&gt;, and a comment on it claimed that&lt;br&gt;
recording use &lt;em&gt;"keeps a live memory from decaying away."&lt;/em&gt; It didn't — and it shouldn't. Recall&lt;br&gt;
returns everything that matches, so an old preference and the newer one contradicting it are&lt;br&gt;
recalled &lt;em&gt;together&lt;/em&gt;. Refresh both on read and they tie on recency, which is the one signal that&lt;br&gt;
lets the newer one win.&lt;/p&gt;

&lt;p&gt;So age now runs from the last time something was &lt;strong&gt;said&lt;/strong&gt;, not the last time it was &lt;strong&gt;read&lt;/strong&gt;.&lt;br&gt;
Measured against the running system, after three recalls of both:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Memory&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"switched the editor to the light theme" (fresh)&lt;/td&gt;
&lt;td&gt;0.765&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"prefers the dark theme" (90 days old)&lt;/td&gt;
&lt;td&gt;0.102&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;…then the dark-theme preference is restated&lt;/td&gt;
&lt;td&gt;0.817&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The test suite had a recency test, and it passed the whole time. It backdated &lt;code&gt;created_at&lt;/code&gt; — the&lt;br&gt;
same column the bug read. The test and the code shared an assumption, so the test could only ever&lt;br&gt;
confirm it. The new test fails against the old query; I checked by putting the old line back.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug three: found by building the next thing
&lt;/h2&gt;

&lt;p&gt;With those fixed, I built what I'd meant to build anyway: a Kafka Connect sink, so a table that&lt;br&gt;
Debezium streams into Kafka becomes a corpus that follows the database.&lt;/p&gt;

&lt;p&gt;Its first end-to-end run showed a batch of changes failing with HTTP 500, twice, then succeeding on&lt;br&gt;
the third attempt. The sink's backoff did its job. The backend's log said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="nv"&gt;"ingest_events"&lt;/span&gt; &lt;span class="n"&gt;violates&lt;/span&gt; &lt;span class="k"&gt;foreign&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt;
&lt;span class="k"&gt;Key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;present&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="nv"&gt;"documents"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;DELETE&lt;/code&gt; event removed the document, then recorded the event row pointing at the id it had just&lt;br&gt;
deleted. The foreign key rejected it. Because the batch loop didn't catch it, every event in the&lt;br&gt;
batch failed with it.&lt;/p&gt;

&lt;p&gt;And it only happened once per document. The retry found nothing to delete, recorded a null id, and&lt;br&gt;
succeeded. Any pipeline that retries — which is every pipeline worth running — hid it completely.&lt;br&gt;
There were no tests for the event API at all, so nothing else was going to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the sink
&lt;/h2&gt;

&lt;p&gt;A few decisions that are easy to get wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The event id comes from Kafka, not from Debezium.&lt;/strong&gt; Ossian's event API is idempotent on a&lt;br&gt;
caller-supplied id, so the sink needs one that is stable under redelivery. Debezium's source position&lt;br&gt;
looks ideal and isn't: every row of an initial snapshot shares one LSN, so two different rows would&lt;br&gt;
collapse into one id and the second would be silently discarded as a duplicate. The sink uses the&lt;br&gt;
connector name, topic, partition and offset, plus the record timestamp to survive a topic being&lt;br&gt;
recreated with offsets starting from zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A blanked row is removed, not skipped.&lt;/strong&gt; If an update empties every text column, leaving the old&lt;br&gt;
document in place means the corpus keeps answering from text the source no longer has. If &lt;em&gt;none&lt;/em&gt; of&lt;br&gt;
the configured columns exist on the record, though, it's rejected as a misconfiguration — otherwise a&lt;br&gt;
typo in &lt;code&gt;ossian.text.fields&lt;/code&gt; deletes the whole table from the corpus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Placeholders are refused.&lt;/strong&gt; With Postgres' default replica identity, an update that doesn't touch&lt;br&gt;
a large column sends Debezium's &lt;code&gt;__debezium_unavailable_value&lt;/code&gt; instead of the text. Indexing that&lt;br&gt;
would replace a real article with a sentinel string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offsets never move ahead of delivery.&lt;/strong&gt; &lt;code&gt;put()&lt;/code&gt; is synchronous. Rate limits and 5xx back off using&lt;br&gt;
&lt;code&gt;Retry-After&lt;/code&gt; and retry; a record Ossian rejects goes to the dead-letter queue while the rest keep&lt;br&gt;
flowing; a 401 stops the task, so a bad key can't drain an entire topic into the DLQ.&lt;/p&gt;

&lt;p&gt;End to end against a Postgres table: a snapshot of three rows became three documents answerable with&lt;br&gt;
citations; an update from 180 to 90 days changed the answer and left no old chunk still saying 180;&lt;br&gt;
a delete removed the document and its chunks; a blanked row disappeared; and resetting the sink's&lt;br&gt;
offsets replayed the whole topic — 18 events before, 18 after, no new documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still open
&lt;/h2&gt;

&lt;p&gt;The original question isn't fully answered, and I said so in the reply.&lt;/p&gt;

&lt;p&gt;Memories have no link back to the document they were learned from, so a memory that goes stale when&lt;br&gt;
its source document changes is never flagged. And deduplication is exact: "prefers dark mode" and&lt;br&gt;
"likes dark mode" both persist, and a contradicting memory doesn't supersede the older one — both come&lt;br&gt;
back, and recency decides. Keeping documents and memory apart is staying. The rest is an&lt;br&gt;
&lt;a href="https://github.com/dockndevai/ossian/issues/3" rel="noopener noreferrer"&gt;open design discussion&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson I keep relearning
&lt;/h2&gt;

&lt;p&gt;None of these three were exotic. Each survived because something &lt;em&gt;around&lt;/em&gt; it agreed with it: a test&lt;br&gt;
that backdated the same column the query read, a citation format that looked right in every&lt;br&gt;
screenshot, a retry loop that turned a deterministic failure into a transient one.&lt;/p&gt;

&lt;p&gt;The fastest way I know to find that kind of bug is to explain the system to someone who asks a&lt;br&gt;
precise question — and check the code before you hit send.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Ossian: &lt;a href="https://dockndevai.github.io/ossian-site/" rel="noopener noreferrer"&gt;dockndevai.github.io/ossian-site&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://github.com/dockndevai/ossian" rel="noopener noreferrer"&gt;github.com/dockndevai/ossian&lt;/a&gt; ·&lt;br&gt;
Kafka Connect sink: &lt;a href="https://github.com/dockndevai/ossian-kafka-connect" rel="noopener noreferrer"&gt;github.com/dockndevai/ossian-kafka-connect&lt;/a&gt; ·&lt;br&gt;
Issues: &lt;a href="https://github.com/dockndevai/ossian/issues/1" rel="noopener noreferrer"&gt;#1&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://github.com/dockndevai/ossian/issues/2" rel="noopener noreferrer"&gt;#2&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://github.com/dockndevai/ossian/issues/5" rel="noopener noreferrer"&gt;#5&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dockndev.medium.com/a-question-on-discord-found-two-bugs-in-my-rag-system-fixing-them-found-a-third-03a5cd6237d9" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with Claude (Anthropic), working in the codebase it describes; every number and code sample&lt;br&gt;
was verified against the running system. Reviewed and published by&lt;br&gt;
&lt;a href="https://github.com/dockndevai" rel="noopener noreferrer"&gt;@dockndevai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>kafka</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Filter Ordering Is the Whole Game: Building an LLM Gateway on Spring Cloud Gateway</title>
      <dc:creator>Ankit Verma</dc:creator>
      <pubDate>Fri, 28 Aug 2026 12:15:00 +0000</pubDate>
      <link>https://dev.to/dockndevai/filter-ordering-is-the-whole-game-building-an-llm-gateway-on-spring-cloud-gateway-52p3</link>
      <guid>https://dev.to/dockndevai/filter-ordering-is-the-whole-game-building-an-llm-gateway-on-spring-cloud-gateway-52p3</guid>
      <description>&lt;p&gt;Every team that runs more than one self-hosted model eventually builds the same thing. Someone&lt;br&gt;
stands up vLLM for a 70B model, someone else runs Ollama for the small stuff, and within a month&lt;br&gt;
you need to answer questions nobody asked at the start: &lt;em&gt;which team burned 40 million tokens last&lt;br&gt;
week?&lt;/em&gt; &lt;em&gt;Can we stop the analytics service from calling the expensive model?&lt;/em&gt; &lt;em&gt;What happens when&lt;br&gt;
vLLM falls over mid-sprint?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Spring Cloud Gateway already solves routing, retries and circuit breaking. What it doesn't know is&lt;br&gt;
that the interesting part of an LLM request is &lt;em&gt;inside the JSON body&lt;/em&gt;, and that the interesting&lt;br&gt;
part of the response is a &lt;code&gt;usage&lt;/code&gt; object that arrives last. So I built a starter that adds exactly&lt;br&gt;
that  -  virtual keys, token quotas, usage metering and failover  -  as a library you drop into an&lt;br&gt;
existing gateway.&lt;/p&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/dockndevai/spring-llm-gateway" rel="noopener noreferrer"&gt;dockndevai/spring-llm-gateway&lt;/a&gt;.&lt;br&gt;
This post is about the parts that were harder than they looked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8sw3n667gza4w7v8rf9t.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8sw3n667gza4w7v8rf9t.gif" alt="One endpoint routing to local Ollama and an NVIDIA GPU cloud, with virtual keys enforced" width="799" height="481"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Counting requests is the wrong unit
&lt;/h2&gt;

&lt;p&gt;The stock &lt;code&gt;RequestRateLimiter&lt;/code&gt; counts requests. For LLM traffic that number means almost nothing  - &lt;br&gt;
one request can be worth four tokens or forty thousand. A per-minute &lt;em&gt;token&lt;/em&gt; budget is the only&lt;br&gt;
limit that maps to load and spend.&lt;/p&gt;

&lt;p&gt;That creates an awkward problem: you don't know what a request costs until it's finished. So the&lt;br&gt;
quota filter reserves an estimate up front  - &lt;br&gt;
&lt;code&gt;prompt_chars / chars_per_token + max_tokens&lt;/code&gt;  -  and reconciles afterwards against the real usage&lt;br&gt;
scraped off the response. Over-reservations get refunded, under-reservations charged.&lt;/p&gt;

&lt;p&gt;Reserving up front is the part that matters. If you only charged after the fact, a burst of&lt;br&gt;
concurrent requests would all pass a check that each of them individually would fail. You can watch&lt;br&gt;
the reconciliation work in the response headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;req 1: HTTP 200  remaining=138
req 2: HTTP 200  remaining=98
req 3: HTTP 200  remaining=52
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each request reserves 62 and actually uses ~40, so ~22 comes back before the next one is measured.&lt;br&gt;
The numbers only add up because the refund happened.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reading the response without consuming it
&lt;/h2&gt;

&lt;p&gt;Metering needs the &lt;code&gt;usage&lt;/code&gt; object, which means reading the response body  -  without breaking&lt;br&gt;
streaming for the client.&lt;/p&gt;

&lt;p&gt;Two things make this tractable. First, &lt;code&gt;DataBuffer.toString(Charset)&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; advance the read&lt;br&gt;
position, so you can observe a chunk and still pass it downstream untouched. Second, &lt;code&gt;usage&lt;/code&gt; is&lt;br&gt;
last in both response shapes: a top-level field on a non-streamed response, and the final &lt;code&gt;data:&lt;/code&gt;&lt;br&gt;
frame of an SSE stream. So you never buffer the whole body  -  just a bounded 8 KB rolling tail.&lt;/p&gt;

&lt;p&gt;The parsing has one trap. The obvious regex is &lt;code&gt;"usage"\s*:\s*\{[^{}]*\}&lt;/code&gt;, and it works right up&lt;br&gt;
until it doesn't: current vLLM and OpenAI builds nest &lt;code&gt;prompt_tokens_details&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;completion_tokens_details&lt;/code&gt; &lt;em&gt;inside&lt;/em&gt; the usage object, and a flat character class can't match&lt;br&gt;
across nested braces. I ended up with a brace-balanced scan that walks backwards from the last&lt;br&gt;
&lt;code&gt;"usage"&lt;/code&gt; and skips anything truncated or &lt;code&gt;null&lt;/code&gt;  -  which also handles the intermediate stream&lt;br&gt;
frames, where &lt;code&gt;"usage":null&lt;/code&gt; appears on every chunk.&lt;/p&gt;

&lt;p&gt;There's one more thing you have to do, and it's easy to miss: &lt;strong&gt;vLLM never sends a usage block for a&lt;br&gt;
stream unless you ask for it.&lt;/strong&gt; If the request doesn't set &lt;code&gt;stream_options.include_usage&lt;/code&gt;, every&lt;br&gt;
streamed request meters as zero tokens. The gateway injects it when absent  -  and then has to fix&lt;br&gt;
&lt;code&gt;Content-Length&lt;/code&gt; on the request decorator, because the body just got longer.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ordering is the actual design
&lt;/h2&gt;

&lt;p&gt;This is where most of the real work went. Spring Cloud Gateway assembles the filter chain &lt;em&gt;after&lt;/em&gt;&lt;br&gt;
route selection, and the orders interact in ways that are invisible until something silently&lt;br&gt;
returns zero.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Order&lt;/th&gt;
&lt;th&gt;Filter&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MIN_VALUE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RemoveCachedBodyFilter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MIN_VALUE + 1000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AdaptCachedBodyGlobalFilter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MIN_VALUE + 1500&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;body parsing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;mine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;metering&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;mine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NettyWriteResponseFilter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1, 2, 3...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;route filters  -  auth, quota, circuit breaker&lt;/td&gt;
&lt;td&gt;by list position&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LOWEST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NettyRoutingFilter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stock&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three constraints hold it together:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Body parsing must run after &lt;code&gt;AdaptCachedBodyGlobalFilter&lt;/code&gt;.&lt;/strong&gt; The route predicate that selects on&lt;br&gt;
&lt;code&gt;model&lt;/code&gt; has to read the body during route selection. That consumes it. &lt;code&gt;AdaptCachedBodyGlobalFilter&lt;/code&gt;&lt;br&gt;
is what swaps the cached bytes back onto the exchange  -  parse any earlier and you read an empty&lt;br&gt;
body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metering must run before &lt;code&gt;NettyWriteResponseFilter&lt;/code&gt;&lt;/strong&gt; (order &amp;lt; -1). That filter captures its own&lt;br&gt;
&lt;code&gt;exchange.getResponse()&lt;/code&gt; reference before writing. Install a response decorator later in the chain&lt;br&gt;
and it is simply never written into. No error, no warning  -  every request just meters as zero&lt;br&gt;
tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route filters are ordered 1, 2, 3... by their position in the YAML list&lt;/strong&gt;, which puts them after&lt;br&gt;
both global filters. That's what lets auth and quota rely on the parsed body already being there.&lt;/p&gt;

&lt;p&gt;I nearly got this wrong in an interesting way. I started to force explicit orders on the auth and&lt;br&gt;
quota filters to guarantee auth ran first  -  then found that default filters and route filters are&lt;br&gt;
index-ordered into a &lt;em&gt;single stably sorted list&lt;/em&gt;. With &lt;code&gt;LlmAuth&lt;/code&gt;/&lt;code&gt;LlmQuota&lt;/code&gt; in &lt;code&gt;default-filters&lt;/code&gt;&lt;br&gt;
and a route-level &lt;code&gt;CircuitBreaker&lt;/code&gt;, forcing orders interleaves them as auth -&amp;gt; breaker -&amp;gt; quota.&lt;br&gt;
Keeping the natural index ordering and documenting it was the right call.&lt;/p&gt;

&lt;p&gt;One more: the rewritten request's &lt;code&gt;getBody()&lt;/code&gt; has to be re-subscribable. A &lt;code&gt;DataBuffer&lt;/code&gt; can only be&lt;br&gt;
read once, and both retries and the circuit breaker re-subscribe. &lt;code&gt;Flux.defer&lt;/code&gt; wrapping a freshly&lt;br&gt;
wrapped byte array on every subscription.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two reactive traps that produce no error
&lt;/h2&gt;

&lt;p&gt;These both cost me real time, and neither throws anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Mono&amp;lt;Void&amp;gt;&lt;/code&gt; and &lt;code&gt;switchIfEmpty&lt;/code&gt;.&lt;/strong&gt; The auth filter looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;principalResolver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flatMap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;authorize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;switchIfEmpty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defer&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;unauthorized&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;  &lt;span class="c1"&gt;// wrong&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reads fine. It's broken. &lt;code&gt;chain.filter()&lt;/code&gt; returns &lt;code&gt;Mono&amp;lt;Void&amp;gt;&lt;/code&gt;, which &lt;em&gt;always&lt;/em&gt; completes empty  -  so&lt;br&gt;
&lt;code&gt;switchIfEmpty&lt;/code&gt; fired on every &lt;strong&gt;successful&lt;/strong&gt; request. The upstream was called and then a 401 was&lt;br&gt;
written over the top of it. The tell was a log line showing the stub upstream had received the&lt;br&gt;
request while the client got a 401. Fold the &lt;code&gt;Optional&lt;/code&gt; explicitly instead; don't put&lt;br&gt;
&lt;code&gt;switchIfEmpty&lt;/code&gt; downstream of anything returning &lt;code&gt;Mono&amp;lt;Void&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;exchangeToMono&lt;/code&gt; releases the connection when its Mono terminates.&lt;/strong&gt; The failover handler fetched&lt;br&gt;
the secondary upstream's response and handed the body to a &lt;code&gt;ServerResponse&lt;/code&gt; to be written later.&lt;br&gt;
Status 200, correct headers, empty body  -  because the &lt;code&gt;ServerResponse&lt;/code&gt; Mono completed immediately,&lt;br&gt;
WebClient released the connection, and &lt;em&gt;then&lt;/em&gt; something tried to read the body. For a streaming&lt;br&gt;
proxy you want a plain &lt;code&gt;WebHandler&lt;/code&gt; that writes straight to the exchange inside the callback, so the&lt;br&gt;
connection stays alive exactly as long as the body is streaming.&lt;/p&gt;

&lt;h2&gt;
  
  
  What only showed up when I ran it
&lt;/h2&gt;

&lt;p&gt;The build was green and the feature list was complete. Then I ran it against a real Ollama on a&lt;br&gt;
laptop, and four things fell out immediately.&lt;/p&gt;

&lt;p&gt;The worst: &lt;strong&gt;&lt;code&gt;auth.mode=jwt&lt;/code&gt; wouldn't start at all.&lt;/strong&gt; The Keycloak resolver bean was guarded by&lt;br&gt;
&lt;code&gt;@ConditionalOnBean(ReactiveJwtDecoder.class)&lt;/code&gt;  -  but my auto-configuration wasn't ordered after&lt;br&gt;
&lt;code&gt;ReactiveOAuth2ResourceServerAutoConfiguration&lt;/code&gt;, so the condition was evaluated before that decoder&lt;br&gt;
existed and backed the bean off entirely. The application then died complaining about a missing&lt;br&gt;
&lt;code&gt;PrincipalResolver&lt;/code&gt;, which points nowhere near the actual cause.&lt;/p&gt;

&lt;p&gt;My tests missed it because I'd unit-tested the resolver class directly and never booted the&lt;br&gt;
application in that mode. That's the lesson worth keeping: &lt;strong&gt;&lt;code&gt;@ConditionalOnBean&lt;/code&gt; on a bean from&lt;br&gt;
another auto-configuration is an ordering bug waiting to happen&lt;/strong&gt;, and testing a class is not&lt;br&gt;
testing its wiring. The fix was &lt;code&gt;afterName&lt;/code&gt; ordering plus dropping the condition so a genuinely&lt;br&gt;
missing decoder names &lt;em&gt;itself&lt;/em&gt; in the error. I wrote the integration test first and confirmed it&lt;br&gt;
failed against the old wiring before fixing it.&lt;/p&gt;

&lt;p&gt;The other three were smaller but all real: the README's own quick-start command didn't work&lt;br&gt;
(&lt;code&gt;-am&lt;/code&gt; on &lt;code&gt;spring-boot:run&lt;/code&gt; drags the parent POM into the reactor  -  "Unable to find a suitable main&lt;br&gt;
class"); &lt;code&gt;ollama: {}&lt;/code&gt; in YAML binds to &lt;em&gt;nothing&lt;/em&gt;, because Spring's binder needs at least one leaf&lt;br&gt;
property to create a map entry; and my failover tests used &lt;code&gt;localhost:1&lt;/code&gt; as the "dead" port, which&lt;br&gt;
on macOS burns six DNS queries and ~10 seconds before failing instead of refusing. With a properly&lt;br&gt;
closed port it refuses in 50ms, and the test got 4x faster.&lt;/p&gt;

&lt;p&gt;That last one produced a false alarm I had to walk back. I'd measured failover at 15 seconds and&lt;br&gt;
started writing it up as a performance problem. It was entirely an artifact of the port I'd picked&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; real failover is 0.23 seconds end to end, including the model generating a reply.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Worth knowing if you build on SCG 4.3
&lt;/h2&gt;

&lt;p&gt;A few things that cost me time and aren't well signposted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The config prefix moved. It's &lt;code&gt;spring.cloud.gateway.server.webflux.*&lt;/code&gt; now, and the starter is
&lt;code&gt;spring-cloud-starter-gateway-server-webflux&lt;/code&gt;. The old &lt;code&gt;spring-cloud-starter-gateway&lt;/code&gt; is a
deprecated alias.&lt;/li&gt;
&lt;li&gt;Model ids contain dots, so config map keys need bracket notation:
&lt;code&gt;models."[llama-3.1-70b-instruct]"&lt;/code&gt;. The dotted form binds as nested properties and silently
never matches.&lt;/li&gt;
&lt;li&gt;Multi-argument filters need the expanded &lt;code&gt;args&lt;/code&gt; form. &lt;code&gt;CircuitBreaker=name=x,fallbackUri=y&lt;/code&gt; binds
only the &lt;em&gt;first&lt;/em&gt; positional argument and drops &lt;code&gt;fallbackUri&lt;/code&gt; without a word of complaint. I lost a
while to a fallback that was never being invoked.&lt;/li&gt;
&lt;li&gt;Raise resilience4j's &lt;code&gt;TimeLimiter&lt;/code&gt;. The 1-second default will trip on healthy generation traffic.&lt;/li&gt;
&lt;li&gt;Building on JDK 23+? Annotation processors on the classpath are off by default, so
&lt;code&gt;spring-boot-configuration-processor&lt;/code&gt; silently produces no metadata and your IDE completion
quietly stops working. &lt;code&gt;&amp;lt;proc&amp;gt;full&amp;lt;/proc&amp;gt;&lt;/code&gt; brings it back.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shape that worked
&lt;/h2&gt;

&lt;p&gt;The thing I'd repeat on the next one: lean on the framework's machinery instead of reimplementing&lt;br&gt;
it. Failover here is the stock &lt;code&gt;CircuitBreaker&lt;/code&gt; filter with &lt;code&gt;fallbackUri: forward:/__llm/fallback&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; I supply only the replay handler. The state machine, the sliding window, the half-open probing
all stay Spring's. Same with the model predicate, which is modelled directly on Spring's own
&lt;code&gt;ReadBodyRoutePredicateFactory&lt;/code&gt; rather than inventing a body-caching scheme.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing is a dependency plus YAML. If you already run Spring Cloud Gateway, you add one&lt;br&gt;
artifact and two filter names and you have virtual keys, token quotas, per-tenant metering and&lt;br&gt;
failover.&lt;/p&gt;

&lt;p&gt;Code, README and the full test suite: &lt;a href="https://github.com/dockndevai/spring-llm-gateway" rel="noopener noreferrer"&gt;github.com/dockndevai/spring-llm-gateway&lt;/a&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>llm</category>
      <category>architecture</category>
    </item>
    <item>
      <title>8 safe-by-default MCP servers for infra — and the governance model behind them</title>
      <dc:creator>Ankit Verma</dc:creator>
      <pubDate>Sun, 23 Aug 2026 16:26:47 +0000</pubDate>
      <link>https://dev.to/dockndevai/8-safe-by-default-mcp-servers-formcp-ai-devops-opensource-infra-and-the-governance-model-4gi8</link>
      <guid>https://dev.to/dockndevai/8-safe-by-default-mcp-servers-formcp-ai-devops-opensource-infra-and-the-governance-model-4gi8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🚀 &lt;strong&gt;Update (Sep 8):&lt;/strong&gt; the suite is now &lt;strong&gt;11 servers&lt;/strong&gt; (added Grafana) and is &lt;strong&gt;live on Product Hunt today&lt;/strong&gt; → &lt;a href="https://www.producthunt.com/products/dockndevai-mcp-server-suite" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/dockndevai-mcp-server-suite&lt;/a&gt; — feedback + support very welcome!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Giving an AI agent access to production infrastructure is a great way to move fast — and a great way to have it drop a database because a prompt was ambiguous. I wanted the upside without the footguns, so I built a family of &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; servers that share one safe-by-default governance model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;MCP lets an agent call tools. For infra, those tools can be &lt;code&gt;delete_topic&lt;/code&gt;, &lt;code&gt;DROP TABLE&lt;/code&gt;, &lt;code&gt;delete_resource_group&lt;/code&gt;. The usual answer is "just don't expose the dangerous ones" — but then the server isn't useful when you &lt;em&gt;do&lt;/em&gt; need them. I wanted graduated, explicit control instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The governance model
&lt;/h2&gt;

&lt;p&gt;Every server shares the same layered policy engine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access modes&lt;/strong&gt; — &lt;code&gt;read-only&lt;/code&gt; → &lt;code&gt;read-write&lt;/code&gt; → &lt;code&gt;admin&lt;/code&gt;. Tools above the current mode are &lt;em&gt;never even registered&lt;/em&gt;, so the model can't call what it can't see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allowlists&lt;/strong&gt; — scope to specific realms / namespaces / topics / databases / projects / subscriptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protected resources&lt;/strong&gt; — system/prod resources (kube-system, the &lt;code&gt;system&lt;/code&gt; DB, internal Kafka topics, the master realm) are readable but never mutable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Destructive gating&lt;/strong&gt; — deletes need an explicit &lt;code&gt;ALLOW_DELETE&lt;/code&gt; flag on top of admin mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typed confirmation&lt;/strong&gt; — high-impact ops (delete a project / resource group) require a &lt;code&gt;confirm&lt;/code&gt; value that echoes the exact target name. A boolean isn't enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret redaction&lt;/strong&gt; — credentials are stripped before anything reaches the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dry-run + audit&lt;/strong&gt; — preview writes without executing; every guarded op emits a JSON audit line to stderr.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The servers
&lt;/h2&gt;

&lt;p&gt;All MIT-licensed, TypeScript, published on npm as &lt;code&gt;@dockndevai/mcp-*&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;mcp-kubernetes&lt;/strong&gt; — pods, logs, deployments, scale/restart, apply, exec&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp-kafka&lt;/strong&gt; — topics, consumer groups + lag, create/alter/reset&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp-clickhouse&lt;/strong&gt; — schema, queries, SQL-classified read/write/destructive gating&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp-debezium&lt;/strong&gt; — CDC connector status, config, lifecycle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp-oci&lt;/strong&gt; — Oracle Cloud discovery + Terraform generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp-azure&lt;/strong&gt; — Azure Resource Manager inventory, tags, VM power, lifecycle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp-azure-devops&lt;/strong&gt; — boards, repos, pipelines, projects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp-keycloak&lt;/strong&gt; — realms, users, clients, roles, groups&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try one
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npx -y @dockndevai/mcp-kubernetes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Or in Claude Code:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claude mcp add kubernetes -e KUBECONFIG_PATH=~/.kube/config -e K8S_MODE=read-only -- npx -y @dockndevai/mcp-kubernetes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Every repo has per-client setup for Claude, Cursor, Codex, VS Code, and Windsurf.&lt;/p&gt;

&lt;p&gt;Repos: &lt;a href="https://github.com/dockndevai" rel="noopener noreferrer"&gt;https://github.com/dockndevai&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'd love feedback — especially on whether the mode + typed-confirmation split is the right default for infrastructure MCP servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Update — v0.1.1: machine-readable safety
&lt;/h2&gt;

&lt;p&gt;Every tool across all 8 servers now ships MCP tool annotations (&lt;code&gt;readOnlyHint&lt;/code&gt;, &lt;code&gt;destructiveHint&lt;/code&gt;, &lt;code&gt;idempotentHint&lt;/code&gt;, &lt;code&gt;openWorldHint&lt;/code&gt;), derived automatically from each tool's access capability. The safe-by-default model is no longer just documented in the README — the host can read it and decide what to auto-approve versus prompt on. A test keeps every hint consistent with its tool's capability. Live now on npm and the official MCP registry.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>devops</category>
      <category>kubernetes</category>
    </item>
  </channel>
</rss>
